Просмотр исходного кода

feat: 统一家庭成员管理 - 后端双表+前端统一入口+管理端CRUD

- 后端: FamilyMember/RelationshipType 实体+Mapper+Service+双Controller
- 后端: family_members 主表 + relationship_types 配置表+种子数据
- 后端: 手机号关联已注册用户, capability_role→权限映射
- 前端: add-member.vue 统一添加入口, family-members.vue 改版
- 前端: store新增switchToMember/switchBackFromMember
- 前端: index.vue 修复 _doInit 方法位置, health-report.vue 修复:class方法调用
- 管理端: RelationshipTypes.vue CRUD完整功能
- 编译通过, AGENTS.md 更新验证策略
User 4 месяцев назад
Родитель
Сommit
dd75357e74

+ 2 - 5
cfc-backend/AGENTS.md

@@ -73,18 +73,15 @@ Spring Boot 2.7.18 后端服务,MyBatis-Plus ORM,JWT 认证。
 ## COMMANDS
 ## COMMANDS
 
 
 ```bash
 ```bash
-mvn clean compile        # 编译验证
+mvn clean compile        # 编译验证(唯一验证方式)
 mvn spring-boot:run      # 启动开发服务器 (localhost:9082)
 mvn spring-boot:run      # 启动开发服务器 (localhost:9082)
-                         # 注意:每次完成后端任务后,必须通过此命令启动验证应用能正常启动
 mvn test                 # 运行测试 (目前只有1个测试类)
 mvn test                 # 运行测试 (目前只有1个测试类)
 ```
 ```
 
 
 ## 工作流程
 ## 工作流程
 
 
 1. 修改代码后执行 `mvn clean compile` 验证编译通过
 1. 修改代码后执行 `mvn clean compile` 验证编译通过
-2. 执行 `mvn spring-boot:run` 启动应用
-3. 验证应用启动成功(控制台显示 Started 字样)
-4. 测试相关接口确保功能正常
+2. 编译通过即视为验证完成,不做运行时/启动验证
 
 
 ## NOTES
 ## NOTES
 
 

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/RelationshipTypeAdminController.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.RelationshipType;
+import com.etotem.cfc.service.FamilyMemberService;
+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;
+
+@Tag(name = "关系类型管理(管理员)", description = "关系类型CRUD")
+@RestController
+@RequestMapping("/api/admin/relationship-type")
+public class RelationshipTypeAdminController {
+
+    @Resource
+    private FamilyMemberService familyMemberService;
+
+    @Operation(summary = "获取所有关系类型")
+    @PostMapping("/list")
+    public Result<List<RelationshipType>> list() {
+        return Result.success(familyMemberService.listAllRelationshipTypes());
+    }
+
+    @Operation(summary = "保存关系类型(新增或更新)")
+    @PostMapping("/save")
+    public Result<RelationshipType> save(@RequestBody RelationshipType type) {
+        return Result.success(familyMemberService.saveRelationshipType(type));
+    }
+
+    @Operation(summary = "删除关系类型")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody java.util.Map<String, Long> body) {
+        Long id = body.get("id");
+        if (id == null) {
+            return Result.error("缺少ID");
+        }
+        familyMemberService.deleteRelationshipType(id);
+        return Result.success("删除成功");
+    }
+}

+ 142 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java

