2026-08-05-family-relationship-strip.md 40 KB

FamilyMemberStrip 关系展示增强 实现计划

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

目标: 在 FamilyMemberStrip/FamilyMemberCard 上直接呈现关系类型与关系现状(信任度/亲密度/沟通质量),长按菜单升级为带关系现状区块的 Action Sheet,支持未创建家庭引导 + 当前用户添加/邀请成员入口,并新增 pairwise 接口支撑成员详情页以任意成员为中心的视图。

架构: 后端在 family_relationships 表增加评分三列并新增 pairwise 接口(问卷提交时同步写入);前端在 FamilyMemberCard 徽章下增加三个迷你色点,FamilyMemberStrip 增加空状态引导与 centerMemberId prop,长按菜单升级为分区 Action Sheet。

技术栈: Spring Boot 2.7.18 + MyBatis-Plus(后端)/ uni-app Vue 2 Options API(前端)

关联设计文档: docs/superpowers/specs/2026-08-05-family-relationship-strip-design.md


文件结构

后端(cfc-backend/src/main/java/com/etotem/cfc/):

  • 修改 entity/FamilyRelationship.java — 增加 trustScore/intimacyScore/communicationScore 三字段
  • 修改 config/DatabaseInitializer.java — 迁移154:family_relationships 表加三列
  • 修改 resources/schema.sql — family_relationships CREATE TABLE 补三列
  • 修改 service/RelationshipQuestionnaireService.java — submitQuestionnaire 增加 pairwise 落库
  • 修改 dto/FamilyMemberVO.java — 补齐 communicationScore 字段
  • 修改 service/FamilyMemberService.java — listMembers 补齐 communicationScore
  • 修改 service/RelationshipQualityService.java — 新增 getPairwiseRelationships()
  • 修改 controller/family/RelationshipQualityController.java — 新增 POST /pairwise

前端(cfc-frontend/):

  • 修改 components/FamilyMemberCard.vue — 徽章下加三色点
  • 修改 components/FamilyMemberStrip.vue — 空状态引导 + centerMemberId prop + Action Sheet 升级
  • 修改 utils/api.js — 新增 getPairwiseRelationships()
  • 修改 pages/home-pages/member-home-detail.vue — 接入 centerMemberId 模式

测试:

  • 无现成后端测试框架(mvn test 只有 1 个测试类),以 mvn clean compile 验证
  • 前端以 npm run build:mp-weixin 验证

任务 1:后端实体 + 数据库迁移

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationship.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  • 修改:cfc-backend/src/main/resources/schema.sql

  • [ ] 步骤 1:实体加字段

FamilyRelationship.javarelationshipKey 字段后追加:

    /** 信任度(答卷人视角) 0-100 */
    private BigDecimal trustScore;

    /** 亲密度(答卷人视角) 0-100 */
    private BigDecimal intimacyScore;

    /** 沟通质量(答卷人视角) 0-100 */
    private BigDecimal communicationScore;

并补充 import(若未引入 java.math.BigDecimal,加在 import java.io.Serializable; 之后)。

  • 步骤 2:DatabaseInitializer 加迁移

找到 // 迁移153: 注释块,在其后追加(用 ensureColumn 三次调用):

        // 迁移154: family_relationships表添加关系质量评分三列(关系条展示增强)
        ensureColumn("family_relationships", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)'");
        ensureColumn("family_relationships", "intimacy_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)'");
        ensureColumn("family_relationships", "communication_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)'");
  • 步骤 3:同步 schema.sql

schema.sqlfamily_relationships CREATE TABLE(约 1818 行)在 relationship_key 行后加三列:

    trust_score        DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)',
    intimacy_score     DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)',
    communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)',
  • 步骤 4:编译验证

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

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationship.java cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(backend): family_relationships表增加关系质量评分三列"
    

任务 2:问卷提交同步写入 pairwise 关系评分

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java

  • [ ] 步骤 1:注入 FamilyRelationshipMapper 并补 import

该文件已有 @Resource private FamilyMemberMapper familyMemberMapper;(line 38)与全部所需 import(LambdaQueryWrapper/FamilyMember/BigDecimal/Date/ArrayList)。只需新增 mapper 注入:

    @Resource
    private com.etotem.cfc.mapper.FamilyRelationshipMapper familyRelationshipMapper;

并在 import 区(import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot; 之后)补:

import com.etotem.cfc.entity.FamilyRelationship;
import com.etotem.cfc.mapper.FamilyRelationshipMapper;
  • 步骤 2:修改 submitQuestionnaire,提交后调用 pairwise 落库

