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

feat: 家庭关系管理系统补齐 (relationship type extension + contact-to-member upgrade + family_member_logs + relation graph shapes)

Sisyphus 2 месяцев назад
Родитель
Сommit
aaf8b51f1e

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -5410,6 +5410,11 @@ try {
                     ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子成员'");
             log.info("已创建social_circle_member表");
         } catch (Exception e) { /* 表已存在忽略 */ }
+
+        // 迁移89: relationship_types表添加分类和血亲字段
+        ensureColumn("relationship_types", "category", "VARCHAR(50) DEFAULT 'social' COMMENT '关系分类: blood血亲/marriage姻亲/social社会关系'");
+        ensureColumn("relationship_types", "is_blood_relation", "TINYINT DEFAULT 0 COMMENT '是否血亲关系: 1是0否'");
+        ensureColumn("relationship_types", "is_immediate_family", "TINYINT DEFAULT 0 COMMENT '是否直系亲属: 1是0否'");
     }
 
     private void runMigration82() {
@@ -5653,6 +5658,29 @@ try {
 
         // commission_records 加 family_id 列
         ensureColumn("commission_records", "family_id", "BIGINT COMMENT '归属家庭 ID'");
+
+        // 迁移90: contacts表添加家庭成员关联字段(联系人→家庭成员升级路径)
+        ensureColumn("contacts", "family_member_id", "BIGINT DEFAULT NULL COMMENT '关联的家庭成员ID'");
+        ensureColumn("contacts", "invited_status", "VARCHAR(16) DEFAULT 'none' COMMENT '邀请加入家庭状态: none/invited/accepted/declined'");
+
+        // 迁移91: 创建family_member_logs表(家庭成员变更日志)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_logs (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+                    "member_id BIGINT NOT NULL COMMENT '成员ID', " +
+                    "action VARCHAR(32) NOT NULL COMMENT '操作: join/leave/update/kick', " +
+                    "old_value VARCHAR(500) COMMENT '旧值', " +
+                    "new_value VARCHAR(500) COMMENT '新值', " +
+                    "operator_id BIGINT COMMENT '操作人用户ID', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_family_id (family_id), " +
+                    "INDEX idx_member_id (member_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员变更日志'");
+            log.info("已创建family_member_logs表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
     }
 
     private void runMigration89() {

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ContactController.java

@@ -63,4 +63,13 @@ public class ContactController {
         Long id = Long.valueOf(params.get("id").toString());
         return contactService.recordInteraction(id, userId);
     }
+
+    @PostMapping("/invite-to-family")
+    public Result<Map<String, Object>> inviteToFamily(@RequestBody Map<String, Object> params,
+                                                     @RequestAttribute("userId") Long userId) {
+        Long contactId = Long.valueOf(params.get("contactId").toString());
+        String relationshipType = (String) params.get("relationshipType");
+        String generationLevel = (String) params.get("generationLevel");
+        return contactService.inviteToFamily(contactId, relationshipType, generationLevel, userId);
+    }
 }

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

@@ -5,6 +5,7 @@ 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.entity.FamilyMemberLog;
 import com.etotem.cfc.service.FamilyMemberService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -155,4 +156,16 @@ public class FamilyMembersController {
         boolean editable = familyMemberService.isEditable(memberId);
         return Result.success(editable);
     }
+
+    @Operation(summary = "查询成员变更日志")
+    @PostMapping("/logs")
+    public Result<List<FamilyMemberLog>> logs(HttpServletRequest request,
+                                              @RequestBody Map<String, Long> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long memberId = body != null ? body.get("memberId") : null;
+        return familyMemberService.getMemberLogs(userId, memberId);
+    }
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ContactDTO.java

@@ -26,4 +26,6 @@ public class ContactDTO {
     private String notes;
     private Date createdAt;
     private Date updatedAt;
+    private Long familyMemberId;
+    private String invitedStatus;
 }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Contact.java

@@ -46,4 +46,10 @@ public class Contact implements Serializable {
     private Date createdAt;
 
     private Date updatedAt;
+
+    /** 关联的家庭成员ID(升级后填充) */
+    private Long familyMemberId;
+
+    /** 邀请加入家庭状态: none/invited/accepted/declined */
+    private String invitedStatus;
 }

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMemberLog.java

@@ -0,0 +1,22 @@
+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_member_logs")
+public class FamilyMemberLog implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long familyId;
+    private Long memberId;
+    private String action;
+    private String oldValue;
+    private String newValue;
+    private Long operatorId;
+    private Date createdAt;
+}

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