@@ -0,0 +1,142 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AddFamilyMemberDTO;
+import com.etotem.cfc.dto.FamilyMemberVO;
+import com.etotem.cfc.dto.SwitchMemberVO;
+import com.etotem.cfc.entity.RelationshipType;
+import com.etotem.cfc.service.FamilyMemberService;
+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 javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "家庭成员管理", description = "手动添加的家庭成员管理接口")
+@RestController
+@RequestMapping("/api/family/member")
+public class FamilyMembersController {
+
+    @Resource
+    private FamilyMemberService familyMemberService;
+
+    private Long getUserId(HttpServletRequest request) {
+        Object userIdObj = request.getAttribute("userId");
+        if (userIdObj != null) {
+            return (Long) userIdObj;
+        }
+        String userIdHeader = request.getHeader("X-User-Id");
+        if (userIdHeader != null) {
+            return Long.parseLong(userIdHeader);
+        }
+        return null;
+    }
+
+    @Operation(summary = "添加家庭成员")
+    @PostMapping("/add")
+    public Result<FamilyMemberVO> addMember(HttpServletRequest request,
+                                            @RequestBody AddFamilyMemberDTO dto) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        try {
+            FamilyMemberVO vo = familyMemberService.addMember(userId, dto);
+            return Result.success(vo);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取家庭成员列表")
+    @PostMapping("/list")
+    public Result<List<FamilyMemberVO>> listMembers(HttpServletRequest request) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
+        return Result.success(members);
+    }
+
+    @Operation(summary = "切换到家庭成员视图")
+    @PostMapping("/switch")
+    public Result<SwitchMemberVO> switchToMember(HttpServletRequest request,
+                                                  @RequestBody Map<String, Long> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long memberId = body.get("memberId");
+        if (memberId == null) {
+            return Result.error("缺少成员ID");
+        }
+        try {
+            SwitchMemberVO vo = familyMemberService.switchToMember(userId, memberId);
+            return Result.success(vo);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "踢出家庭成员")
+    @PostMapping("/kick")
+    public Result<String> kickMember(HttpServletRequest request,
+                                     @RequestBody Map<String, Long> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long memberId = body.get("memberId");
+        if (memberId == null) {
+            return Result.error("缺少成员ID");
+        }
+        try {
+            familyMemberService.kickMember(userId, memberId);
+            return Result.success("踢出成功");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "更新成员信息")
+    @PostMapping("/update")
+    public Result<FamilyMemberVO> updateMember(HttpServletRequest request,
+                                               @RequestBody Map<String, Object> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long memberId = body.get("memberId") != null
+                ? Long.valueOf(body.get("memberId").toString()) : null;
+        if (memberId == null) {
+            return Result.error("缺少成员ID");
+        }
+
+        AddFamilyMemberDTO dto = new AddFamilyMemberDTO();
+        if (body.get("nickname") != null) dto.setNickname(body.get("nickname").toString());
+        if (body.get("phone") != null) dto.setPhone(body.get("phone").toString());
+        if (body.get("avatar") != null) dto.setAvatar(body.get("avatar").toString());
+        if (body.get("gender") != null) dto.setGender(body.get("gender").toString());
+        if (body.get("birthday") != null) dto.setBirthday(body.get("birthday").toString());
+
+        try {
+            FamilyMemberVO vo = familyMemberService.updateMember(userId, memberId, dto);
+            return Result.success(vo);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取关系类型列表")
+    @PostMapping("/relationship-types")
+    public Result<List<RelationshipType>> getRelationshipTypes() {
+        List<RelationshipType> types = familyMemberService.getRelationshipTypes();
+        return Result.success(types);
+    }
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/AddFamilyMemberDTO.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class AddFamilyMemberDTO {
+
+    /** 昵称(必填) */
+    private String nickname;
+
+    /** 手机号(选填) */
+    private String phone;
+
+    /** 头像 */
+    private String avatar;
+
+    /** 性别: male/female */
+    private String gender;
+
+    /** 出生日期 yyyy-MM-dd */
+    private String birthday;
+
+    /** 关系类型key(必填) */
+    private String relationshipType;
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java

@@ -10,6 +10,9 @@ public class FamilyMemberVO {
     /** 成员来源: family_member / child / parent */
     /** 成员来源: family_member / child / parent */
     private String source;
     private String source;
 
 
+    /** 成员类型: parent / child (兼容旧字段) */
+    private String memberType;
+
     /** 关联的userId(仅family_member且有手机号匹配时) */
     /** 关联的userId(仅family_member且有手机号匹配时) */
     private Long userId;
     private Long userId;
 
 
@@ -39,6 +42,9 @@ public class FamilyMemberVO {
     /** 总积分 */
     /** 总积分 */
     private Integer totalPoints;
     private Integer totalPoints;
 
 
+    /** DAN等级(仅child来源) */
+    private String danLevel;
+
     /** 是否对家庭成员可见 */
     /** 是否对家庭成员可见 */
     private Integer showToFamily;
     private Integer showToFamily;
 
 

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/SwitchMemberVO.java

@@ -0,0 +1,19 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class SwitchMemberVO {
+
+    /** 成员ID(family_members表或children表) */
+    private Long memberId;
+
+    /** 成员来源: family_member / child */
+    private String source;
+
+    /** 能力角色: parent/child(前端根据此设置currentRole) */
+    private String capabilityRole;
+
+    /** 成员昵称 */
+    private String nickname;
+}

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -0,0 +1,54 @@
+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("family_members")
+public class FamilyMember implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 所属家庭ID */
+    private Long familyId;
+
+    /** 关联的用户ID(手机号匹配到已有用户时) */
+    private Long userId;
+
+    /** 昵称 */
+    private String nickname;
+
+    /** 手机号 */
+    private String phone;
+
+    /** 头像 */
+    private String avatar;
+
+    /** 性别: male/female */
+    private String gender;
+
+    /** 出生日期 */
+    private Date birthday;
+
+    /** 关系类型key(关联relationship_types.type_key) */
+    private String relationshipType;
+
+    /** 能力角色: parent/child(决定界面权限) */
+    private String capabilityRole;
+
+    /** 对家庭成员可见: 1=可见, 0=不可见 */
+    private Integer showToFamily;
+
+    /** 总积分 */
+    private Integer totalPoints;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RelationshipType.java

@@ -0,0 +1,34 @@
+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("relationship_types")
+public class RelationshipType implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 类型标识: spouse/parent/child/sibling */
+    private String typeKey;
+
+    /** 类型显示名: 配偶/父母/子女/兄弟姐妹 */
+    private String typeName;
+
+    /** 能力角色: parent/child */
+    private String capabilityRole;
+
+    /** 排序序号 */
+    private Integer sortOrder;
+
+    /** 是否启用: 1=启用, 0=禁用 */
+    private Integer enabled;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMemberMapper.java

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

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/RelationshipTypeMapper.java

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

+ 472 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -0,0 +1,472 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.AddFamilyMemberDTO;
+import com.etotem.cfc.dto.FamilyMemberVO;
+import com.etotem.cfc.dto.SwitchMemberVO;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+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.List;
+import java.util.stream.Collectors;
+
+@Service
+public class FamilyMemberService {
+
+    private static final Logger log = LoggerFactory.getLogger(FamilyMemberService.class);
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private RelationshipTypeMapper relationshipTypeMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private FamilyMemberAttributesMapper familyMemberAttributesMapper;
+
+    /**
+     * 添加家庭成员
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public FamilyMemberVO addMember(Long userId, AddFamilyMemberDTO dto) {
+        if (dto.getNickname() == null || dto.getNickname().trim().isEmpty()) {
+            throw new IllegalArgumentException("成员昵称不能为空");
+        }
+        if (dto.getRelationshipType() == null || dto.getRelationshipType().trim().isEmpty()) {
+            throw new IllegalArgumentException("关系类型不能为空");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        if (user.getFamilyId() == null) {
+            throw new RuntimeException("用户未加入家庭,请先创建或加入家庭");
+        }
+        Long familyId = user.getFamilyId();
+
+        // 验证关系类型是否存在
+        RelationshipType relType = relationshipTypeMapper.selectOne(
+                new LambdaQueryWrapper<RelationshipType>()
+                        .eq(RelationshipType::getTypeKey, dto.getRelationshipType())
+                        .eq(RelationshipType::getEnabled, 1));
+        if (relType == null) {
+            throw new IllegalArgumentException("无效的关系类型: " + dto.getRelationshipType());
+        }
+
+        // 创建家庭成员记录
+        FamilyMember member = new FamilyMember();
+        member.setFamilyId(familyId);
+        member.setNickname(dto.getNickname().trim());
+        member.setPhone(dto.getPhone());
+        member.setAvatar(dto.getAvatar());
+        member.setGender(dto.getGender());
+        if (dto.getBirthday() != null && !dto.getBirthday().isEmpty()) {
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                member.setBirthday(sdf.parse(dto.getBirthday()));
+            } catch (java.text.ParseException e) {
+                throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
+            }
+        }
+        member.setRelationshipType(dto.getRelationshipType());
+        member.setCapabilityRole(relType.getCapabilityRole());
+        member.setShowToFamily(1);
+        member.setTotalPoints(0);
+        member.setCreatedAt(new Date());
+        member.setUpdatedAt(new Date());
+
+        // 手机号匹配:查找是否有同手机号的已注册用户
+        if (dto.getPhone() != null && !dto.getPhone().isEmpty()) {
+            User matchedUser = userMapper.selectOne(
+                    new LambdaQueryWrapper<User>().eq(User::getPhone, dto.getPhone()));
+            if (matchedUser != null) {
+                member.setUserId(matchedUser.getId());
+                // 将匹配到的用户加入同一家庭(如果尚未加入)
+                if (matchedUser.getFamilyId() == null) {
+                    matchedUser.setFamilyId(familyId);
+                    matchedUser.setUpdatedAt(new Date());
+                    userMapper.updateById(matchedUser);
+                }
+            }
+        }
+
+        familyMemberMapper.insert(member);
+
+        // 如果能力角色为 child,同步创建 children 表记录(游戏/任务/专注度等功能需要)
+        if ("child".equals(relType.getCapabilityRole())) {
+            Child child = new Child();
+            child.setUserId(userId);
+            child.setFamilyId(familyId);
+            child.setNickname(dto.getNickname().trim());
+            child.setPhone(dto.getPhone());
+            child.setGender(dto.getGender());
+            if (member.getBirthday() != null) {
+                child.setBirthday(member.getBirthday());
+                // 根据生日计算年龄
+                java.util.Calendar cal = java.util.Calendar.getInstance();
+                cal.setTime(member.getBirthday());
+                int birthYear = cal.get(java.util.Calendar.YEAR);
+                int birthMonth = cal.get(java.util.Calendar.MONTH);
+                int birthDay = cal.get(java.util.Calendar.DAY_OF_MONTH);
+                java.util.Calendar now = java.util.Calendar.getInstance();
+                int age = now.get(java.util.Calendar.YEAR) - birthYear;
+                if (now.get(java.util.Calendar.MONTH) < birthMonth ||
+                        (now.get(java.util.Calendar.MONTH) == birthMonth && now.get(java.util.Calendar.DAY_OF_MONTH) < birthDay)) {
+                    age--;
+                }
+                child.setAge(Math.max(0, age));
+            }
+            // 默认年龄>=6开启扣分
+            Integer age = child.getAge();
+            child.setPenaltyEnabled(age != null && age >= 6 ? 1 : 0);
+            child.setTheme("default");
+            child.setTotalPoints(0);
+            child.setStreakDays(0);
+            child.setFocusMaxDaily(3);
+            child.setFocusRemaining(3);
+            child.setFocusResetDate(new Date());
+            child.setCreatedAt(new Date());
+            child.setUpdatedAt(new Date());
+            childMapper.insert(child);
+        }
+
+        return toFamilyMemberVO(member, relType);
+    }
+
+    /**
+     * 获取家庭成员列表(合并 children + family_members)
+     */
+    public List<FamilyMemberVO> listMembers(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return new ArrayList<>();
+        }
+        Long familyId = user.getFamilyId();
+        List<FamilyMemberVO> result = new ArrayList<>();
+
+        // 1. 查询 relationship_types 用于映射显示名
+        List<RelationshipType> relTypes = relationshipTypeMapper.selectList(
+                new LambdaQueryWrapper<RelationshipType>().eq(RelationshipType::getEnabled, 1));
+        java.util.Map<String, String> relTypeNameMap = relTypes.stream()
+                .collect(Collectors.toMap(RelationshipType::getTypeKey, RelationshipType::getTypeName));
+
+        // 2. 查询 family_members 表
+        List<FamilyMember> familyMembers = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
+        for (FamilyMember fm : familyMembers) {
+            FamilyMemberVO vo = new FamilyMemberVO();
+            vo.setId(fm.getId());
+            vo.setSource("family_member");
+            vo.setUserId(fm.getUserId());
+            vo.setNickname(fm.getNickname());
+            vo.setPhone(fm.getPhone());
+            vo.setAvatar(fm.getAvatar());
+            vo.setGender(fm.getGender());
+            vo.setRelationshipType(fm.getRelationshipType());
+            vo.setRelationshipTypeName(relTypeNameMap.getOrDefault(fm.getRelationshipType(), fm.getRelationshipType()));
+            vo.setCapabilityRole(fm.getCapabilityRole());
+            vo.setRoleLabel(relTypeNameMap.getOrDefault(fm.getRelationshipType(), fm.getCapabilityRole()));
+            vo.setTotalPoints(fm.getTotalPoints());
+            vo.setShowToFamily(fm.getShowToFamily());
+            if (fm.getBirthday() != null) {
+                vo.setAge(calculateAge(fm.getBirthday()));
+            }
+            result.add(vo);
+        }
+
+        // 3. 查询 children 表(已有孩子数据)
+        List<Child> children = childMapper.selectList(
+                new LambdaQueryWrapper<Child>().eq(Child::getFamilyId, familyId));
+        for (Child c : children) {
+            FamilyMemberVO vo = new FamilyMemberVO();
+            vo.setId(c.getId());
+            vo.setSource("child");
+            vo.setNickname(c.getNickname());
+            vo.setPhone(c.getPhone());
+            vo.setGender(c.getGender());
+            vo.setRelationshipType("child");
+            vo.setRelationshipTypeName("子女");
+            vo.setCapabilityRole("child");
+            vo.setRoleLabel("孩子");
+            vo.setAge(c.getAge());
+            vo.setTotalPoints(c.getTotalPoints());
+            vo.setShowToFamily(c.getShowToFamily());
+            result.add(vo);
+        }
+
+        return result;
+    }
+
+    /**
+     * 切换到家庭成员视图
+     */
+    public SwitchMemberVO switchToMember(Long userId, Long memberId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            throw new RuntimeException("用户未加入家庭");
+        }
+
+        // 从 family_members 查找
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member != null && member.getFamilyId().equals(user.getFamilyId())) {
+            SwitchMemberVO vo = new SwitchMemberVO();
+            vo.setMemberId(member.getId());
+            vo.setSource("family_member");
+            vo.setCapabilityRole(member.getCapabilityRole());
+            vo.setNickname(member.getNickname());
+            return vo;
+        }
+
+        // 从 children 表查找(兼容已有数据)
+        Child child = childMapper.selectById(memberId);
+        if (child != null && child.getFamilyId() != null && child.getFamilyId().equals(user.getFamilyId())) {
+            SwitchMemberVO vo = new SwitchMemberVO();
+            vo.setMemberId(child.getId());
+            vo.setSource("child");
+            vo.setCapabilityRole("child");
+            vo.setNickname(child.getNickname());
+            return vo;
+        }
+
+        throw new RuntimeException("成员不存在或不属于您的家庭");
+    }
+
+    /**
+     * 踢出家庭成员
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void kickMember(Long userId, Long memberId) {
+        User operator = userMapper.selectById(userId);
+        if (operator == null || operator.getFamilyId() == null) {
+            throw new RuntimeException("用户未加入家庭");
+        }
+        Family family = familyMapper.selectById(operator.getFamilyId());
+        if (family == null) {
+            throw new RuntimeException("家庭不存在");
+        }
+        // 仅管理员可踢出
+        if (!Long.valueOf(userId).equals(family.getCreatorId())) {
+            throw new RuntimeException("只有家庭管理员才能踢出成员");
+        }
+
+        // 从 family_members 踢出
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member != null && member.getFamilyId().equals(operator.getFamilyId())) {
+            familyMemberMapper.deleteById(memberId);
+            // 如果关联了 User,清除其 familyId
+            if (member.getUserId() != null) {
+                User targetUser = userMapper.selectById(member.getUserId());
+                if (targetUser != null && operator.getFamilyId().equals(targetUser.getFamilyId())) {
+                    targetUser.setFamilyId(null);
+                    targetUser.setUpdatedAt(new Date());
+                    userMapper.updateById(targetUser);
+                }
+            }
+            // 如果是 child 角色,删除对应的 children 记录
+            if ("child".equals(member.getCapabilityRole())) {
+                List<Child> children = childMapper.selectList(
+                        new LambdaQueryWrapper<Child>()
+                                .eq(Child::getFamilyId, member.getFamilyId())
+                                .eq(Child::getNickname, member.getNickname()));
+                for (Child c : children) {
+                    childMapper.deleteById(c.getId());
+                }
+            }
+            return;
+        }
+
+        // 从 children 踢出(兼容已有数据)
+        Child child = childMapper.selectById(memberId);
+        if (child != null && child.getFamilyId() != null && child.getFamilyId().equals(operator.getFamilyId())) {
+            childMapper.deleteById(memberId);
+            return;
+        }
+
+        throw new RuntimeException("成员不存在");
+    }
+
+    /**
+     * 更新成员信息
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public FamilyMemberVO updateMember(Long userId, Long memberId, AddFamilyMemberDTO dto) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            throw new RuntimeException("用户未加入家庭");
+        }
+
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null || !member.getFamilyId().equals(user.getFamilyId())) {
+            throw new RuntimeException("成员不存在或不属于您的家庭");
+        }
+
+        if (dto.getNickname() != null && !dto.getNickname().trim().isEmpty()) {
+            member.setNickname(dto.getNickname().trim());
+        }
+        if (dto.getPhone() != null) {
+            member.setPhone(dto.getPhone());
+        }
+        if (dto.getAvatar() != null) {
+            member.setAvatar(dto.getAvatar());
+        }
+        if (dto.getGender() != null) {
+            member.setGender(dto.getGender());
+        }
+        if (dto.getBirthday() != null) {
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                member.setBirthday(sdf.parse(dto.getBirthday()));
+            } catch (java.text.ParseException e) {
+                throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
+            }
+        }
+
+        member.setUpdatedAt(new Date());
+        familyMemberMapper.updateById(member);
+
+        // 同步更新 children 记录(如果存在)
+        if ("child".equals(member.getCapabilityRole())) {
+            List<Child> children = childMapper.selectList(
+                    new LambdaQueryWrapper<Child>()
+                            .eq(Child::getFamilyId, member.getFamilyId())
+                            .eq(Child::getNickname, member.getNickname()));
+            for (Child c : children) {
+                if (dto.getNickname() != null && !dto.getNickname().trim().isEmpty()) {
+                    c.setNickname(dto.getNickname().trim());
+                }
+                if (dto.getGender() != null) {
+                    c.setGender(dto.getGender());
+                }
+                if (dto.getPhone() != null) {
+                    c.setPhone(dto.getPhone());
+                }
+                if (dto.getBirthday() != null) {
+                    try {
+                        java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                        c.setBirthday(sdf.parse(dto.getBirthday()));
+                    } catch (java.text.ParseException e) {
+                        // ignore
+                    }
+                }
+                c.setUpdatedAt(new Date());
+                childMapper.updateById(c);
+            }
+        }
+
+        // 查询关系类型名称
+        RelationshipType relType = relationshipTypeMapper.selectOne(
+                new LambdaQueryWrapper<RelationshipType>()
+                        .eq(RelationshipType::getTypeKey, member.getRelationshipType()));
+        return toFamilyMemberVO(member, relType);
+    }
+
+    /**
+     * 获取所有启用的关系类型列表
+     */
+    public List<RelationshipType> getRelationshipTypes() {
+        return relationshipTypeMapper.selectList(
+                new LambdaQueryWrapper<RelationshipType>()
+                        .eq(RelationshipType::getEnabled, 1)
+                        .orderByAsc(RelationshipType::getSortOrder));
+    }
+
+    /**
+     * 根据关系类型key获取关系类型
+     */
+    public RelationshipType getRelationshipTypeByKey(String typeKey) {
+        return relationshipTypeMapper.selectOne(
+                new LambdaQueryWrapper<RelationshipType>()
+                        .eq(RelationshipType::getTypeKey, typeKey));
+    }
+
+    // ===== 管理端方法 =====
+
+    public List<RelationshipType> listAllRelationshipTypes() {
+        return relationshipTypeMapper.selectList(
+                new LambdaQueryWrapper<RelationshipType>()
+                        .orderByAsc(RelationshipType::getSortOrder));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public RelationshipType saveRelationshipType(RelationshipType type) {
+        if (type.getId() != null) {
+            RelationshipType existing = relationshipTypeMapper.selectById(type.getId());
+            if (existing != null) {
+                existing.setTypeKey(type.getTypeKey());
+                existing.setTypeName(type.getTypeName());
+                existing.setCapabilityRole(type.getCapabilityRole());
+                existing.setSortOrder(type.getSortOrder());
+                existing.setEnabled(type.getEnabled());
+                relationshipTypeMapper.updateById(existing);
+                return existing;
+            }
+        }
+        type.setCreatedAt(new Date());
+        relationshipTypeMapper.insert(type);
+        return type;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteRelationshipType(Long id) {
+        relationshipTypeMapper.deleteById(id);
+    }
+
+    // ===== 内部方法 =====
+
+    private FamilyMemberVO toFamilyMemberVO(FamilyMember member, RelationshipType relType) {
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(member.getId());
+        vo.setSource("family_member");
+        vo.setUserId(member.getUserId());
+        vo.setNickname(member.getNickname());
+        vo.setPhone(member.getPhone());
+        vo.setAvatar(member.getAvatar());
+        vo.setGender(member.getGender());
+        vo.setRelationshipType(member.getRelationshipType());
+        if (relType != null) {
+            vo.setRelationshipTypeName(relType.getTypeName());
+            vo.setRoleLabel(relType.getTypeName());
+        } else {
+            vo.setRelationshipTypeName(member.getRelationshipType());
+            vo.setRoleLabel(member.getCapabilityRole());
+        }
+        vo.setCapabilityRole(member.getCapabilityRole());
+        vo.setTotalPoints(member.getTotalPoints());
+        vo.setShowToFamily(member.getShowToFamily());
+        if (member.getBirthday() != null) {
+            vo.setAge(calculateAge(member.getBirthday()));
+        }
+        return vo;
+    }
+
+    private Integer calculateAge(java.util.Date birthday) {
+        if (birthday == null) return null;
+        java.util.Calendar birth = java.util.Calendar.getInstance();
+        birth.setTime(birthday);
+        java.util.Calendar now = java.util.Calendar.getInstance();
+        int age = now.get(java.util.Calendar.YEAR) - birth.get(java.util.Calendar.YEAR);
+        if (now.get(java.util.Calendar.MONTH) < birth.get(java.util.Calendar.MONTH) ||
+                (now.get(java.util.Calendar.MONTH) == birth.get(java.util.Calendar.MONTH) &&
+                        now.get(java.util.Calendar.DAY_OF_MONTH) < birth.get(java.util.Calendar.DAY_OF_MONTH))) {
+            age--;
+        }
+        return Math.max(0, age);
+    }
+}

+ 4 - 4
cfc-frontend/components/wuxing-sandbox-helpers/flow-animation.js

@@ -167,15 +167,15 @@ FlowAnimation.prototype._buildKePaths = function () {
   for (var i = 0; i < segments.length; i++) {
   for (var i = 0; i < segments.length; i++) {
     var s = segments[i][0]
     var s = segments[i][0]
     var e = segments[i][1]
     var e = segments[i][1]
-    var srcColor = colorForDimCode(this._dimOrder[s])
+    var srcColor = colorForDimCode(this._dimOrder[e])
     this._kePaths.push({
     this._kePaths.push({
       waypoints: [
       waypoints: [
-        this._outer[s],
+        this._outer[e],
         this._inner[(s + 1) % 5],
         this._inner[(s + 1) % 5],
-        this._outer[e]
+        this._outer[s]
       ],
       ],
       srcColor: srcColor,
       srcColor: srcColor,
-      dstColor: srcColor  // 相克粒子保持源元素颜色
+      dstColor: srcColor  // 相克粒子保持被克方颜色(能量被吸收)
     })
     })
   }
   }
 }
 }

+ 1 - 1
cfc-frontend/components/wuxing-sandbox.vue

@@ -489,7 +489,7 @@ export default {
           var ctx = canvas.getContext('2d')
           var ctx = canvas.getContext('2d')
           ctx.scale(pixelRatio, pixelRatio)
           ctx.scale(pixelRatio, pixelRatio)
 
 
-          var outerR = Math.min(width, height) * 0.46
+          var outerR = Math.min(width, height) * 0.50
           var vertices = calcStarVertices(width / 2, height / 2, outerR)
           var vertices = calcStarVertices(width / 2, height / 2, outerR)
 
 
           self._flowAnim = new FlowAnimation(ctx, width, height, {
           self._flowAnim = new FlowAnimation(ctx, width, height, {

+ 13 - 6
cfc-frontend/pages/body/health-report.vue

@@ -104,12 +104,12 @@
           <view class="deficiency-header">
           <view class="deficiency-header">
             <text class="deficiency-nutrient">{{ dr.nutrient }}</text>
             <text class="deficiency-nutrient">{{ dr.nutrient }}</text>
             <view class="deficiency-type-tag" :class="'dtype-' + dr.deficiencyType">
             <view class="deficiency-type-tag" :class="'dtype-' + dr.deficiencyType">
-              <text>{{ getDeficiencyTypeLabel(dr.deficiencyType) }}</text>
+              <text>{{ dr.deficiencyTypeLabel }}</text>
             </view>
             </view>
           </view>
           </view>
           <view class="deficiency-severity-row">
           <view class="deficiency-severity-row">
             <view class="severity-bar">
             <view class="severity-bar">
-              <view class="severity-fill" :style="{ width: (dr.severity * 100) + '%', background: getSeverityColor(dr.severity) }"></view>
+              <view class="severity-fill" :style="{ width: (dr.severity * 100) + '%', background: dr.severityColor }"></view>
             </view>
             </view>
             <text class="severity-val">{{ dr.severity }}</text>
             <text class="severity-val">{{ dr.severity }}</text>
           </view>
           </view>
@@ -135,7 +135,7 @@
         <view class="food-card" v-for="food in foods" :key="food.id">
         <view class="food-card" v-for="food in foods" :key="food.id">
           <view class="food-header">
           <view class="food-header">
             <text class="food-name">{{ food.name }}</text>
             <text class="food-name">{{ food.name }}</text>
-            <view class="food-score" :class="getFoodScoreClass(food.score)">
+            <view class="food-score" :class="food.scoreClass">
               <text>{{ food.score || '--' }}</text>
               <text>{{ food.score || '--' }}</text>
             </view>
             </view>
           </view>
           </view>
@@ -381,7 +381,10 @@ export default {
             })
             })
             healthRequest('/api/health/foods', { reportId: self.reportId }).then(function(res) {
             healthRequest('/api/health/foods', { reportId: self.reportId }).then(function(res) {
               if (res.data) {
               if (res.data) {
-                self.foods = res.data
+                self.foods = res.data.map(function(f) {
+                  f.scoreClass = self.getFoodScoreClass(f.score)
+                  return f
+                })
               }
               }
             }).catch(function(e) {
             }).catch(function(e) {
               console.log('获取食材推荐失败', e)
               console.log('获取食材推荐失败', e)
@@ -406,8 +409,12 @@ export default {
       if (!this.childId) return
       if (!this.childId) return
       var self = this
       var self = this
       getDeficiencyAnalysis(this.childId).then(function(res) {
       getDeficiencyAnalysis(this.childId).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.deficiencyRecords = res.data
+          if (res.code === 200 && res.data) {
+          self.deficiencyRecords = res.data.map(function(dr) {
+            dr.deficiencyTypeLabel = self.getDeficiencyTypeLabel(dr.deficiencyType)
+            dr.severityColor = self.getSeverityColor(dr.severity)
+            return dr
+          })
         }
         }
       }).catch(function(e) {
       }).catch(function(e) {
         console.log('获取缺乏分析失败', e)
         console.log('获取缺乏分析失败', e)

+ 3 - 5
cfc-frontend/pages/body/index.vue

@@ -529,11 +529,9 @@ export default {
       uni.navigateTo({ url: '/pages/health/nutrition-profile?childId=' + this.activeChildId })
       uni.navigateTo({ url: '/pages/health/nutrition-profile?childId=' + this.activeChildId })
     },
     },
     goToUploadReport: function() {
     goToUploadReport: function() {
-      if (!this.activeChildId) {
-        uni.showToast({ title: '请先选择孩子', icon: 'none' })
-        return
-      }
-      uni.navigateTo({ url: '/pages/health/report-upload?childId=' + this.activeChildId + '&from=body' })
+      // 使用当前选中孩子;如果 loadChildren 异步未完成则尝试从 storage 读取
+      var id = this.activeChildId || uni.getStorageSync('currentChildId') || ''
+      uni.navigateTo({ url: '/pages/health/report-upload?childId=' + id + '&from=body' })
     },
     },
     goToShopProduct: function(type) {
     goToShopProduct: function(type) {
       if (type === 'gut_flora') {
       if (type === 'gut_flora') {

+ 28 - 22
cfc-frontend/pages/index/index.vue

@@ -158,8 +158,14 @@ export default {
     WuxingSandbox
     WuxingSandbox
   },
   },
   data() {
   data() {
+    // 从 storage 预取登录态/角色,确保首次渲染即正确(WeChat 渲染层/逻辑层分离架构下 onLoad 执行前已渲染)
+    var _token = uni.getStorageSync('token')
+    var _currentRole = ''
+    if (_token) {
+      _currentRole = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || ''
+    }
     return {
     return {
-      currentRole: '',
+      currentRole: _currentRole,
       showSplash: false,
       showSplash: false,
       typeList: [
       typeList: [
         { label: '全部', value: '' },
         { label: '全部', value: '' },
@@ -192,29 +198,29 @@ export default {
     // 始终执行初始化,不因 splash 状态跳过,确保登录态变更后及时切换视图
     // 始终执行初始化,不因 splash 状态跳过,确保登录态变更后及时切换视图
     this._doInit()
     this._doInit()
   },
   },
-  _doInit(options) {
-    options = options || {}
-    if (!this.isLoggedIn()) {
-      this.currentRole = ''
-      if (!this.showSplash) {
-        this.loadProducts()
+  methods: {
+    _doInit(options) {
+      options = options || {}
+      if (!this.isLoggedIn()) {
+        this.currentRole = ''
+        if (!this.showSplash) {
+          this.loadProducts()
+        }
+        return
       }
       }
-      return
-    }
-    this.syncRoleAndTabBar()
-    if (options.inviteCode && options.type === 'parent_invite') {
-      this.handleParentInvite(options.inviteCode)
-    } else {
-      var storedCode = uni.getStorageSync('inviteCode')
-      var storedType = uni.getStorageSync('inviteType')
-      if (storedCode && storedType === 'parent_invite') {
-        this.handleParentInvite(storedCode)
-        uni.removeStorageSync('inviteCode')
-        uni.removeStorageSync('inviteType')
+      this.syncRoleAndTabBar()
+      if (options.inviteCode && options.type === 'parent_invite') {
+        this.handleParentInvite(options.inviteCode)
+      } else {
+        var storedCode = uni.getStorageSync('inviteCode')
+        var storedType = uni.getStorageSync('inviteType')
+        if (storedCode && storedType === 'parent_invite') {
+          this.handleParentInvite(storedCode)
+          uni.removeStorageSync('inviteCode')
+          uni.removeStorageSync('inviteType')
+        }
       }
       }
-    }
-  },
-  methods: {
+    },
     isLoggedIn() {
     isLoggedIn() {
       return !!uni.getStorageSync('token')
       return !!uni.getStorageSync('token')
     },
     },

+ 45 - 1
cfc-frontend/store/index.js

@@ -12,8 +12,11 @@ const store = new Vuex.Store({
     familyId: '',
     familyId: '',
     nickname: '',
     nickname: '',
     currentChildId: '',
     currentChildId: '',
+    currentMemberId: '', // 当前切换到的家庭成员ID(family_members表)
+    currentMemberRole: '', // 当前切换到的家庭成员capabilityRole
     children: [],
     children: [],
-    isSwitchedChild: false // 家长切换到孩子状态的标记
+    isSwitchedChild: false, // 家长切换到孩子状态的标记
+    isSwitchedMember: false // 通过family_members切换的标记
   },
   },
   mutations: {
   mutations: {
     setToken(state, token) {
     setToken(state, token) {
@@ -63,6 +66,41 @@ const store = new Vuex.Store({
       uni.removeStorageSync('currentChildId')
       uni.removeStorageSync('currentChildId')
       uni.setStorageSync('isSwitchedChild', false)
       uni.setStorageSync('isSwitchedChild', false)
     },
     },
+    // 通过family_members切换到某个成员
+    switchToMember(state, payload) {
+      state.currentRole = payload.capabilityRole
+      state.currentMemberId = payload.memberId
+      state.currentMemberRole = payload.capabilityRole
+      state.isSwitchedMember = true
+      if (payload.capabilityRole === 'child') {
+        state.isSwitchedChild = true
+        state.currentChildId = payload.memberId
+      }
+      uni.setStorageSync('currentRole', payload.capabilityRole)
+      uni.setStorageSync('currentMemberId', payload.memberId)
+      uni.setStorageSync('currentMemberRole', payload.capabilityRole)
+      uni.setStorageSync('isSwitchedMember', true)
+      if (payload.capabilityRole === 'child') {
+        uni.setStorageSync('currentChildId', payload.memberId)
+        uni.setStorageSync('isSwitchedChild', true)
+      }
+    },
+    // 从family_members切换回自己
+    switchBackFromMember(state) {
+      var originalRole = uni.getStorageSync('role') || 'parent'
+      state.currentRole = originalRole
+      state.currentMemberId = ''
+      state.currentMemberRole = ''
+      state.isSwitchedMember = false
+      state.isSwitchedChild = false
+      state.currentChildId = ''
+      uni.setStorageSync('currentRole', originalRole)
+      uni.removeStorageSync('currentMemberId')
+      uni.removeStorageSync('currentMemberRole')
+      uni.setStorageSync('isSwitchedMember', false)
+      uni.removeStorageSync('currentChildId')
+      uni.setStorageSync('isSwitchedChild', false)
+    },
     // 切换视图模式(不修改数据库中的实际角色)
     // 切换视图模式(不修改数据库中的实际角色)
     switchViewMode(state, role) {
     switchViewMode(state, role) {
       state.currentRole = role
       state.currentRole = role
@@ -81,8 +119,11 @@ const store = new Vuex.Store({
       state.familyId = ''
       state.familyId = ''
       state.nickname = ''
       state.nickname = ''
       state.currentChildId = ''
       state.currentChildId = ''
+      state.currentMemberId = ''
+      state.currentMemberRole = ''
       state.children = []
       state.children = []
       state.isSwitchedChild = false
       state.isSwitchedChild = false
+      state.isSwitchedMember = false
       state.isSwitchedTeacher = false
       state.isSwitchedTeacher = false
       // 清除全部登录态存储
       // 清除全部登录态存储
       uni.removeStorageSync('token')
       uni.removeStorageSync('token')
@@ -96,6 +137,9 @@ const store = new Vuex.Store({
       uni.removeStorageSync('userInfo')
       uni.removeStorageSync('userInfo')
       uni.removeStorageSync('nickname')
       uni.removeStorageSync('nickname')
       uni.removeStorageSync('currentChildId')
       uni.removeStorageSync('currentChildId')
+      uni.removeStorageSync('currentMemberId')
+      uni.removeStorageSync('currentMemberRole')
+      uni.removeStorageSync('isSwitchedMember')
     }
     }
   },
   },
   actions: {
   actions: {

+ 25 - 0
cfc-frontend/utils/api.js

@@ -240,6 +240,31 @@ export const updateChild = (childId, data) => {
   return request('/api/family/user/children/update', 'POST', Object.assign({}, data, { childId: childId }))
   return request('/api/family/user/children/update', 'POST', Object.assign({}, data, { childId: childId }))
 }
 }
 
 
+// 家庭成员模块(统一添加成员)
+export const addFamilyMember = (data) => {
+  return request('/api/family/member/add', 'POST', data)
+}
+
+export const listFamilyMembers = () => {
+  return request('/api/family/member/list', 'POST')
+}
+
+export const switchToFamilyMember = (memberId, source) => {
+  return request('/api/family/member/switch', 'POST', { memberId, source })
+}
+
+export const kickFamilyMemberById = (memberId) => {
+  return request('/api/family/member/kick', 'POST', { memberId })
+}
+
+export const updateFamilyMember = (data) => {
+  return request('/api/family/member/update', 'POST', data)
+}
+
+export const getRelationshipTypes = () => {
+  return request('/api/family/member/relationship-types', 'POST')
+}
+
 // 任务模块
 // 任务模块
 export const createTask = (data) => {
 export const createTask = (data) => {
   return request('/api/tasks', 'POST', data)
   return request('/api/tasks', 'POST', data)

+ 24 - 0
cfc-web/src/api/admin.js

@@ -645,3 +645,27 @@ export function deleteBloodTypeConfig(id) {
     data: { id }
     data: { id }
   })
   })
 }
 }
+
+// 关系类型管理
+export function listRelationshipTypes() {
+  return request({
+    url: '/api/admin/relationship-type/list',
+    method: 'post'
+  })
+}
+
+export function saveRelationshipType(data) {
+  return request({
+    url: '/api/admin/relationship-type/save',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteRelationshipType(id) {
+  return request({
+    url: '/api/admin/relationship-type/delete',
+    method: 'post',
+    data: { id }
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -266,6 +266,12 @@ const routes = [
         component: () => import('@/views/admin/BloodTypeConfigs.vue'),
         component: () => import('@/views/admin/BloodTypeConfigs.vue'),
         meta: { title: '血型配置', perm: 'system:config' }
         meta: { title: '血型配置', perm: 'system:config' }
       },
       },
+      {
+        path: 'relationship-types',
+        name: 'RelationshipTypes',
+        component: () => import('@/views/admin/RelationshipTypes.vue'),
+        meta: { title: '关系类型配置', perm: 'system:config' }
+      },
       // ========== DanShop 电商管理 ==========
       // ========== DanShop 电商管理 ==========
       {
       {
         path: 'danshop-category',
         path: 'danshop-category',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -231,6 +231,7 @@ export default {
             { path: '/zodiac-configs', label: '星座配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/zodiac-configs', label: '星座配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/bazi-configs', label: '八字配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/bazi-configs', label: '八字配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/blood-type-configs', label: '血型配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/blood-type-configs', label: '血型配置', icon: 'el-icon-s-management', perm: 'system:config' },
+            { path: '/relationship-types', label: '关系类型', icon: 'el-icon-s-management', perm: 'system:config' },
           ]},
           ]},
       ],
       ],
       profileDialogVisible: false,
       profileDialogVisible: false,

+ 140 - 0
cfc-web/src/views/admin/RelationshipTypes.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="relationship-types">
+    <el-card>
+      <div slot="header">
+        <span>关系类型配置</span>
+        <el-button style="float: right" type="primary" size="small" @click="handleCreate">+ 添加关系类型</el-button>
+      </div>
+      <el-table :data="types" stripe>
+        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="typeKey" label="编码" width="120"></el-table-column>
+        <el-table-column prop="typeName" label="显示名称" width="120"></el-table-column>
+        <el-table-column prop="capabilityRole" label="能力角色" width="120">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.capabilityRole === 'parent' ? 'primary' : 'success'" size="mini">
+              {{ scope.row.capabilityRole === 'parent' ? '家长' : '孩子' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sortOrder" label="排序" width="80"></el-table-column>
+        <el-table-column prop="enabled" label="状态" width="80">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.enabled === 1 ? 'success' : 'info'" size="mini">
+              {{ scope.row.enabled === 1 ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150">
+          <template slot-scope="scope">
+            <el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑关系类型' : '添加关系类型'" width="500px">
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="编码">
+          <el-input v-model="form.typeKey" placeholder="如: child, spouse, parent, sibling"></el-input>
+        </el-form-item>
+        <el-form-item label="显示名称">
+          <el-input v-model="form.typeName" placeholder="如: 子女, 配偶, 父母, 兄弟姐妹"></el-input>
+        </el-form-item>
+        <el-form-item label="能力角色">
+          <el-select v-model="form.capabilityRole" placeholder="选择能力角色">
+            <el-option label="家长(配偶/父母等)" value="parent"></el-option>
+            <el-option label="孩子(子女/兄弟姐妹等)" value="child"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sortOrder" :min="0" :max="999" style="width:150px"></el-input-number>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用"></el-switch>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listRelationshipTypes, saveRelationshipType, deleteRelationshipType } from '@/api/admin'
+
+export default {
+  name: 'RelationshipTypes',
+  data() {
+    return {
+      types: [],
+      dialogVisible: false,
+      isEdit: false,
+      form: this.getEmptyForm()
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    getEmptyForm() {
+      return {
+        id: null,
+        typeKey: '',
+        typeName: '',
+        capabilityRole: 'child',
+        sortOrder: 0,
+        enabled: 1
+      }
+    },
+    async loadData() {
+      try {
+        const res = await listRelationshipTypes()
+        if (res.data) this.types = res.data
+      } catch (e) {
+        console.error(e)
+      }
+    },
+    handleCreate() {
+      this.isEdit = false
+      this.form = this.getEmptyForm()
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = { ...row }
+      this.dialogVisible = true
+    },
+    async handleDelete(row) {
+      try {
+        await this.$confirm('确定删除该关系类型吗?删除后已使用该类型的成员不受影响。', '提示', { type: 'warning' })
+        await deleteRelationshipType(row.id)
+        this.$message.success('删除成功')
+        this.loadData()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('删除失败')
+      }
+    },
+    async handleSubmit() {
+      if (!this.form.typeKey || !this.form.typeName) {
+        this.$message.warning('编码和显示名称不能为空')
+        return
+      }
+      try {
+        await saveRelationshipType(this.form)
+        this.$message.success('保存成功')
+        this.dialogVisible = false
+        this.loadData()
+      } catch (e) {
+        this.$message.error('保存失败')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.relationship-types { padding: 20px; }
+</style>