submitQuestionnaireupdateMemberScores(snapshot.getFamilyMemberId(), scores); 之后追加:

        // 同步写入 pairwise 关系评分(答卷人视角 → 被评成员)
        syncPairwiseScores(snapshot.getRespondentId(), snapshot.getFamilyMemberId(), scores);
  • 步骤 3:新增 syncPairwiseScores 方法

updateMemberScores 方法后新增:

    /**
     * 将问卷评分写入 family_relationships 表(答卷人视角)
     * 答卷人无 family_member 记录或关系行不存在时,静默跳过(不阻塞问卷保存)
     */
    private void syncPairwiseScores(Long respondentUserId, Long targetMemberId, BigDecimal[] scores) {
        try {
            if (respondentUserId == null || targetMemberId == null || scores == null) return;
            // 找到答卷人对应的 family_member 记录
            LambdaQueryWrapper<FamilyMember> respWrapper = new LambdaQueryWrapper<>();
            respWrapper.eq(FamilyMember::getUserId, respondentUserId);
            FamilyMember respondent = familyMemberMapper.selectOne(respWrapper);
            if (respondent == null) {
                log.warn("答卷人用户{}无family_member记录,跳过pairwise评分同步", respondentUserId);
                return;
            }
            // 查 from→to 关系行(有则更新,无则插入)
            LambdaQueryWrapper<FamilyRelationship> relWrapper = new LambdaQueryWrapper<>();
            relWrapper.eq(FamilyRelationship::getFromMemberId, respondent.getId())
                    .eq(FamilyRelationship::getToMemberId, targetMemberId);
            FamilyRelationship rel = familyRelationshipMapper.selectOne(relWrapper);
            Date now = new Date();
            if (rel != null) {
                rel.setTrustScore(scores[0]);
                rel.setIntimacyScore(scores[1]);
                rel.setCommunicationScore(scores[2]);
                rel.setUpdatedAt(now);
                familyRelationshipMapper.updateById(rel);
            } else {
                FamilyMember target = familyMemberMapper.selectById(targetMemberId);
                if (target == null) return;
                FamilyRelationship newRel = new FamilyRelationship();
                newRel.setFamilyId(target.getFamilyId());
                newRel.setFromMemberId(respondent.getId());
                newRel.setToMemberId(targetMemberId);
                int respGen = respondent.getGeneration() != null ? respondent.getGeneration() : 0;
                int targetGen = target.getGeneration() != null ? target.getGeneration() : 0;
                newRel.setGenerationValue(respGen - targetGen);
                newRel.setTrustScore(scores[0]);
                newRel.setIntimacyScore(scores[1]);
                newRel.setCommunicationScore(scores[2]);
                newRel.setCreatedAt(now);
                newRel.setUpdatedAt(now);
                familyRelationshipMapper.insert(newRel);
            }
        } catch (Exception e) {
            log.warn("pairwise评分同步失败: {}", e.getMessage());
        }
    }

需要新增 import:com.etotem.cfc.entity.FamilyRelationship(文件顶部 import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot; 之后)。

  • 步骤 4:编译验证

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

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java
    git commit -m "feat(backend): 问卷提交时同步pairwise关系评分到family_relationships"
    

任务 3:后端 VO 补齐 communicationScore + pairwise 接口

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java

  • [ ] 步骤 1:FamilyMemberVO 增加 communicationScore 字段

FamilyMemberVO.java 现有 intimacyScore(line 65)与 lastInteractionAt(line 68)之间补:

    /** 沟通质量分数 */
    private java.math.BigDecimal communicationScore;
  • 步骤 2:FamilyMemberService.listMembers 补齐 communicationScore 与 isSelf

FamilyMemberService.java 现有 vo.setTrustScore(fm.getTrustScore());(line 226)与 vo.setIntimacyScore(fm.getIntimacyScore());(line 227),在其后补 communicationScore;同时该方法从未设置 isSelf(已核实,全文件无 setIsSelf),需一并补齐——否则首页 8 处现有 FamilyMemberStrip 调用中"添加成员/邀请成员"入口永不显示,且现有 canRemove!actionMember.isSelf 判断恒为 true(对自己也显示移除按钮):

        vo.setCommunicationScore(fm.getCommunicationScore());
        vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));

说明:viewerMember 在该方法 line 175-181 已计算(当前查看者在 family_members 中的记录),直接复用。isSelf 语义 = 该成员就是当前登录用户在家庭中的记录。

  • 步骤 3:RelationshipQualityService 新增 getPairwiseRelationships 方法