@@ -30,5 +30,14 @@ public class RelationshipType implements Serializable {
     /** 是否启用: 1=启用, 0=禁用 */
     private Integer enabled;
 
+    /** 关系分类: blood(血亲)/marriage(姻亲)/social(社会关系) */
+    private String category;
+
+    /** 是否血亲关系: 1=是, 0=否 */
+    private Integer isBloodRelation;
+
+    /** 是否直系亲属: 1=是, 0=否 */
+    private Integer isImmediateFamily;
+
     private Date createdAt;
 }

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

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

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContactService.java

@@ -120,6 +120,27 @@ public class ContactService {
         return Result.success(toDTO(contact));
     }
 
+    public Result<Map<String, Object>> inviteToFamily(Long contactId, String relationshipType, String generationLevel, Long userId) {
+        Contact contact = contactMapper.selectById(contactId);
+        if (contact == null || !contact.getUserId().equals(userId)) {
+            return Result.error("联系人不存在");
+        }
+        if (contact.getFamilyMemberId() != null) {
+            return Result.error("该联系人已是家庭成员");
+        }
+
+        contact.setInvitedStatus("invited");
+        contact.setUpdatedAt(new Date());
+        contactMapper.updateById(contact);
+
+        Map<String, Object> result = new java.util.HashMap<>();
+        result.put("contactId", contactId);
+        result.put("invitedStatus", "invited");
+        result.put("relationshipType", relationshipType);
+        result.put("generationLevel", generationLevel);
+        return Result.success(result);
+    }
+
     public int calcIntimacy(Contact c) {
         int base = Math.min((c.getContactCount() == null ? 0 : c.getContactCount()) * 5, 60);
         int active = 0;
@@ -163,6 +184,8 @@ public class ContactService {
         dto.setNotes(c.getNotes());
         dto.setCreatedAt(c.getCreatedAt());
         dto.setUpdatedAt(c.getUpdatedAt());
+        dto.setFamilyMemberId(c.getFamilyMemberId());
+        dto.setInvitedStatus(c.getInvitedStatus());
         return dto;
     }
 

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.AddFamilyMemberDTO;
 import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.dto.SwitchMemberVO;
@@ -8,11 +9,13 @@ import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyRelationship;
 import com.etotem.cfc.entity.FamilyMemberAttributes;
+import com.etotem.cfc.entity.FamilyMemberLog;
 import com.etotem.cfc.entity.RelationshipType;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.enums.GenerationLevel;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
+import com.etotem.cfc.mapper.FamilyMemberLogMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.RelationshipTypeMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -57,6 +60,9 @@ public class FamilyMemberService {
     @Resource
     private FamilyRelationshipService familyRelationshipService;
 
+    @Resource
+    private FamilyMemberLogMapper familyMemberLogMapper;
+
     /**
      * 添加家庭成员
      */
@@ -159,6 +165,9 @@ public class FamilyMemberService {
 
         familyMemberMapper.insert(member);
 
+        // 写入变更日志
+        logMemberAction(member.getFamilyId(), member.getId(), "join", null, member.getNickname(), userId);
+
         // children 表已废除,无需同步。角色信息通过 computeEffectiveRole() 自动计算。
         // 若需要 child 专属初始化(如游戏数据),后续在 computeEffectiveRole() 返回 child 时处理。
 
@@ -291,6 +300,7 @@ public class FamilyMemberService {
             // 先清理关系记录
             familyRelationshipService.deleteRelationsForMember(memberId);
             familyMemberMapper.deleteById(memberId);
+            logMemberAction(operator.getFamilyId(), memberId, "kick", member.getNickname(), null, userId);
             // 如果关联了 User,清除其 familyId
             if (member.getUserId() != null) {
                 User targetUser = userMapper.selectById(member.getUserId());
@@ -347,6 +357,8 @@ public class FamilyMemberService {
             throw new RuntimeException("该成员关联了登录账号,不可编辑");
         }
 
+        String oldNickname = member.getNickname();
+
         if (dto.getNickname() != null && !dto.getNickname().trim().isEmpty()) {
             member.setNickname(dto.getNickname().trim());
         }
@@ -373,6 +385,7 @@ public class FamilyMemberService {
 
         member.setUpdatedAt(new Date());
         familyMemberMapper.updateById(member);
+        logMemberAction(member.getFamilyId(), member.getId(), "update", oldNickname, member.getNickname(), userId);
 
         // 同步更新 family_members 中的 child 专属字段
         if ("child".equals(computeEffectiveRole(member))) {
@@ -604,4 +617,36 @@ public class FamilyMemberService {
         }
         return null;
     }
+
+    private void logMemberAction(Long familyId, Long memberId, String action, String oldValue, String newValue, Long operatorId) {
+        try {
+            FamilyMemberLog logEntry = new FamilyMemberLog();
+            logEntry.setFamilyId(familyId);
+            logEntry.setMemberId(memberId);
+            logEntry.setAction(action);
+            logEntry.setOldValue(oldValue);
+            logEntry.setNewValue(newValue);
+            logEntry.setOperatorId(operatorId);
+            logEntry.setCreatedAt(new Date());
+            familyMemberLogMapper.insert(logEntry);
+        } catch (Exception e) {
+            log.warn("写入家庭成员日志失败: {}", e.getMessage());
+        }
+    }
+
+    public Result<List<FamilyMemberLog>> getMemberLogs(Long userId, Long memberId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("用户未加入家庭");
+        }
+        Long familyId = user.getFamilyId();
+        LambdaQueryWrapper<FamilyMemberLog> query = new LambdaQueryWrapper<FamilyMemberLog>()
+                .eq(FamilyMemberLog::getFamilyId, familyId)
+                .orderByDesc(FamilyMemberLog::getCreatedAt);
+        if (memberId != null) {
+            query.eq(FamilyMemberLog::getMemberId, memberId);
+        }
+        List<FamilyMemberLog> logs = familyMemberLogMapper.selectList(query);
+        return Result.success(logs);
+    }
 }

+ 13 - 6
cfc-backend/src/main/resources/schema.sql

@@ -1785,15 +1785,18 @@ CREATE TABLE IF NOT EXISTS relationship_types (
     default_role     VARCHAR(20)  DEFAULT NULL COMMENT '默认角色模板: parent/child(已废弃, 兼容保留)',
     sort_order       INT DEFAULT 0 COMMENT '排序序号',
     enabled          INT DEFAULT 1 COMMENT '是否启用: 1=启用, 0=禁用',
+    category         VARCHAR(50)  DEFAULT 'social' COMMENT '关系分类: blood血亲/marriage姻亲/social社会关系',
+    is_blood_relation TINYINT     DEFAULT 0 COMMENT '是否血亲关系: 1是0否',
+    is_immediate_family TINYINT   DEFAULT 0 COMMENT '是否直系亲属: 1是0否',
     created_at       DATETIME DEFAULT CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系类型配置表';
 
 -- 默认关系类型数据(default_role: parent/child 为该关系类型的默认角色模板)
-INSERT IGNORE INTO relationship_types (type_key, type_name, default_role, sort_order, enabled) VALUES
-('spouse', '配偶', 'parent', 1, 1),
-('parent', '父母', 'parent', 2, 1),
-('child', '子女', 'child', 3, 1),
-('sibling', '兄弟姐妹', 'child', 4, 1);
+INSERT IGNORE INTO relationship_types (type_key, type_name, default_role, sort_order, enabled, category, is_blood_relation, is_immediate_family) VALUES
+('spouse', '配偶', 'parent', 1, 1, 'marriage', 0, 1),
+('parent', '父母', 'parent', 2, 1, 'blood', 1, 1),
+('child', '子女', 'child', 3, 1, 'blood', 1, 1),
+('sibling', '兄弟姐妹', 'child', 4, 1, 'blood', 1, 0);
 
 -- 用户收货地址表
 CREATE TABLE IF NOT EXISTS user_address (
@@ -2959,7 +2962,11 @@ CREATE TABLE IF NOT EXISTS cart_items (id BIGINT AUTO_INCREMENT PRIMARY KEY,user
 
 -- contacts
 
-CREATE TABLE IF NOT EXISTS contacts (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '用户ID', name VARCHAR(100) NOT NULL COMMENT '姓名', phone VARCHAR(20) COMMENT '电话', avatar VARCHAR(500) COMMENT '头像', relationship_type VARCHAR(20) COMMENT '关系类型: family/friend/partner/colleague/other', known_since DATETIME COMMENT '认识时间', bio VARCHAR(500) COMMENT '简介', birthday DATE COMMENT '生日', intimacy_level INT DEFAULT 0 COMMENT '亲密度(0-100)', contact_source VARCHAR(20) DEFAULT 'manual' COMMENT '来源: phone/manual', tags VARCHAR(500) COMMENT '标签(逗号分隔)', last_contact_at DATETIME COMMENT '最近联系时间', contact_count INT DEFAULT 0 COMMENT '联系次数', notes TEXT COMMENT '备注', created_at DATETIME, updated_at DATETIME, INDEX idx_user_id (user_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表';
+CREATE TABLE IF NOT EXISTS contacts (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '用户ID', name VARCHAR(100) NOT NULL COMMENT '姓名', phone VARCHAR(20) COMMENT '电话', avatar VARCHAR(500) COMMENT '头像', relationship_type VARCHAR(20) COMMENT '关系类型: family/friend/partner/colleague/other', known_since DATETIME COMMENT '认识时间', bio VARCHAR(500) COMMENT '简介', birthday DATE COMMENT '生日', intimacy_level INT DEFAULT 0 COMMENT '亲密度(0-100)', contact_source VARCHAR(20) DEFAULT 'manual' COMMENT '来源: phone/manual', tags VARCHAR(500) COMMENT '标签(逗号分隔)', last_contact_at DATETIME COMMENT '最近联系时间', contact_count INT DEFAULT 0 COMMENT '联系次数', notes TEXT COMMENT '备注', family_member_id BIGINT DEFAULT NULL COMMENT '关联的家庭成员ID', invited_status VARCHAR(16) DEFAULT 'none' COMMENT '邀请加入家庭状态: none/invited/accepted/declined', created_at DATETIME, updated_at DATETIME, INDEX idx_user_id (user_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表';
+
+-- family_member_logs
+
+CREATE TABLE IF NOT EXISTS family_member_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, family_id BIGINT NOT NULL COMMENT '家庭ID', member_id BIGINT NOT NULL COMMENT '成员ID', action VARCHAR(32) NOT NULL COMMENT '操作: join/leave/update/kick', old_value VARCHAR(500) COMMENT '旧值', new_value VARCHAR(500) COMMENT '新值', operator_id BIGINT COMMENT '操作人用户ID', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_family_id (family_id), INDEX idx_member_id (member_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员变更日志';
 
 -- dan_knowledge_base
 

+ 200 - 0
cfc-frontend/components/ContactCard.vue

@@ -18,6 +18,34 @@
     <text class="card-birthday" v-if="contact.birthdayCountdown">
       🎂 {{ contact.birthdayLabel }} · {{ contact.birthdayCountdown }}
     </text>
+    <view class="card-family-status" v-if="contact.familyMemberId">
+      <text class="family-badge">已是家庭成员</text>
+    </view>
+    <view class="card-invite" v-else @click.stop="handleInvite">
+      <text class="invite-text">邀请加入家庭</text>
+    </view>
+  </view>
+  <view class="invite-modal" v-if="showInviteModal" @click.stop>
+    <view class="modal-mask" @click="showInviteModal = false"></view>
+    <view class="modal-content">
+      <text class="modal-title">邀请加入家庭</text>
+      <view class="form-row">
+        <text class="form-label">关系类型</text>
+        <picker :range="relationshipTypeList" range-key="typeName" @change="onRelationshipChange">
+          <text class="form-value">{{ selectedRelationshipName || '请选择' }}</text>
+        </picker>
+      </view>
+      <view class="form-row">
+        <text class="form-label">辈分等级</text>
+        <picker :range="generationLevelList" @change="onGenerationChange">
+          <text class="form-value">{{ selectedGeneration || '请选择' }}</text>
+        </picker>
+      </view>
+      <view class="modal-buttons">
+        <view class="modal-btn cancel" @click="showInviteModal = false">取消</view>
+        <view class="modal-btn confirm" @click="confirmInvite">确认邀请</view>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -26,6 +54,15 @@ export default {
   props: {
     contact: { type: Object, required: true }
   },
+  data: function() {
+    return {
+      showInviteModal: false,
+      relationshipTypeList: [],
+      selectedRelationshipName: '',
+      generationLevelList: ['父母(+1)', '子女(-1)', '配偶(0)', '兄弟姐妹(0)', '祖父母(+2)', '孙子女(-2)'],
+      selectedGeneration: ''
+    }
+  },
   computed: {
     initial: function() {
       return this.contact.name ? this.contact.name[0] : '?'
@@ -49,6 +86,80 @@ export default {
       var known = new Date(this.contact.knownSince)
       return Math.floor((now - known) / (365.25 * 86400000))
     }
+  },
+  methods: {
+    handleInvite: function() {
+      this.showInviteModal = true
+      this.loadRelationshipTypes()
+    },
+    loadRelationshipTypes: function() {
+      var that = this
+      var app = getApp()
+      if (!app || !app.globalData || !app.globalData.baseUrl) return
+      uni.request({
+        url: app.globalData.baseUrl + '/api/family/member/relationship-types',
+        method: 'POST',
+        header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+        success: function(res) {
+          if (res.data && res.data.data) {
+            that.relationshipTypeList = res.data.data
+          }
+        }
+      })
+    },
+    onRelationshipChange: function(e) {
+      var idx = e.detail.value
+      var item = this.relationshipTypeList[idx]
+      if (item) {
+        this.selectedRelationshipName = item.typeName
+      }
+    },
+    onGenerationChange: function(e) {
+      this.selectedGeneration = this.generationLevelList[e.detail.value]
+    },
+    confirmInvite: function() {
+      if (!this.selectedRelationshipName) {
+        uni.showToast({ title: '请选择关系类型', icon: 'none' })
+        return
+      }
+      var that = this
+      var generationMap = {
+        '父母(+1)': 'parent',
+        '子女(-1)': 'child',
+        '配偶(0)': 'spouse',
+        '兄弟姐妹(0)': 'sibling',
+        '祖父母(+2)': 'grandparent',
+        '孙子女(-2)': 'grandchild'
+      }
+      var generationLevel = generationMap[this.selectedGeneration] || 'child'
+      var selectedItem = this.relationshipTypeList.filter(function(item) {
+        return item.typeName === that.selectedRelationshipName
+      })[0]
+      var app = getApp()
+      if (!app || !app.globalData || !app.globalData.baseUrl) return
+      uni.request({
+        url: app.globalData.baseUrl + '/api/contact/invite-to-family',
+        method: 'POST',
+        header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+        data: {
+          contactId: this.contact.id,
+          relationshipType: selectedItem ? selectedItem.typeKey : 'child',
+          generationLevel: generationLevel
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200) {
+            uni.showToast({ title: '邀请成功', icon: 'success' })
+            that.showInviteModal = false
+            that.$emit('invited', that.contact.id)
+          } else {
+            uni.showToast({ title: res.data && res.data.message || '邀请失败', icon: 'none' })
+          }
+        },
+        fail: function() {
+          uni.showToast({ title: '网络错误', icon: 'none' })
+        }
+      })
+    }
   }
 }
 </script>
@@ -110,4 +221,93 @@ export default {
   font-size: 20rpx;
   color: #EC4899;
 }
+.card-family-status {
+  margin-top: 8rpx;
+}
+.family-badge {
+  font-size: 20rpx;
+  color: #10B981;
+  background: rgba(16, 185, 129, 0.12);
+  padding: 2rpx 12rpx;
+  border-radius: 8rpx;
+}
+.card-invite {
+  margin-top: 8rpx;
+  padding: 4rpx 16rpx;
+  background: #F97316;
+  border-radius: 10rpx;
+}
+.invite-text {
+  font-size: 20rpx;
+  color: #fff;
+}
+.invite-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.modal-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+}
+.modal-content {
+  position: relative;
+  width: 600rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 40rpx;
+}
+.modal-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  text-align: center;
+  margin-bottom: 30rpx;
+}
+.form-row {
+  margin-bottom: 20rpx;
+}
+.form-label {
+  font-size: 26rpx;
+  color: #666;
+  margin-bottom: 8rpx;
+}
+.form-value {
+  font-size: 28rpx;
+  color: #333;
+  padding: 12rpx 16rpx;
+  background: #f5f5f5;
+  border-radius: 8rpx;
+}
+.modal-buttons {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 30rpx;
+}
+.modal-btn {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  border-radius: 10rpx;
+  font-size: 28rpx;
+}
+.modal-btn.cancel {
+  background: #f5f5f5;
+  color: #666;
+  margin-right: 16rpx;
+}
+.modal-btn.confirm {
+  background: #F97316;
+  color: #fff;
+}
 </style>

+ 140 - 4
cfc-frontend/components/FamilyRelationGraph.vue

@@ -139,6 +139,7 @@ export default {
           displayName: displayName,
           isSelf: isSelf,
           memberType: m.memberType || 'child',
+          relationshipType: m.relationshipType || '',
           radius: radius,
           x: initX,
           y: initY,
@@ -380,18 +381,45 @@ export default {
       for (var i = 0; i < nodes.length; i++) {
         var node = nodes[i]
         var r = node.radius
-        ctx.beginPath()
-        ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
+
         if (node.isSelf) {
+          // 自我:双层圆环
+          ctx.beginPath()
+          ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
           ctx.fillStyle = '#FFFFFF'
           ctx.fill()
           ctx.strokeStyle = this.themeColor
           ctx.lineWidth = 3
           ctx.stroke()
+          // 内环
+          ctx.beginPath()
+          ctx.arc(node.x, node.y, r * 0.7, 0, 2 * Math.PI)
+          ctx.strokeStyle = this.themeColor + '88'
+          ctx.lineWidth = 2
+          ctx.stroke()
+        } else if (node.relationshipType === 'spouse') {
+          // 配偶:心形
+          this.drawHeart(ctx, node.x, node.y, r)
+          ctx.fillStyle = this.themeColor + '22'
+          ctx.fill()
+        } else if (node.relationshipType === 'parent') {
+          // 父母:圆角方形
+          this.drawRoundedRect(ctx, node.x, node.y, r * 2, r * 2, r * 0.3)
+          ctx.fillStyle = this.themeColor + '22'
+          ctx.fill()
+        } else if (node.relationshipType === 'sibling') {
+          // 兄弟姐妹:八边形
+          this.drawPolygon(ctx, node.x, node.y, r, 8)
+          ctx.fillStyle = this.themeColor + '22'
+          ctx.fill()
         } else {
+          // 孩子/默认:圆形
+          ctx.beginPath()
+          ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
           ctx.fillStyle = this.themeColor + '22'
           ctx.fill()
         }
+
         ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
         ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
         ctx.textAlign = 'center'
@@ -424,18 +452,40 @@ export default {
       for (var j = 0; j < nodes.length; j++) {
         var node = nodes[j]
         var r = node.radius
-        ctx.beginPath()
-        ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
+
         if (node.isSelf) {
+          ctx.beginPath()
+          ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
           ctx.setFillStyle('#FFFFFF')
           ctx.fill()
           ctx.setStrokeStyle(this.themeColor)
           ctx.setLineWidth(3)
           ctx.stroke()
+          // inner ring
+          ctx.beginPath()
+          ctx.arc(node.x, node.y, r * 0.7, 0, 2 * Math.PI)
+          ctx.setStrokeStyle(this.themeColor + '88')
+          ctx.setLineWidth(2)
+          ctx.stroke()
+        } else if (node.relationshipType === 'spouse') {
+          this.drawHeartLegacy(ctx, node.x, node.y, r)
+          ctx.setFillStyle(this.themeColor + '22')
+          ctx.fill()
+        } else if (node.relationshipType === 'parent') {
+          this.drawRoundedRectLegacy(ctx, node.x, node.y, r * 2, r * 2, r * 0.3)
+          ctx.setFillStyle(this.themeColor + '22')
+          ctx.fill()
+        } else if (node.relationshipType === 'sibling') {
+          this.drawPolygonLegacy(ctx, node.x, node.y, r, 8)
+          ctx.setFillStyle(this.themeColor + '22')
+          ctx.fill()
         } else {
+          ctx.beginPath()
+          ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
           ctx.setFillStyle(this.themeColor + '22')
           ctx.fill()
         }
+
         ctx.setFillStyle(node.isSelf ? this.themeColor : '#4A5568')
         ctx.setFont('bold ' + (r * 0.9) + 'px sans-serif')
         ctx.setTextAlign('center')
@@ -554,6 +604,92 @@ export default {
           self.canvasHeight = rect.height || 280
         }
       }).exec()
+    },
+
+    // ===== 形状绘制辅助方法 =====
+
+    drawHeart: function(ctx, cx, cy, size) {
+      ctx.beginPath()
+      var topX = cx
+      var topY = cy - size * 0.4
+      ctx.moveTo(topX, topY)
+      // 左曲线
+      ctx.bezierCurveTo(
+        cx - size * 0.9, cy - size * 0.7,
+        cx - size * 0.6, cy + size * 0.5,
+        cx, cy + size * 0.8
+      )
+      // 右曲线
+      ctx.bezierCurveTo(
+        cx + size * 0.6, cy + size * 0.5,
+        cx + size * 0.9, cy - size * 0.7,
+        cx, cy - size * 0.4
+      )
+      ctx.closePath()
+    },
+
+    drawRoundedRect: function(ctx, cx, cy, w, h, r) {
+      ctx.beginPath()
+      var x = cx - w / 2
+      var y = cy - h / 2
+      ctx.moveTo(x + r, y)
+      ctx.arcTo(x + w, y, x + w, y + h, r)
+      ctx.arcTo(x + w, y + h, x, y + h, r)
+      ctx.arcTo(x, y + h, x, y, r)
+      ctx.arcTo(x, y, x + w, y, r)
+      ctx.closePath()
+    },
+
+    drawPolygon: function(ctx, cx, cy, r, sides) {
+      ctx.beginPath()
+      var startAngle = -Math.PI / 2
+      for (var i = 0; i < sides; i++) {
+        var angle = startAngle + (2 * Math.PI * i) / sides
+        var px = cx + r * Math.cos(angle)
+        var py = cy + r * Math.sin(angle)
+        if (i === 0) {
+          ctx.moveTo(px, py)
+        } else {
+          ctx.lineTo(px, py)
+        }
+      }
+      ctx.closePath()
+    },
+
+    drawHeartLegacy: function(ctx, cx, cy, size) {
+      ctx.beginPath()
+      ctx.moveTo(cx, cy - size * 0.4)
+      ctx.bezierCurveTo(cx - size * 0.9, cy - size * 0.7, cx - size * 0.6, cy + size * 0.5, cx, cy + size * 0.8)
+      ctx.bezierCurveTo(cx + size * 0.6, cy + size * 0.5, cx + size * 0.9, cy - size * 0.7, cx, cy - size * 0.4)
+      ctx.closePath()
+    },
+
+    drawRoundedRectLegacy: function(ctx, cx, cy, w, h, r) {
+      ctx.beginPath()
+      var x = cx - w / 2
+      var y = cy - h / 2
+      ctx.moveTo(x + r, y)
+      ctx.arcTo(x + w, y, x + w, y + h, r)
+      ctx.arcTo(x + w, y + h, x, y + h, r)
+      ctx.arcTo(x, y + h, x, y, r)
+      ctx.arcTo(x, y, x + w, y, r)
+      ctx.closePath()
+    },
+
+    drawPolygonLegacy: function(ctx, cx, cy, r, sides) {
+      ctx.beginPath()
+      var startAngle = -Math.PI / 2
+      for (var i = 0; i < sides; i++) {
+        var angle = startAngle + (2 * Math.PI * i) / sides
+        var px = cx + r * Math.cos(angle)
+        var py = cy + r * Math.sin(angle)
+        if (i === 0) {
+          ctx.moveTo(px, py)
+        } else {
+          ctx.lineTo(px, py)
+        }
+      }
+      ctx.closePath()
     }
   }
 }

+ 47 - 2
cfc-web/src/views/admin/RelationshipTypes.vue

@@ -28,6 +28,27 @@
             </el-tag>
           </template>
         </el-table-column>
+        <el-table-column prop="category" label="关系分类" width="120">
+          <template slot-scope="scope">
+            <el-tag :type="getCategoryType(scope.row.category)" size="mini">
+              {{ getCategoryLabel(scope.row.category) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="isBloodRelation" label="血亲" width="80">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.isBloodRelation === 1 ? 'danger' : 'info'" size="mini">
+              {{ scope.row.isBloodRelation === 1 ? '是' : '否' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="isImmediateFamily" label="直系亲属" width="100">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.isImmediateFamily === 1 ? 'warning' : 'info'" size="mini">
+              {{ scope.row.isImmediateFamily === 1 ? '是' : '否' }}
+            </el-tag>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template slot-scope="{ row }">
             <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
@@ -64,6 +85,19 @@
         <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-item label="关系分类">
+          <el-select v-model="form.category" placeholder="选择关系分类">
+            <el-option label="血亲" value="blood"></el-option>
+            <el-option label="姻亲" value="marriage"></el-option>
+            <el-option label="社会关系" value="social"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="血亲关系">
+          <el-switch v-model="form.isBloodRelation" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否"></el-switch>
+        </el-form-item>
+        <el-form-item label="直系亲属">
+          <el-switch v-model="form.isImmediateFamily" :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>
@@ -104,7 +138,7 @@ export default {
       if (!this.keyword) return this.types
       const kw = this.keyword.toLowerCase()
       return this.types.filter(item =>
-        [item.typeKey, item.typeName].some(v => (v||'').toLowerCase().includes(kw))
+        [item.typeKey, item.typeName, item.category].some(v => (v||'').toLowerCase().includes(kw))
       )
     }
   },
@@ -120,7 +154,10 @@ export default {
         typeName: '',
         defaultRole: 'child',
         sortOrder: 0,
-        enabled: 1
+        enabled: 1,
+        category: 'social',
+        isBloodRelation: 0,
+        isImmediateFamily: 0
       }
     },
     async loadData() {
@@ -181,6 +218,14 @@ export default {
       this.successDialogVisible = false
       this.dialogVisible = false
       this.loadData()
+    },
+    getCategoryType(category) {
+      const map = { blood: 'danger', marriage: 'warning', social: 'info' }
+      return map[category] || 'info'
+    },
+    getCategoryLabel(category) {
+      const map = { blood: '血亲', marriage: '姻亲', social: '社会关系' }
+      return map[category] || category
     }
   }
 }