该类已 import LambdaQueryWrapper / FamilyMember / FamilyMemberMapper / BigDecimal / ArrayList / Date,且已有 private computeEffectiveRole(FamilyMember)(line 211)可复用。新增注入:

    @Resource
    private com.etotem.cfc.service.FamilyRelationshipService familyRelationshipService;

getScores 方法后新增:

    /**
     * 获取以指定成员为中心的所有成员关系(关系类型 + 三项评分)
     */
    public List<FamilyMemberVO> getPairwiseRelationships(Long centerMemberId, Long familyId) {
        List<FamilyMemberVO> result = new ArrayList<>();
        FamilyMember center = familyMemberMapper.selectById(centerMemberId);
        if (center == null || (familyId != null && !familyId.equals(center.getFamilyId()))) {
            throw new RuntimeException("成员不存在或不属于该家庭");
        }
        if (familyId == null) {
            familyId = center.getFamilyId();
        }

        List<FamilyMember> members = familyMemberMapper.selectList(
                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));

        // 中心成员 → 各成员的关系行(评分与关系类型来源)
        List<FamilyRelationship> rels = familyRelationshipService.getRelationsForMember(centerMemberId);
        Map<Long, FamilyRelationship> relMap = new java.util.HashMap<>();
        for (FamilyRelationship rel : rels) {
            relMap.put(rel.getToMemberId(), rel);
        }

        for (FamilyMember fm : members) {
            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.setGeneration(fm.getGeneration());
            vo.setEffectiveRole(computeEffectiveRole(fm));
            vo.setRoleLabel(computeEffectiveRole(fm));
            vo.setIsSelf(centerMemberId.equals(fm.getId()));
            vo.setAge(fm.getAge());

            FamilyRelationship rel = relMap.get(fm.getId());
            if (rel != null) {
                vo.setGenerationValue(rel.getGenerationValue());
                String label = familyRelationshipService.computeRelationshipLabel(rel.getGenerationValue(), fm.getGender());
                vo.setRelativeLabel(label);
                vo.setRelationship(label);
                vo.setTrustScore(rel.getTrustScore());
                vo.setIntimacyScore(rel.getIntimacyScore());
                vo.setCommunicationScore(rel.getCommunicationScore());
            } else if (centerMemberId.equals(fm.getId())) {
                vo.setRelationship("我");
                vo.setRelativeLabel("我");
            } else {
                vo.setTrustScore(BigDecimal.ZERO);
                vo.setIntimacyScore(BigDecimal.ZERO);
                vo.setCommunicationScore(BigDecimal.ZERO);
            }
            result.add(vo);
        }
        return result;
    }

需要新增 import:com.etotem.cfc.entity.FamilyRelationshipcom.etotem.cfc.dto.FamilyMemberVOjava.util.Map

注意(已核实): FamilyMember 实体自带 age 字段(line 67),直接 vo.setAge(fm.getAge()),无需调 FamilyMemberService.calculateAge(那是 private 方法)。computeEffectiveRole 是本类 private 方法,可直接调用。

  • 步骤 4:Controller 新增 pairwise 端点

RelationshipQualityController 已有 getFamilyIdFromRequest(userId, body)(line 30)与 @PostMapping("/scores") 模式。在 getScores 方法后新增:

    @Operation(summary = "获取以指定成员为中心的所有成员关系(pairwise)")
    @PostMapping("/pairwise")
    public Result<List<FamilyMemberVO>> getPairwise(@RequestBody Map<String, Object> body,
                                                    @RequestAttribute("userId") Long userId) {
        if (userId == null) {
            return Result.error("请先登录");
        }
        Long centerMemberId = body.get("centerMemberId") != null
                ? Long.valueOf(body.get("centerMemberId").toString()) : null;
        if (centerMemberId == null) {
            return Result.error("centerMemberId不能为空");
        }
        Long familyId = getFamilyIdFromRequest(userId, body);
        try {
            List<FamilyMemberVO> members = relationshipQualityService.getPairwiseRelationships(centerMemberId, familyId);
            return Result.success(members);
        } catch (RuntimeException e) {
            return Result.error(e.getMessage());
        }
    }

需要新增 import:com.etotem.cfc.dto.FamilyMemberVO

  • 步骤 5:编译验证

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

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java
    git commit -m "feat(backend): 关系pairwise接口(以成员为中心) + VO补齐沟通评分"
    

任务 4:前端 FamilyMemberCard 三色点

文件:

  • 修改:cfc-frontend/components/FamilyMemberCard.vue

  • [ ] 步骤 1:模板加三色点(关系徽章下方)

card-relation-badge</view> 之后、card-spouse-mark 之前插入:

      <!-- 关系现状三色点:信任/亲密度/沟通 -->
      <view class="card-relation-dots" v-if="hasRelationScores">
        <view class="relation-dot" :style="{ background: dotColor(scoreKey('trust')) }"></view>
        <view class="relation-dot" :style="{ background: dotColor(scoreKey('intimacy')) }"></view>
        <view class="relation-dot" :style="{ background: dotColor(scoreKey('communication')) }"></view>
      </view>
      <view class="card-relation-dots" v-else>
        <view class="relation-dot dot-empty"></view>
        <view class="relation-dot dot-empty"></view>
        <view class="relation-dot dot-empty"></view>
      </view>

注意: 模板中禁止带参数方法调用(dotColor(scoreKey('trust')) 嵌套调用在微信小程序模板编译器可能报错)。改为计算属性返回三个色值数组:

      <view class="card-relation-dots">
        <view class="relation-dot" v-for="(color, idx) in relationDotColors" :key="idx"
              :style="{ background: color }"></view>
      </view>
  • 步骤 2:script 增加计算属性

computedrelationColor 后新增:

    hasRelationScores: function() {
      var m = this.member
      if (!m) return false
      var t = Number(m.trustScore) || 0
      var i = Number(m.intimacyScore) || 0
      var c = Number(m.communicationScore) || 0
      return t > 0 || i > 0 || c > 0
    },
    relationDotColors: function() {
      var m = this.member
      var empty = '#E5E7EB'
      if (!m) return [empty, empty, empty]
      var self = this
      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
      return keys.map(function(k) {
        var v = Number(m[k]) || 0
        if (v <= 0) return empty
        if (v >= 70) return '#10B981'
        if (v >= 40) return '#F59E0B'
        return '#EF4444'
      })
    }
  • 步骤 3:样式

<style scoped>.card-relation-badge 后新增:

.card-relation-dots {
  display: flex;
  flex-direction: row;
  justify-content: center;
  margin-top: 6rpx;
  height: 12rpx;
}
.relation-dot {
  width: 12rpx;
  height: 12rpx;
  border-radius: 50%;
  margin: 0 4rpx;
}
.dot-empty {
  background: #E5E7EB;
}

注意: 卡片宽度 140rpx,徽章在头像下方 bottom:-4rpx 处,三色点放徽章下方会与昵称挤压。实现时需验证布局:若重叠,将 .card-relation-dots 的 margin-top 调小(如 2rpx)并将 .card-nickname 的 margin-top 压缩;或把色点移到徽章内部右侧(position:absolute; right:4rpx)。以视觉不重叠为准。

  • 步骤 4:构建验证

运行:cd cfc-frontend && npm run build:mp-weixin 预期:BUILD SUCCESS(无模板编译错误)

  • [ ] 步骤 5:Commit

    git add cfc-frontend/components/FamilyMemberCard.vue
    git commit -m "feat(frontend): 家庭成员卡片增加关系现状三色点"
    

任务 5:前端 FamilyMemberStrip 空状态 + Action Sheet 升级

文件:

  • 修改:cfc-frontend/components/FamilyMemberStrip.vue

  • [ ] 步骤 1:模板空状态分支(单根节点重构)

Vue 2 组件模板要求单根节点,当前根 <view class="family-member-strip" v-if="..."> 无法直接挂 v-else。将根节点改为常渲染,内部用 v-if/v-else 二选一:

<template>
  <view class="family-member-strip">
    <!-- 有成员:滚动条 / 居中模式 -->
    <view v-if="renderedMembers && renderedMembers.length > 0">
      <scroll-view
        class="strip-scroll"
        scroll-x
        :scroll-with-animation="true"
        :enhanced="true"
        show-scrollbar="false"
        v-if="renderedMembers.length >= 4"
      >
        <view class="strip-inner scroll-mode">
          <FamilyMemberCard
            v-for="member in renderedMembers"
            :key="member.id"
            :member="member"
            :isSelected="member.id === selectedMemberId"
            :energyScore="getMemberEnergy(member)"
            @select="onSelect"
            @longpress="onLongPress"
          />
        </view>
      </scroll-view>
      <view class="strip-inner center-mode" v-else>
        <FamilyMemberCard
          v-for="member in renderedMembers"
          :key="member.id"
          :member="member"
          :isSelected="member.id === selectedMemberId"
          :energyScore="getMemberEnergy(member)"
          @select="onSelect"
          @longpress="onLongPress"
        />
      </view>
      <!-- 长按操作菜单:原样保留,见步骤 3 升级 -->
    </view>
    <!-- 未创建家庭引导 -->
    <view class="family-strip-empty" v-else>
      <view class="empty-content">
        <text class="empty-icon">🏠</text>
        <text class="empty-title">还未创建家庭</text>
        <text class="empty-desc">创建家庭,开启全家幸福之旅</text>
        <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
      </view>
    </view>
  </view>
</template>

注意: 此步骤同时引入 renderedMembers(见任务 6 步骤 3 的 data/watch 改造)。模板中所有 members 引用(含 action-sheet 内)统一改为 renderedMembersv-if 里的空数组判断必须写 renderedMembers && renderedMembers.length > 0,避免空数组渲染异常。

  • 步骤 2:script 增加空状态创建家庭 / 添加成员 / 邀请成员(弹窗模式)

import 行追加(现有:import { kickFamilyMemberById } from '@/utils/api'):

import { kickFamilyMemberById, createFamily, generateFamilyInviteQrCode } from '@/utils/api'
import SharePoster from '@/components/SharePoster.vue'
import { parseDate } from '@/utils/format.js'

components 注册追加:SharePoster

data 增加字段:

      creatingFamily: false,
      showPoster: false,
      inviteCode: '',
      inviteQrCode: '',
      posterNickname: '',
      posterAvatar: ''

methods 新增:

    onCreateFamily: function() {
      var self = this
      if (this.creatingFamily) return
      this.creatingFamily = true
      // 家庭名称使用当前用户昵称 + '的家庭'(与 user-edit.vue createFamilyAction 一致)
      var userInfo = uni.getStorageSync('userInfo')
      var nickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || '我的'
      uni.showLoading({ title: '创建中...' })
      createFamily(nickname + '的家庭')
        .then(function(res) {
          uni.hideLoading()
          self.creatingFamily = false
          if (res.code === 200) {
            uni.showToast({ title: '家庭创建成功', icon: 'success' })
            self.$emit('created')
          } else {
            uni.showToast({ title: res.message || '创建失败', icon: 'none' })
          }
        })
        .catch(function(err) {
          uni.hideLoading()
          self.creatingFamily = false
          uni.showToast({ title: (err && err.message) || '创建失败', icon: 'none' })
        })
    },
    addMember: function() {
      this.closeMenu()
      uni.navigateTo({ url: '/pages/family/add-member' })
    },
    // 邀请成员:弹窗展示分享海报(与 family-members.vue onInvite 一致的模式)
    inviteMember: function() {
      var self = this
      this.closeMenu()
      var userInfo = uni.getStorageSync('userInfo')
      this.posterNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
      this.posterAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
      this.inviteCode = ''
      this.inviteQrCode = ''
      uni.showLoading({ title: '生成邀请...' })
      generateFamilyInviteQrCode()
        .then(function(res) {
          uni.hideLoading()
          if (res.code === 200 && res.data) {
            self.inviteCode = res.data.inviteCode
            self.inviteQrCode = res.data.qrCodeBase64
            self.showPoster = true
          } else {
            uni.showToast({ title: res.message || '生成失败', icon: 'none' })
          }
        })
        .catch(function() {
          uni.hideLoading()
          uni.showToast({ title: '生成失败', icon: 'none' })
        })
    }

模板中 action-sheet 内"添加成员 / 邀请成员"入口(isSelf 才显示)加在"切换视角"之前:

      <view class="action-item" @click="addMember" v-if="actionMember && actionMember.isSelf">
        <text class="action-item-icon">➕</text>
        <text class="action-item-text">添加成员</text>
      </view>
      <view class="action-item" @click="inviteMember" v-if="actionMember && actionMember.isSelf">
        <text class="action-item-icon">📨</text>
        <text class="action-item-text">邀请成员</text>
      </view>

SharePoster 挂载位置:根节点 .family-member-strip 的直接子级(与 v-if/v-else 两个分支平级,放在它们之后、根 </view> 闭合前),保证空态/有成员态下都能渲染:

    </view>
    <SharePoster
      :show="showPoster"
      :referralCode="inviteCode"
      :qrCodeBase64="inviteQrCode"
      :nickname="posterNickname"
      :userAvatar="posterAvatar"
      @close="showPoster = false" />
  </view>
</template>
  • 步骤 3:Action Sheet 模板升级(头部 + 关系现状区块)

action-sheet 内头部替换为头像 + 关系标签,关系现状区块插在 header 之后、操作项之前。已核实微信模板禁止 :style 内带参方法调用(scoreWidth('trustScore') 不可用),宽度/数值统一走计算属性 scoreWidths[] / scoreValues[](下标 0=信任度 1=亲密度 2=沟通质量):

    <view class="action-sheet" v-if="showActionMenu">
      <view class="action-header">
        <image class="action-avatar" :src="actionMember ? (actionMember.avatar || '/static/default-avatar.png') : ''" mode="aspectFill" />
        <view class="action-header-info">
          <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
          <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || actionMember.roleLabel || '') : '' }}</text>
        </view>
      </view>
      <!-- 关系现状区块(无数据时展示"未测评") -->
      <view class="action-scores" v-if="actionMember">
        <view class="score-row">
          <text class="score-label">信任度</text>
          <view class="score-bar-track">
            <view class="score-bar-fill trust" :style="{ width: scoreWidths[0] }"></view>
          </view>
          <text class="score-value">{{ scoreValues[0] }}</text>
        </view>
        <view class="score-row">
          <text class="score-label">亲密度</text>
          <view class="score-bar-track">
            <view class="score-bar-fill intimacy" :style="{ width: scoreWidths[1] }"></view>
          </view>
          <text class="score-value">{{ scoreValues[1] }}</text>
        </view>
        <view class="score-row">
          <text class="score-label">沟通质量</text>
          <view class="score-bar-track">
            <view class="score-bar-fill communication" :style="{ width: scoreWidths[2] }"></view>
          </view>
          <text class="score-value">{{ scoreValues[2] }}</text>
        </view>
      </view>
      <view class="action-divider"></view>
      <!-- 添加成员/邀请成员入口已由步骤 2 加入(isSelf 时显示),位于切换视角之前 -->
      <view class="action-item" @click="switchToMember" v-if="canSwitch">
        ...(保留现有)
      </view>
      ...(其余操作项保留现有)

computed 新增:

    actionScores: function() {
      var m = this.actionMember
      if (!m) return []
      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
      return keys.map(function(k) {
        var v = Number(m[k]) || 0
        return { width: Math.max(0, Math.min(100, v)) + '%', value: Math.round(v) }
      })
    },
    scoreWidths: function() {
      return this.actionScores.map(function(s) { return s.width })
    },
    scoreValues: function() {
      return this.actionScores.map(function(s) { return s.value })
    },
    // 上次填写时间(问卷提交时 updateMemberScores 已更新 lastInteractionAt)
    lastQuestionnaireText: function() {
      var m = this.actionMember
      if (!m || !m.lastInteractionAt) return ''
      var d = parseDate(m.lastInteractionAt)
      if (!d) return ''
      var now = new Date()
      var diffMs = now.getTime() - d.getTime()
      var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
      if (diffDays === 0) return '今天'
      if (diffDays === 1) return '昨天'
      if (diffDays < 7) return diffDays + '天前'
      if (diffDays < 30) return Math.floor(diffDays / 7) + '周前'
      return Math.floor(diffDays / 30) + '个月前'
    }

注意:import { parseDate } from '@/utils/format.js'(小程序规范:禁止 new Date(string) 解析日期,统一用 parseDate() 归一化格式,见 cfc-frontend/AGENTS.md)。

模板中"关系问卷"操作项(保留现有,追加时间后缀):

      <view class="action-item" @click="questionnaireMember">
        <text class="action-item-icon">📋</text>
        <text class="action-item-text">关系问卷</text>
        <text class="action-time" v-if="lastQuestionnaireText">  ·  上次填写 {{ lastQuestionnaireText }}</text>
      </view>

.action-time 样式追加到 .action-item-text 同级:

.action-time {
  font-size: 22rpx;
  color: #9CA3AF;
  margin-left: 4rpx;
}

未测评展示: 三项分数全为 0/空时,进度条宽度为 0%、数值为 0。如需"未测评"文案,可在 scoreValues 分支内判断(v <= 0 时返回 --),实现时按视觉效果取舍,不影响数据结构。

  • 步骤 4:样式

<style scoped> 追加:

/* 空状态 */
.family-strip-empty {
  margin: 16rpx 0;
  padding: 40rpx 30rpx;
  background: #FFFFFF;
  border-radius: 24rpx;
  display: flex;
  justify-content: center;
}
.empty-content {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.empty-icon { font-size: 56rpx; }
.empty-title { font-size: 30rpx; font-weight: 600; color: #1F2937; margin-top: 12rpx; }
.empty-desc { font-size: 24rpx; color: #9CA3AF; margin-top: 8rpx; }
.empty-btn {
  margin-top: 24rpx;
  padding: 16rpx 48rpx;
  background: #F97316;
  color: #FFFFFF;
  font-size: 28rpx;
  border-radius: 40rpx;
}
/* Action Sheet 头部 */
.action-header {
  display: flex;
  flex-direction: row;
  align-items: center;
  padding: 24rpx 30rpx;
  border-bottom: 1rpx solid #F3F4F6;
}
.action-avatar {
  width: 88rpx;
  height: 88rpx;
  border-radius: 50%;
  margin-right: 20rpx;
  background: #F3F4F6;
}
.action-header-info {
  display: flex;
  flex-direction: column;
}
/* 关系现状 */
.action-scores {
  padding: 20rpx 30rpx;
}
.score-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  margin-bottom: 12rpx;
}
.score-label {
  width: 110rpx;
  font-size: 24rpx;
  color: #6B7280;
}
.score-bar-track {
  flex: 1;
  height: 14rpx;
  background: #F3F4F6;
  border-radius: 7rpx;
  overflow: hidden;
}
.score-bar-fill {
  height: 100%;
  border-radius: 7rpx;
}
.score-bar-fill.trust { background: #10B981; }
.score-bar-fill.intimacy { background: #3B82F6; }
.score-bar-fill.communication { background: #F59E0B; }
.score-value {
  width: 70rpx;
  text-align: right;
  font-size: 24rpx;
  color: #1F2937;
  font-weight: 600;
}
.action-divider {
  height: 1rpx;
  background: #F3F4F6;
  margin: 0 30rpx;
}
  • 步骤 5:构建验证

运行:cd cfc-frontend && npm run build:mp-weixin 预期:BUILD SUCCESS

  • [ ] 步骤 6:Commit

    git add cfc-frontend/components/FamilyMemberStrip.vue
    git commit -m "feat(frontend): 成员条空状态引导 + 长按菜单升级关系现状Action Sheet"
    

任务 6:前端 pairwise API + 组件 centerMemberId 模式

文件:

  • 修改:cfc-frontend/utils/api.js
  • 修改:cfc-frontend/components/FamilyMemberStrip.vue

  • [ ] 步骤 1:api.js 新增 pairwise 接口

getVisibleFamilyMembers 定义后新增:

// 获取以指定成员为中心的所有成员关系(关系类型 + 信任/亲密度/沟通评分)
export const getPairwiseRelationships = (centerMemberId) => {
	return request('/api/family/relationship/pairwise', 'POST', { centerMemberId })
}
  • 步骤 2:FamilyMemberStrip 增加 centerMemberId prop

props 增加:

    centerMemberId: { type: Number, default: null }
  • 步骤 3:watch centerMemberId 自动拉取 + renderedMembers 数据代理

已核实: 组件 members 是 prop 且为只读,不能直接赋值。引入 data renderedMembers 作为渲染数据源,members prop 变更时同步;centerMemberId 传入时组件内部拉取 pairwise 数据覆盖渲染。

data 增加:

      renderedMembers: []

watch 新增:

  watch: {
    members: {
      handler: function(val) {
        this.renderedMembers = val || []
      },
      immediate: true
    },
    centerMemberId: function(val) {
      if (val) {
        this.loadPairwise(val)
      }
    }
  }

methods 新增:

    loadPairwise: function(centerMemberId) {
      var self = this
      getPairwiseRelationships(centerMemberId).then(function(res) {
        if (res.code === 200 && res.data) {
          var arr = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
          self.renderedMembers = arr
        }
      }).catch(function() {})
    }

模板中所有 members 引用(v-if="members && members.length > 0"v-for="member in members"members.length >= 4)统一改为 renderedMembers(任务 5 步骤 1 已含此改造,此处确认无遗漏)。

import 增加 getPairwiseRelationships

  • 步骤 4:构建验证

运行:cd cfc-frontend && npm run build:mp-weixin 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-frontend/utils/api.js cfc-frontend/components/FamilyMemberStrip.vue
    git commit -m "feat(frontend): pairwise接口 + 成员条centerMemberId视角模式"
    

任务 7:成员详情页接入 pairwise

文件:

  • 修改:cfc-frontend/pages/home-pages/member-home-detail.vue

  • [ ] 步骤 1:引入组件

import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue',并加入 components 注册。

  • 步骤 2:模板插入关系条区块

member-card 之后、FamilyRelationGraph 之前插入:

      <!-- 以该成员为中心的家庭成员关系条 -->
      <FamilyMemberStrip
        v-if="memberId"
        :center-member-id="memberId"
        :selected-member-id="memberId"
        :is-parent="true" />

注意: centerMemberId 是 Number 类型 prop,options.memberId 来自 URL 是 String。需在 onLoad 中 this.memberId = Number(options.memberId) || null,否则 Number prop 校验告警。

  • 步骤 3:onLoad 转 Number

onLoad 修改:

  onLoad: function(options) {
    this.memberId = Number(options.memberId) || null
    this.memberType = options.memberType || 'child'
  },
  • 步骤 4:构建验证

运行:cd cfc-frontend && npm run build:mp-weixin 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-frontend/pages/home-pages/member-home-detail.vue
    git commit -m "feat(frontend): 成员详情页接入以成员为中心的关系条"
    

任务 8:最终验证

文件:

  • 步骤 1:后端全量编译

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

  • 步骤 2:前端构建

运行:cd cfc-frontend && npm run build:mp-weixin 预期:BUILD SUCCESS

  • 步骤 3:检查路由无重复

运行:grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep pairwise 预期:/api/family/relationship/pairwise 出现一次

  • 步骤 4:git 状态确认

运行:git status --short 预期:无未提交变更(或仅剩余待提交的临时文件)


自检记录

规格覆盖度:

  • 2.1 表扩展三列 → 任务 1 ✅
  • 2.2 问卷同步 → 任务 2 ✅
  • 2.3 pairwise 接口 → 任务 3 ✅
  • 3.1 卡片三色点 → 任务 4 ✅
  • 3.2 空状态引导 → 任务 5 ✅
  • 3.3 Action Sheet 升级(含添加/邀请成员 + 上次填写时间)→ 任务 5 ✅
  • 3.4 centerMemberId prop → 任务 6 ✅
  • 3.5 成员详情页接入 → 任务 7 ✅
  • 5 错误处理边界(fallback、权限保留)→ 任务 5/6 ✅
  • 6 验证 → 任务 8 ✅

实现期风险(已核实并在任务内处理):

  1. 微信模板禁止带参数方法调用 → 全部改用计算属性(任务 4/5)
  2. members 是 prop 不可赋值 → 引入 renderedMembers data(任务 6)
  3. URL 参数 String → Number 转换(任务 7)
  4. calculateAgeStatic 不存在已核实FamilyMember 实体自带 age 字段(line 67),直接 vo.setAge(fm.getAge())(任务 3 步骤 3)
  5. FamilyMemberVO.communicationScore 缺失已核实并修复:任务 3 步骤 1 补字段 + 步骤 2 在 listMembers 补齐(否则首页 8 处现有调用第三点恒灰)
  6. FamilyRelationshipService.getRelationsForMember 是假设已核实存在(line 88,返回 from 方向边),computeRelationshipLabel(Integer, String) 存在(line 118)
  7. computeEffectiveRole 跨类不可调已核实:是 RelationshipQualityService 本类 private 方法(line 211),可直接调用
  8. 问卷服务需新增 FamilyMemberMapper已核实familyMemberMapper 已注入(line 38),只需补 familyRelationshipMapper(任务 2 步骤 1)
  9. isSelf 已有值已核实反例FamilyMemberService.listMembers 全文件无 setIsSelf,若不加则首页模式"添加成员/邀请成员"入口永不显示、canRemove 对自己也显示 → 任务 3 步骤 2 补齐 vo.setIsSelf(viewerMember.getId().equals(fm.getId()))(复用已有 viewerMember)
  10. 邀请成员跳页已核实改为弹窗:与 family-members.vue onInvite(line 258)+ SharePoster 组件(props: show/referralCode/qrCodeBase64/nickname/userAvatar)一致,任务 5 步骤 2 已实现
  11. Vue2 单根节点挂 v-else已核实:任务 5 步骤 1 改为根节点常渲染 + 内部 v-if/v-else 二选一,避免直接对根节点挂 v-else
  12. generation_value 计算未知已核实:FamilyRelationshipService.createRelationsForNewMember(line 38-80)约定 from→to: fromGen - toGen,syncPairwiseScores 使用相同公式(respGen - targetGen)✅
  13. 上次填写时间无数据源已核实updateMemberScores(line 210)在问卷提交时更新 member.lastInteractionAt,FamilyMemberVO 已有该字段;任务 5 步骤 3 用 parseDate 格式化为"X天前"展示
  14. 卡片三色点布局挤压 → 实现时视觉调优(任务 4 步骤 3 备注)

占位符扫描: 无 TBD/TODO;所有代码块为完整实现代码,且关键 API 已对照真实源码核实(entity 字段、mapper、service 方法、controller 模式、Lombok setter)。

类型一致性: FamilyRelationship 新增字段名(trustScore/intimacyScore/communicationScore)在任务 1/2/3 中一致;FamilyMemberVO 补齐 communicationScore 后,任务 3 的 setter 调用与任务 4/5 前端字段读取(member.communicationScore)对齐;renderedMembers 在任务 6 中所有模板引用统一替换。