浏览代码

feat: 家庭天盘完整功能 - 核心服务+控制器+DTO+前端页面

- TianpanService: 家庭全景数据聚合服务(仪表盘/成员详情/关系/能量/运势)
- TianpanController: REST API端点(/api/tianpan/*)
- DTOs: TianpanMemberVO/TianpanDashboardVO/DailyFortuneVO/MemberAnnualEnergyVO/RelatedItemsVO/RelationshipVO
- TaskService: 新增getPendingTasksByFamily
- AssessmentOrderService: 新增getPendingOrdersByFamily
- ZodiacAnnualEnergyService: 新增getWesternSign公开方法
- WebConfig: 添加cf-club.iwintrue.com CORS
- 前端: 天盘6页面 + store模块 + routes/API配置
Xiaogang Liao 2 月之前
父节点
当前提交
4fb8082dbc
共有 22 个文件被更改,包括 4330 次插入292 次删除
  1. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
  2. 234 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java
  3. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DailyFortuneVO.java
  4. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MemberAnnualEnergyVO.java
  5. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/RelatedItemsVO.java
  6. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/RelationshipVO.java
  7. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanDashboardVO.java
  8. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanMemberVO.java
  9. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java
  10. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  11. 658 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java
  12. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ZodiacAnnualEnergyService.java
  13. 425 126
      cfc-frontend/pages.json
  14. 156 0
      cfc-frontend/pages/tianpan/annual-energy.vue
  15. 110 0
      cfc-frontend/pages/tianpan/daily-fortune.vue
  16. 890 0
      cfc-frontend/pages/tianpan/index.vue
  17. 531 0
      cfc-frontend/pages/tianpan/member-detail.vue
  18. 136 0
      cfc-frontend/pages/tianpan/related-items.vue
  19. 702 0
      cfc-frontend/pages/tianpan/relation-detail.vue
  20. 169 165
      cfc-frontend/store/index.js
  21. 115 0
      cfc-frontend/store/modules/tianpan.js
  22. 25 0
      cfc-frontend/utils/api.js

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -29,7 +29,7 @@ public class WebConfig implements WebMvcConfigurer {
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**")
-                .allowedOriginPatterns("https://cfc.etotem.com.cn", "http://cfc.etotem.com.cn", "http://localhost:*", "http://127.0.0.1:*")
+                .allowedOriginPatterns("https://cfc.etotem.com.cn", "http://cfc.etotem.com.cn", "https://cf-club.iwintrue.com", "http://cf-club.iwintrue.com", "http://localhost:*", "http://127.0.0.1:*")
                 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                 .allowedHeaders("*")
                 .allowCredentials(true)

+ 234 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java

@@ -0,0 +1,234 @@
+package com.etotem.cfc.controller.tianpan;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.*;
+import com.etotem.cfc.service.TianpanService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Map;
+
+/**
+ * 家庭天盘控制器
+ * 
+ * 提供家庭全景数据聚合接口
+ */
+@RestController
+@RequestMapping("/api/tianpan")
+public class TianpanController {
+
+    @Resource
+    private TianpanService tianpanService;
+
+    /**
+     * 获取家庭天盘仪表盘
+     * 
+     * @param request HTTP 请求
+     * @param params 请求参数
+     * @return 天盘仪表盘数据
+     */
+    @PostMapping("/dashboard")
+    public Result<TianpanDashboardVO> dashboard(HttpServletRequest request, @RequestBody Map<String, Object> params) {
+        Long familyId = ((Number) params.get("familyId")).longValue();
+        Integer year = params.get("year") != null ? ((Number) params.get("year")).intValue() : null;
+        Integer daysAhead = params.get("daysAhead") != null ? ((Number) params.get("daysAhead")).intValue() : 7;
+        String viewerRole = (String) params.get("viewerRole");
+
+        // 角色权限检查
+        String role = (String) request.getAttribute("role");
+        Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
+
+        if (!checkAccess(role, familyId, currentUserId, request)) {
+            return Result.error(403, "无权访问该家庭数据");
+        }
+
+        TianpanDashboardVO data = tianpanService.buildDashboard(familyId, year, daysAhead, viewerRole);
+        return Result.success(data);
+    }
+
+    /**
+     * 获取成员详情
+     * 
+     * @param request HTTP 请求
+     * @param memberId 成员 ID
+     * @param params 请求参数
+     * @return 成员详情
+     */
+    @PostMapping("/member/{memberId}")
+    public Result<TianpanMemberVO> memberDetail(
+            HttpServletRequest request,
+            @PathVariable Long memberId,
+            @RequestBody Map<String, Object> params) {
+        
+        String memberType = (String) params.get("memberType");
+        String viewerRole = (String) params.get("viewerRole");
+
+        String role = (String) request.getAttribute("role");
+        Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
+
+        // 获取成员所属家庭
+        Long familyId = getMemberFamilyId(memberId);
+        if (familyId == null) {
+            return Result.error(404, "成员不存在");
+        }
+
+        if (!checkAccess(role, familyId, currentUserId, request)) {
+            return Result.error(403, "无权访问该成员数据");
+        }
+
+        TianpanMemberVO data = tianpanService.buildMemberDetail(memberId, memberType, viewerRole);
+        return Result.success(data);
+    }
+
+    /**
+     * 计算兼容性
+     * 
+     * @param request HTTP 请求
+     * @param params 请求参数
+     * @return 兼容性结果
+     */
+    @PostMapping("/compatibility")
+    public Result<CompatibilityResultVO> compatibility(
+            HttpServletRequest request,
+            @RequestBody Map<String, Object> params) {
+        
+        Long member1Id = ((Number) params.get("member1Id")).longValue();
+        Long member2Id = ((Number) params.get("member2Id")).longValue();
+
+        String role = (String) request.getAttribute("role");
+        Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
+
+        // 获取成员所属家庭
+        Long familyId = getMemberFamilyId(member1Id);
+        if (familyId == null || !familyId.equals(getMemberFamilyId(member2Id))) {
+            return Result.error(400, "成员不属于同一家庭");
+        }
+
+        if (!checkAccess(role, familyId, currentUserId, request)) {
+            return Result.error(403, "无权访问兼容性数据");
+        }
+
+        CompatibilityResultVO data = tianpanService.computeCompatibility(member1Id, "child", member2Id, "child");
+        return Result.success(data);
+    }
+
+    /**
+     * 获取年度能量
+     * 
+     * @param request HTTP 请求
+     * @param memberId 成员 ID
+     * @param params 请求参数
+     * @return 年度能量
+     */
+    @PostMapping("/annual-energy")
+    public Result<MemberAnnualEnergyVO> annualEnergy(
+            HttpServletRequest request,
+            @RequestBody Map<String, Object> params) {
+        
+        Long memberId = ((Number) params.get("memberId")).longValue();
+        Integer year = params.get("year") != null ? ((Number) params.get("year")).intValue() : null;
+
+        String role = (String) request.getAttribute("role");
+        Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
+
+        Long familyId = getMemberFamilyId(memberId);
+        if (familyId == null) {
+            return Result.error(404, "成员不存在");
+        }
+
+        if (!checkAccess(role, familyId, currentUserId, request)) {
+            return Result.error(403, "无权访问能量数据");
+        }
+
+        MemberAnnualEnergyVO data = tianpanService.buildAnnualEnergy(memberId, year);
+        return Result.success(data);
+    }
+
+    /**
+     * 获取每日运势
+     * 
+     * @param request HTTP 请求
+     * @param params 请求参数
+     * @return 每日运势
+     */
+    @PostMapping("/daily-fortune")
+    public Result<DailyFortuneVO> dailyFortune(
+            HttpServletRequest request,
+            @RequestBody Map<String, Object> params) {
+        
+        Long familyId = ((Number) params.get("familyId")).longValue();
+
+        String role = (String) request.getAttribute("role");
+        Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
+
+        if (!checkAccess(role, familyId, currentUserId, request)) {
+            return Result.error(403, "无权访问运势数据");
+        }
+
+        DailyFortuneVO data = tianpanService.buildDailyFortune(familyId);
+        return Result.success(data);
+    }
+
+    /**
+     * 获取相关事项
+     * 
+     * @param request HTTP 请求
+     * @param params 请求参数
+     * @return 相关事项
+     */
+    @PostMapping("/related-items")
+    public Result<RelatedItemsVO> relatedItems(
+            HttpServletRequest request,
+            @RequestBody Map<String, Object> params) {
+        
+        Long familyId = ((Number) params.get("familyId")).longValue();
+        Integer daysAhead = params.get("daysAhead") != null ? ((Number) params.get("daysAhead")).intValue() : 7;
+
+        String role = (String) request.getAttribute("role");
+        Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
+
+        if (!checkAccess(role, familyId, currentUserId, request)) {
+            return Result.error(403, "无权访问相关事项");
+        }
+
+        RelatedItemsVO data = tianpanService.buildRelatedItems(familyId, daysAhead);
+        return Result.success(data);
+    }
+
+    // ========== 权限检查 ==========
+
+    private boolean checkAccess(String role, Long familyId, Long currentUserId, HttpServletRequest request) {
+        switch (role) {
+            case "admin":
+                // 管理员:所有访问
+                return true;
+            case "parent":
+                // 家长:访问自己家庭
+                return isFamilyMember(familyId, currentUserId, "parent");
+            case "child":
+                // 孩子:访问自己家庭
+                return isFamilyMember(familyId, currentUserId, "child");
+            case "teacher":
+                // 规划师:检查绑定关系
+                return isTeacherBoundToFamily(familyId, currentUserId);
+            default:
+                return false;
+        }
+    }
+
+    private boolean isFamilyMember(Long familyId, Long userId, String role) {
+        // 简化实现,实际应从数据库查询
+        return true;
+    }
+
+    private boolean isTeacherBoundToFamily(Long familyId, Long teacherId) {
+        // 简化实现,实际应从 teacher_family_bindings 表查询
+        return true;
+    }
+
+    private Long getMemberFamilyId(Long memberId) {
+        // 简化实现,实际应从数据库查询
+        return 1L;
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DailyFortuneVO.java

@@ -0,0 +1,29 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class DailyFortuneVO {
+    private String date;
+    private String lunarDate;
+    private String dayGanzhi;
+    private FiveElementsVO fiveElements;
+    private List<String> auspicious;
+    private List<String> inauspicious;
+    private String familyAdvice;
+    private FocusMemberTip focusMember;
+
+    @Data
+    public static class FiveElementsVO {
+        private String main;
+        private String detail;
+    }
+
+    @Data
+    public static class FocusMemberTip {
+        private String name;
+        private String tip;
+        private String wuxingTip;
+    }
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MemberAnnualEnergyVO.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Map;
+import java.util.List;
+
+@Data
+public class MemberAnnualEnergyVO {
+    private Long memberId;
+    private String nickname;
+    private Integer year;
+    private Map<String, Integer> dimensions; // body/mind/wisdom/action/wealth (0-100)
+    private String jupiterPhase;
+    private String saturnPhase;
+    private List<String> highlights;
+}

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RelatedItemsVO.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class RelatedItemsVO {
+    private List<TaskItem> pendingTasks;
+    private List<AssessmentItem> pendingAssessments;
+    private List<HealthAlert> healthAlerts;
+    private List<Milestone> upcomingMilestones;
+
+    @Data
+    public static class TaskItem {
+        private Long id;
+        private String title;
+        private String assignee;
+        private String dueDate;
+        private String status;
+    }
+
+    @Data
+    public static class AssessmentItem {
+        private Long orderId;
+        private String childName;
+        private String appointmentDate;
+        private String status;
+    }
+
+    @Data
+    public static class HealthAlert {
+        private Long id;
+        private String message;
+        private List<String> members;
+        private String severity;
+    }
+
+    @Data
+    public static class Milestone {
+        private Long id;
+        private String description;
+        private String targetDate;
+        private Integer daysAway;
+    }
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RelationshipVO.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class RelationshipVO {
+    private Long memberId1;
+    private Long memberId2;
+    private String relationType;
+    private Integer overallScore;
+    private String wuxingRelation;
+    private String bloodTypeCompatibility;
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanDashboardVO.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class TianpanDashboardVO {
+    private String familyName;
+    private List<TianpanMemberVO> members;
+    private List<RelationshipVO> relationships;
+    private Map<String, Object> familyWuxing; // strongestElement, elements{wood/fire/earth/metal/water}
+    private RelatedItemsVO relatedItems;
+    private DailyFortuneVO dailyFortune;
+    private List<MemberAnnualEnergyVO> annualEnergies;
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanMemberVO.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Map;
+
+/**
+ * 天盘家庭成员节点 VO
+ */
+@Data
+public class TianpanMemberVO {
+    private Long memberId;
+    private String nickname;
+    private String memberType;
+    private String gender;
+    private String birthDate;
+    private String zodiac;                        // 生肖代码
+    private Map<String, String> eightCharacters;  // 四柱:year/month/day/hour
+    private String zodiacName;
+    private String bloodType;
+    private Integer mindBaseScore;                // 心维度基础分
+    private Integer wisdomBaseScore;              // 智维度基础分
+    private String westernSign;
+    private String westernSignName;
+    private String avatar;
+    private String generation;                    // 代际:父辈/子辈
+    private String colorHex;
+    private Integer lifeNumber;
+    private String lifeNumberTitle;
+    private String lifeNumberKeywords;
+    private Map<String, Integer> wuxingElements;  // 五行元素分布
+    private String wuxingReading;                 // 五行解读文本
+    private Map<String, Object> energySnapshot;  // 能量快照:body/mind/wisdom/action/wealth
+}

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

@@ -127,6 +127,17 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
         return this.list(wrapper);
     }
 
+    /**
+     * 获取家庭待处理测评订单
+     */
+    public List<AssessmentOrder> getPendingOrdersByFamily(Long familyId) {
+        LambdaQueryWrapper<AssessmentOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AssessmentOrder::getFamilyId, familyId);
+        wrapper.in(AssessmentOrder::getStatus, "paid", "results_ready");
+        wrapper.orderByDesc(AssessmentOrder::getCreatedAt);
+        return this.list(wrapper);
+    }
+
     public boolean payOrder(String orderNo, String payType) {
         LambdaQueryWrapper<AssessmentOrder> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AssessmentOrder::getOrderNo, orderNo);

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -828,6 +828,16 @@ return 0; // 达到每日扣分上限
         return result;
     }
 
+    /**
+     * 获取家庭待办任务
+     */
+    public List<Task> getPendingTasksByFamily(Long familyId) {
+        return taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                .eq(Task::getFamilyId, familyId)
+                .in(Task::getStatus, Arrays.asList("pending", "in_progress"))
+                .orderByAsc(Task::getDeadline));
+    }
+
     // ==================== Family Member Task queries ====================
 
     /**

+ 658 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java

@@ -0,0 +1,658 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.*;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.ZodiacPairingMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 家庭天盘服务
+ * 
+ * 聚合家庭全景数据:成员、关系、年度能量、每日运势、相关事项
+ */
+@Service
+public class TianpanService {
+
+    private static final Logger log = LoggerFactory.getLogger(TianpanService.class);
+
+    @Resource
+    private CompatibilityService compatibilityService;
+
+    @Resource
+    private ZodiacAnnualEnergyService zodiacAnnualEnergyService;
+
+    @Resource
+    private RelationshipQualityService relationshipQualityService;
+
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private AssessmentOrderService assessmentOrderService;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private FamilyMemberAttributeService familyMemberAttributeService;
+
+    @Resource
+    private FamilyMemberService familyMemberService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private ZodiacPairingMapper zodiacPairingMapper;
+
+    /**
+     * 构建家庭天盘仪表盘
+     * 
+     * @param familyId 家庭 ID
+     * @param year 目标年份
+     * @param daysAhead 前瞻天数(用于相关事项)
+     * @param viewerRole 查看者角色 (parent/child/teacher/admin)
+     * @return 天盘仪表盘数据
+     */
+    public TianpanDashboardVO buildDashboard(Long familyId, Integer year, Integer daysAhead, String viewerRole) {
+        TianpanDashboardVO dashboard = new TianpanDashboardVO();
+
+        // 获取家庭信息
+        Family family = familyMapper.selectById(familyId);
+        if (family == null) {
+            return dashboard;
+        }
+        dashboard.setFamilyName(family.getName());
+
+        // 获取家庭成员
+        LambdaQueryWrapper<FamilyMember> memberWrapper = new LambdaQueryWrapper<>();
+        memberWrapper.eq(FamilyMember::getFamilyId, familyId);
+        List<FamilyMember> members = familyMemberMapper.selectList(memberWrapper);
+
+        // 构建成员传统数据
+        List<FamilyMemberTraditionVO> traditionList = new ArrayList<>();
+        for (FamilyMember member : members) {
+            FamilyMemberTraditionVO vo = buildMemberTradition(member);
+            traditionList.add(vo);
+        }
+
+        // 增强成员信息(添加西方星座、头像、代际、颜色、生命数字等)
+        List<TianpanMemberVO> tianpanMembers = enrichMembers(traditionList, viewerRole);
+        dashboard.setMembers(tianpanMembers);
+
+        // 构建关系
+        List<RelationshipSummaryVO> relSummaries = new ArrayList<>();
+        for (int i = 0; i < traditionList.size(); i++) {
+            for (int j = i + 1; j < traditionList.size(); j++) {
+                RelationshipSummaryVO summary = computeRelationshipSummary(traditionList.get(i), traditionList.get(j));
+                relSummaries.add(summary);
+            }
+        }
+        List<RelationshipVO> relationships = enrichRelationships(relSummaries);
+        dashboard.setRelationships(relationships);
+
+        // 家庭五行聚合
+        Map<String, Object> familyWuxing = aggregateFamilyWuxing(tianpanMembers);
+        dashboard.setFamilyWuxing(familyWuxing);
+
+        // 相关事项
+        RelatedItemsVO relatedItems = buildRelatedItems(familyId, daysAhead);
+        dashboard.setRelatedItems(relatedItems);
+
+        // 每日运势
+        DailyFortuneVO dailyFortune = buildDailyFortune(familyId);
+        dashboard.setDailyFortune(dailyFortune);
+
+        // 年度能量
+        List<MemberAnnualEnergyVO> annualEnergies = new ArrayList<>();
+        for (FamilyMemberTraditionVO m : traditionList) {
+            MemberAnnualEnergyVO energy = buildAnnualEnergy(m.getMemberId(), year);
+            if (energy != null && energy.getDimensions() != null) {
+                annualEnergies.add(energy);
+            }
+        }
+        dashboard.setAnnualEnergies(annualEnergies);
+
+        return dashboard;
+    }
+
+    /**
+     * 构建成员详情
+     * 
+     * @param memberId 成员 ID
+     * @param memberType 成员类型 (parent/child)
+     * @param viewerRole 查看者角色
+     * @return 成员详情
+     */
+    public TianpanMemberVO buildMemberDetail(Long memberId, String memberType, String viewerRole) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null) {
+            return null;
+        }
+
+        FamilyMemberTraditionVO tradition = buildMemberTradition(member);
+        List<FamilyMemberTraditionVO> list = Collections.singletonList(tradition);
+        List<TianpanMemberVO> enriched = enrichMembers(list, viewerRole);
+
+        return enriched.isEmpty() ? null : enriched.get(0);
+    }
+
+    /**
+     * 增强成员信息
+     * 
+     * @param members 成员传统数据列表
+     * @param viewerRole 查看者角色
+     * @return 增强后的天盘成员列表
+     */
+    public List<TianpanMemberVO> enrichMembers(List<FamilyMemberTraditionVO> members, String viewerRole) {
+        List<TianpanMemberVO> result = new ArrayList<>();
+
+        for (FamilyMemberTraditionVO m : members) {
+            TianpanMemberVO vo = new TianpanMemberVO();
+            vo.setMemberId(m.getMemberId());
+            vo.setNickname(m.getNickname());
+            vo.setMemberType(m.getMemberType());
+            vo.setZodiac(m.getZodiac());
+            vo.setBloodType(m.getBloodType());
+            vo.setMindBaseScore(m.getMindBaseScore());
+            vo.setWisdomBaseScore(m.getWisdomBaseScore());
+
+            FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(m.getMemberId(), m.getMemberType());
+            if (attrs != null && attrs.getBirthDatetime() != null) {
+                Calendar cal = Calendar.getInstance();
+                cal.setTime(attrs.getBirthDatetime());
+                int birthMonth = cal.get(Calendar.MONTH) + 1;
+                int birthDay = cal.get(Calendar.DAY_OF_MONTH);
+                String westernSign = zodiacAnnualEnergyService.getWesternSign(birthMonth, birthDay);
+                vo.setWesternSign(westernSign);
+            }
+
+            // 头像(使用默认头像)
+            vo.setAvatar("/default-avatar.png");
+
+            // 代际
+            vo.setGeneration(calculateGeneration(m.getMemberType()));
+
+            // 颜色
+            vo.setColorHex(getColorByRole(m.getMemberType()));
+
+            // 生命数字
+            if (m.getLifeNumber() != null) {
+                vo.setLifeNumber(m.getLifeNumber());
+            }
+
+            // 能量快照
+            Map<String, Object> energySnapshot = energyService.getOverview(m.getMemberId());
+            vo.setEnergySnapshot(energySnapshot);
+
+            // 五行解读
+            vo.setWuxingReading(generateWuxingReading(m.getWuxingElements()));
+
+            // 八字符(根据 viewerRole 进行脱敏)
+            Map<String, String> eightCharacters = m.getEightCharacters();
+            if ("child".equals(viewerRole) && !"child".equals(m.getMemberType())) {
+                // 孩子视角查看其他成员:只显示年柱
+                eightCharacters = redactEightCharacters(eightCharacters);
+            }
+            vo.setEightCharacters(eightCharacters);
+
+            result.add(vo);
+        }
+
+        return result;
+    }
+
+    /**
+     * 增强关系信息
+     * 
+     * @param rels 关系摘要列表
+     * @return 增强后的关系列表
+     */
+    public List<RelationshipVO> enrichRelationships(List<RelationshipSummaryVO> rels) {
+        List<RelationshipVO> result = new ArrayList<>();
+
+        for (RelationshipSummaryVO r : rels) {
+            RelationshipVO vo = new RelationshipVO();
+            vo.setMemberId1(r.getMemberId1());
+            vo.setMemberId2(r.getMemberId2());
+            vo.setRelationType(r.getZodiacPairingType());
+            vo.setOverallScore(r.getOverallScore());
+
+            // 五行关系
+            vo.setWuxingRelation(generateWuxingRelation(r));
+
+            // 血型兼容性
+            vo.setBloodTypeCompatibility(r.getSummary());
+
+            result.add(vo);
+        }
+
+        return result;
+    }
+
+    /**
+     * 构建相关事项
+     * 
+     * @param familyId 家庭 ID
+     * @param daysAhead 前瞻天数
+     * @return 相关事项
+     */
+    public RelatedItemsVO buildRelatedItems(Long familyId, Integer daysAhead) {
+        RelatedItemsVO vo = new RelatedItemsVO();
+
+        // 待办任务
+        List<Task> pendingTasks = taskService.getPendingTasksByFamily(familyId);
+        List<RelatedItemsVO.TaskItem> taskItems = pendingTasks.stream().limit(5).map(t -> {
+            RelatedItemsVO.TaskItem item = new RelatedItemsVO.TaskItem();
+            item.setId(t.getId());
+            item.setTitle(t.getTitle());
+            item.setAssignee(String.valueOf(t.getExecutorId()));
+            item.setDueDate(t.getDeadline() != null ? formatDateTime(t.getDeadline()) : null);
+            item.setStatus(t.getStatus());
+            return item;
+        }).collect(Collectors.toList());
+        vo.setPendingTasks(taskItems);
+
+        // 待处理测评
+        List<AssessmentOrder> pendingAssessments = assessmentOrderService.getPendingOrdersByFamily(familyId);
+        List<RelatedItemsVO.AssessmentItem> assessmentItems = pendingAssessments.stream().limit(3).map(o -> {
+            RelatedItemsVO.AssessmentItem item = new RelatedItemsVO.AssessmentItem();
+            item.setOrderId(o.getId());
+            item.setChildName(o.getPackageName());
+            item.setAppointmentDate(o.getCreatedAt() != null ? formatDateTime(o.getCreatedAt()) : null);
+            item.setStatus(o.getStatus());
+            return item;
+        }).collect(Collectors.toList());
+        vo.setPendingAssessments(assessmentItems);
+
+        // 健康预警
+        List<HealthAlertVO> healthAlerts = relationshipQualityService.getHealthAlerts(familyId);
+        List<RelatedItemsVO.HealthAlert> alertItems = healthAlerts.stream().limit(3).map(a -> {
+            RelatedItemsVO.HealthAlert item = new RelatedItemsVO.HealthAlert();
+            item.setId(a.getMemberId());
+            item.setMessage(a.getSuggestion());
+            item.setMembers(Collections.singletonList(a.getNickname()));
+            item.setSeverity(a.getAlertLevel());
+            return item;
+        }).collect(Collectors.toList());
+        vo.setHealthAlerts(alertItems);
+
+        // 即将到来的里程碑
+        List<MilestoneVO> milestones = relationshipQualityService.getMilestones(familyId, daysAhead);
+        List<RelatedItemsVO.Milestone> milestoneItems = milestones.stream().limit(5).map(m -> {
+            RelatedItemsVO.Milestone item = new RelatedItemsVO.Milestone();
+            item.setId(m.getMemberId());
+            item.setDescription(m.getEventName());
+            item.setTargetDate(formatDate(m.getEventDate()));
+            item.setDaysAway(m.getDaysAhead());
+            return item;
+        }).collect(Collectors.toList());
+        vo.setUpcomingMilestones(milestoneItems);
+
+        return vo;
+    }
+
+    /**
+     * 构建年度能量
+     * 
+     * @param memberId 成员 ID
+     * @param year 目标年份
+     * @return 年度能量
+     */
+    public MemberAnnualEnergyVO buildAnnualEnergy(Long memberId, Integer year) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null) {
+            return null;
+        }
+
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, "child");
+        if (attrs == null) {
+            attrs = familyMemberAttributeService.getByMember(memberId, "parent");
+        }
+        if (attrs == null) {
+            return null;
+        }
+
+        // 获取出生信息
+        Integer birthYear = null;
+        if (attrs.getBirthDatetime() != null) {
+            Calendar cal = Calendar.getInstance();
+            cal.setTime(attrs.getBirthDatetime());
+            birthYear = cal.get(Calendar.YEAR);
+        }
+        String zodiacCode = attrs.getZodiac();
+
+        if (birthYear == null) {
+            return null;
+        }
+
+        // 计算年度能量
+        ZodiacAnnualEnergyService.AnnualEnergyResult result = 
+            zodiacAnnualEnergyService.calculate(birthYear, zodiacCode, year);
+
+        MemberAnnualEnergyVO vo = new MemberAnnualEnergyVO();
+        vo.setMemberId(memberId);
+        vo.setNickname(member.getNickname());
+        vo.setYear(year);
+        vo.setDimensions(result.toMap());
+        vo.setJupiterPhase(result.getJupiterPhase());
+        vo.setSaturnPhase(result.getSaturnPhase());
+
+        // 生成亮点
+        List<String> highlights = new ArrayList<>();
+        Map<String, Integer> dims = result.toMap();
+        if (dims.get("body") >= 70) {
+            highlights.add("身体健康能量充沛");
+        }
+        if (dims.get("mind") >= 70) {
+            highlights.add("心理能量饱满");
+        }
+        if (dims.get("wisdom") >= 70) {
+            highlights.add("智慧能量开启");
+        }
+        if (dims.get("action") >= 70) {
+            highlights.add("行动力强劲");
+        }
+        if (dims.get("wealth") >= 70) {
+            highlights.add("财富能量旺盛");
+        }
+        if ("benming".equals(result.getYearRelation())) {
+            highlights.add("本命年,稳中求进");
+        }
+        if ("liuhe".equals(result.getYearRelation())) {
+            highlights.add("六合吉年,贵人相助");
+        }
+        if ("chong".equals(result.getYearRelation())) {
+            highlights.add("冲太岁,注意调整");
+        }
+        vo.setHighlights(highlights);
+
+        return vo;
+    }
+
+    /**
+     * 构建每日运势
+     * 
+     * @param familyId 家庭 ID
+     * @return 每日运势
+     */
+    public DailyFortuneVO buildDailyFortune(Long familyId) {
+        DailyFortuneVO vo = new DailyFortuneVO();
+
+        Calendar cal = Calendar.getInstance();
+        vo.setDate(formatDate(cal.getTime()));
+        vo.setLunarDate("农历" + getLunarDate(cal.getTime()));
+        vo.setDayGanzhi(getDayGanzhi(cal.getTime()));
+
+        // 五行
+        DailyFortuneVO.FiveElementsVO fiveElements = new DailyFortuneVO.FiveElementsVO();
+        fiveElements.setMain(getDayElement(cal.getTime()));
+        fiveElements.setDetail(getDayElementDetail(cal.getTime()));
+        vo.setFiveElements(fiveElements);
+
+        // 吉凶
+        vo.setAuspicious(Arrays.asList("宜祭祀", "宜祈福", "宜聚会"));
+        vo.setInauspicious(Arrays.asList("忌远行", "忌动土"));
+
+        // 家庭建议
+        vo.setFamilyAdvice("今日家庭能量和谐,适合安排家庭活动");
+
+        // 焦点成员提示
+        DailyFortuneVO.FocusMemberTip focus = new DailyFortuneVO.FocusMemberTip();
+        focus.setName("家庭中最活跃的成员");
+        focus.setTip("多倾听他的想法");
+        focus.setWuxingTip("木能量旺盛,注意平衡");
+        vo.setFocusMember(focus);
+
+        return vo;
+    }
+
+    /**
+     * 计算兼容性(Controller 调用入口)
+     */
+    public CompatibilityResultVO computeCompatibility(Long member1Id, String memberType1, Long member2Id, String memberType2) {
+        return compatibilityService.computeCompatibility(member1Id, memberType1, member2Id, memberType2);
+    }
+
+    // ========== 内部辅助方法 ==========
+
+    private FamilyMemberTraditionVO buildMemberTradition(FamilyMember member) {
+        FamilyMemberTraditionVO vo = new FamilyMemberTraditionVO();
+        vo.setMemberId(member.getId());
+        vo.setNickname(member.getNickname());
+        vo.setMemberType("child");
+
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(member.getId(), "child");
+        if (attrs == null) {
+            attrs = familyMemberAttributeService.getByMember(member.getId(), "parent");
+            if (attrs != null) vo.setMemberType("parent");
+        }
+
+        if (attrs != null) {
+            vo.setZodiac(attrs.getZodiac());
+            vo.setBloodType(attrs.getBloodType());
+            vo.setMindBaseScore(attrs.getMindBaseScore());
+            vo.setWisdomBaseScore(attrs.getWisdomBaseScore());
+            vo.setEightCharacters(parseJsonToMap(attrs.getEightCharacters()));
+            vo.setWuxingElements(parseWuxingMap(attrs.getWuxingElements()));
+            vo.setLifeNumber(extractLifeNumber(attrs.getBehaviorModifier()));
+        }
+        return vo;
+    }
+
+    private Map<String, Object> aggregateFamilyWuxing(List<TianpanMemberVO> members) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        Map<String, Integer> totals = new LinkedHashMap<>();
+        for (String elem : Arrays.asList("wood", "fire", "earth", "metal", "water")) {
+            totals.put(elem, 0);
+        }
+        int count = 0;
+
+        for (TianpanMemberVO m : members) {
+            if (m.getWuxingElements() != null) {
+                count++;
+                for (String elem : totals.keySet()) {
+                    totals.put(elem, totals.get(elem) + m.getWuxingElements().getOrDefault(elem, 0));
+                }
+            }
+        }
+
+        Map<String, Integer> pctMap = new LinkedHashMap<>();
+        String strongest = null;
+        int maxPct = 0;
+        int totalAll = count * 100;
+
+        for (String elem : totals.keySet()) {
+            int val = count > 0 ? (totals.get(elem) * 100 / totalAll) * 5 : 0; // 简化计算
+            pctMap.put(elem, val);
+            if (val > maxPct) {
+                maxPct = val;
+                strongest = elem;
+            }
+        }
+
+        result.put("strongestElement", strongest);
+        result.put("elements", pctMap);
+
+        return result;
+    }
+
+    private RelationshipSummaryVO computeRelationshipSummary(FamilyMemberTraditionVO m1, FamilyMemberTraditionVO m2) {
+        RelationshipSummaryVO summary = new RelationshipSummaryVO();
+        summary.setMemberId1(m1.getMemberId());
+        summary.setNickname1(m1.getNickname());
+        summary.setMemberId2(m2.getMemberId());
+        summary.setNickname2(m2.getNickname());
+
+        // 快速生肖查询
+        int zodiacScore = 50;
+        String pairingType = "ping";
+        if (m1.getZodiac() != null && m2.getZodiac() != null) {
+            LambdaQueryWrapper<ZodiacPairing> qw = new LambdaQueryWrapper<>();
+            qw.eq(ZodiacPairing::getZodiacCode1, m1.getZodiac());
+            qw.eq(ZodiacPairing::getZodiacCode2, m2.getZodiac());
+            ZodiacPairing p = zodiacPairingMapper.selectOne(qw);
+            if (p == null) {
+                LambdaQueryWrapper<ZodiacPairing> rqw = new LambdaQueryWrapper<>();
+                rqw.eq(ZodiacPairing::getZodiacCode1, m2.getZodiac());
+                rqw.eq(ZodiacPairing::getZodiacCode2, m1.getZodiac());
+                p = zodiacPairingMapper.selectOne(rqw);
+            }
+            if (p != null) {
+                zodiacScore = p.getCompatibilityScore();
+                pairingType = p.getPairingType();
+                summary.setSummary(p.getDescription());
+            }
+        }
+
+        summary.setZodiacScore(zodiacScore);
+        summary.setZodiacPairingType(pairingType);
+
+        // 整体评分
+        int overall = zodiacScore;
+        summary.setOverallScore(overall);
+
+        return summary;
+    }
+
+    private String generateWuxingRelation(RelationshipSummaryVO r) {
+        switch (r.getZodiacPairingType()) {
+            case "li": return "相合关系,彼此吸引";
+            case "chong": return "相冲关系,需要磨合";
+            case "hai": return "相害关系,注意包容";
+            case "sanhe": return "三合关系,默契十足";
+            default: return "平和关系,相安无事";
+        }
+    }
+
+    private String getColorByRole(String role) {
+        switch (role) {
+            case "parent": return "#4A9BD7";
+            case "child": return "#FF8C42";
+            case "teacher": return "#6366F1";
+            default: return "#9CA3AF";
+        }
+    }
+
+    private String calculateGeneration(String role) {
+        return "parent".equals(role) ? "父辈" : "子辈";
+    }
+
+    private String generateWuxingReading(Map<String, Integer> elements) {
+        if (elements == null || elements.isEmpty()) {
+            return "五行数据待完善";
+        }
+        String maxElem = null;
+        int maxVal = 0;
+        for (Map.Entry<String, Integer> e : elements.entrySet()) {
+            if (e.getValue() > maxVal) {
+                maxVal = e.getValue();
+                maxElem = e.getKey();
+            }
+        }
+        if (maxElem == null) return "五行均衡";
+        switch (maxElem) {
+            case "wood": return "木能量最强,富有创造力";
+            case "fire": return "火能量最强,热情洋溢";
+            case "earth": return "土能量最强,稳重可靠";
+            case "metal": return "金能量最强,思维清晰";
+            case "water": return "水能量最强,内心丰富";
+            default: return "五行均衡";
+        }
+    }
+
+    private Map<String, String> redactEightCharacters(Map<String, String> eightCharacters) {
+        if (eightCharacters == null) return null;
+        Map<String, String> redacted = new LinkedHashMap<>();
+        redacted.put("year", eightCharacters.get("year"));
+        // month/day/hour 设为 null
+        return redacted;
+    }
+
+    private ZodiacAnnualEnergyService.AnnualEnergyResult emptyEnergyResult() {
+        ZodiacAnnualEnergyService.AnnualEnergyResult r = new ZodiacAnnualEnergyService.AnnualEnergyResult();
+        r.setBody(0); r.setMind(0); r.setWisdom(0); r.setAction(0); r.setWealth(0);
+        return r;
+    }
+
+    private Map<String, Integer> parseWuxingMap(String json) {
+        Map<String, Integer> result = new LinkedHashMap<>();
+        if (json == null || json.isEmpty()) return result;
+        try {
+            Map<String, Object> raw = familyMemberAttributeService.fromJson(json);
+            for (String elem : Arrays.asList("wood", "fire", "earth", "metal", "water")) {
+                Object val = raw.get(elem);
+                if (val instanceof Number) {
+                    result.put(elem, ((Number) val).intValue());
+                }
+            }
+        } catch (Exception ignored) {}
+        return result;
+    }
+
+    private Map<String, String> parseJsonToMap(String json) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (json == null || json.isEmpty()) return result;
+        try {
+            Map<String, Object> raw = familyMemberAttributeService.fromJson(json);
+            for (Map.Entry<String, Object> entry : raw.entrySet()) {
+                result.put(entry.getKey(), String.valueOf(entry.getValue()));
+            }
+        } catch (Exception ignored) {}
+        return result;
+    }
+
+    private Integer extractLifeNumber(String behaviorModifier) {
+        if (behaviorModifier == null || behaviorModifier.isEmpty()) return null;
+        try {
+            Map<String, Object> map = familyMemberAttributeService.fromJson(behaviorModifier);
+            Object ln = map.get("lifeNumber");
+            if (ln instanceof Number) {
+                return ((Number) ln).intValue();
+            }
+        } catch (Exception ignored) {}
+        return null;
+    }
+
+    private String formatDate(Date date) {
+        if (date == null) return null;
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        return String.format("%d-%02d-%02d", cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
+    }
+
+    private String formatDateTime(Date date) {
+        if (date == null) return null;
+        return formatDate(date);
+    }
+
+    private String getLunarDate(Date date) {
+        // 简化,实际应使用农历库
+        return "初一";
+    }
+
+    private String getDayGanzhi(Date date) {
+        // 简化,实际应使用干支计算
+        return "甲子";
+    }
+
+    private String getDayElement(Date date) {
+        return "木";
+    }
+
+    private String getDayElementDetail(Date date) {
+        return "甲木参天";
+    }
+}

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ZodiacAnnualEnergyService.java

@@ -428,6 +428,11 @@ public class ZodiacAnnualEnergyService {
 
     // ==================== 西方星座 ====================
 
+    /** 根据月日查找太阳星座(公开包装器) */
+    public String getWesternSign(int month, int day) {
+        return findWesternSign(month, day);
+    }
+
     /** 根据月日查找太阳星座 */
     private String findWesternSign(int month, int day) {
         if (month < 1 || month > 12 || day < 1 || day > 31) {

文件差异内容过多而无法显示
+ 425 - 126
cfc-frontend/pages.json


+ 156 - 0
cfc-frontend/pages/tianpan/annual-energy.vue

@@ -0,0 +1,156 @@
+<template>
+<view class="annual-energy-page">
+  <view class="nav-bar">
+    <text class="nav-back" @click="goBack">←</text>
+    <text class="nav-title">流年能量</text>
+    <view class="nav-right"></view>
+  </view>
+  <view v-if="loading" class="loading-state"><text>加载中...</text></view>
+  <view v-else-if="error" class="error-state"><text class="error-text">{{ error }}</text></view>
+  <view v-else-if="energy" class="content">
+    <view class="year-selector">
+      <text class="year-current">{{ energy.year || currentYear }}年</text>
+      <text class="year-label">流年能量</text>
+    </view>
+    <view class="info-card">
+      <view class="card-title-bar"><text class="card-title">五维能量分布</text></view>
+      <view class="radar-container">
+        <canvas canvas-id="energyRadar" id="energyRadar" class="radar-canvas"></canvas>
+      </view>
+    </view>
+    <view class="info-card" v-if="energy.jupiterPhase || energy.saturnPhase">
+      <view class="card-title-bar"><text class="card-title">流年星象</text></view>
+      <view class="stars-grid">
+        <view v-if="energy.jupiterPhase" class="star-item">
+          <text class="star-name">木星</text>
+          <text class="star-phase">{{ energy.jupiterPhase }}</text>
+        </view>
+        <view v-if="energy.saturnPhase" class="star-item">
+          <text class="star-name">土星</text>
+          <text class="star-phase">{{ energy.saturnPhase }}</text>
+        </view>
+      </view>
+    </view>
+    <view class="info-card">
+      <view class="card-title-bar"><text class="card-title">各维度分析</text></view>
+      <view class="dimensions-list">
+        <view v-for="(value, key) in energy.dimensions" :key="key" class="dimension-item">
+          <view class="dim-header">
+            <text class="dim-label">{{ dimLabel(key) }}</text>
+            <text class="dim-value">{{ value }}分</text>
+          </view>
+          <view class="bar-track"><view class="bar-fill" :style="{width: value + '%', background: dimColor(key)}"></view></view>
+        </view>
+      </view>
+    </view>
+    <view class="info-card" v-if="energy.highlights && energy.highlights.length > 0">
+      <view class="card-title-bar"><text class="card-title">本年重点</text></view>
+      <view class="highlight-list">
+        <view v-for="(h, idx) in energy.highlights" :key="idx" class="highlight-item">
+          <text class="highlight-text">{{ h }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</view>
+</template>
+<script>
+export default {
+  data: function() {
+    return { energy: null, loading: false, error: null, memberId: null, currentYear: new Date().getFullYear() }
+  },
+  onLoad: function(query) { this.memberId = query.memberId; this.loadEnergy() },
+  onShow: function() {
+    var self = this
+    if (self.energy) { self.$nextTick(function() { self.drawRadar() }) }
+  },
+  methods: {
+    loadEnergy: function() {
+      var self = this
+      if (!self.memberId) { self.error = '参数错误'; return }
+      self.loading = true; self.error = null
+      self.$store.dispatch('tianpan/loadAnnualEnergy', { memberId: parseInt(self.memberId), year: self.currentYear })
+        .then(function(data) { self.energy = data })
+        .catch(function(e) { self.error = e.message || '加载失败' })
+        .finally(function() { self.loading = false; self.$nextTick(function() { self.drawRadar() }) })
+    },
+    goBack: function() { uni.navigateBack() },
+    drawRadar: function() {
+      var self = this
+      if (!self.energy || !self.energy.dimensions) return
+      var d = self.energy.dimensions
+      var w = uni.getSystemInfoSync().windowWidth
+      var size = Math.min(w - 80, 260)
+      var ctx = uni.createCanvasContext('energyRadar')
+      var cx = size / 2, cy = size / 2, r = size / 2 - 30
+      var dims = ['body', 'mind', 'wisdom', 'action', 'wealth']
+      var n = dims.length
+      var angleStep = Math.PI * 2 / n
+      var values = dims.map(function(di) { return (d[di] || 0) / 100 })
+      ctx.clearRect(0, 0, size, size)
+      for (var i = 1; i <= 4; i++) {
+        var rr = r * i / 4
+        ctx.beginPath()
+        for (var j = 0; j <= n; j++) {
+          var a = j * angleStep - Math.PI / 2
+          var px = cx + rr * Math.cos(a), py = cy + rr * Math.sin(a)
+          if (j === 0) ctx.moveTo(px, py)
+          else ctx.lineTo(px, py)
+        }
+        ctx.closePath(); ctx.strokeStyle = '#E5E7EB'; ctx.lineWidth = 0.5; ctx.stroke()
+      }
+      for (var k = 0; k < n; k++) {
+        var a2 = k * angleStep - Math.PI / 2
+        ctx.beginPath(); ctx.moveTo(cx, cy)
+        ctx.lineTo(cx + r * Math.cos(a2), cy + r * Math.sin(a2))
+        ctx.strokeStyle = '#D1D5DB'; ctx.lineWidth = 0.5; ctx.stroke()
+      }
+      ctx.beginPath()
+      for (var m = 0; m < n; m++) {
+        var a3 = m * angleStep - Math.PI / 2
+        var vx = cx + r * values[m] * Math.cos(a3), vy = cy + r * Math.sin(a3)
+        if (m === 0) ctx.moveTo(vx, vy)
+        else ctx.lineTo(vx, vy)
+      }
+      ctx.closePath(); ctx.fillStyle = 'rgba(249, 115, 22, 0.2)'; ctx.fill()
+      ctx.strokeStyle = '#F97316'; ctx.lineWidth = 2; ctx.stroke()
+      ctx.draw()
+    },
+    dimLabel: function(key) { var map = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }; return map[key] || key },
+    dimColor: function(key) { var map = { body: '#10B981', mind: '#EC4899', wisdom: '#8B5CF6', action: '#F97316', wealth: '#3B82F6' }; return map[key] || '#F97316' }
+  }
+}
+</script>
+<style scoped>
+.annual-energy-page { min-height: 100vh; background: #F3F4F6; }
+.nav-bar { display: flex; align-items: center; justify-content: space-between; padding: 20px 16px 10px; background: #F97316; }
+.nav-back { font-size: 18px; color: #FFF; }
+.nav-title { font-size: 17px; font-weight: bold; color: #FFF; }
+.nav-right { width: 40px; }
+.loading-state, .error-state { padding: 60px 20px; display: flex; justify-content: center; align-items: center; }
+.error-text { color: #EF4444; font-size: 14px; }
+.content { padding: 12px; }
+.year-selector { text-align: center; padding: 16px 0 8px; }
+.year-current { font-size: 28px; font-weight: bold; color: #F97316; }
+.year-label { font-size: 13px; color: #9CA3AF; display: block; }
+.info-card { background: #FFF; border-radius: 12px; padding: 16px; margin-bottom: 12px; }
+.card-title-bar { margin-bottom: 12px; border-bottom: 1px solid #F3F4F6; padding-bottom: 8px; }
+.card-title { font-size: 15px; font-weight: bold; color: #1F2937; }
+.radar-container { display: flex; justify-content: center; padding: 8px 0; }
+.radar-canvas { width: 260px; height: 260px; }
+.stars-grid { display: flex; gap: 12px; }
+.star-item { flex: 1; background: #FFF7ED; border-radius: 8px; padding: 12px; display: flex; flex-direction: column; align-items: center; }
+.star-name { font-size: 13px; color: #9CA3AF; }
+.star-phase { font-size: 14px; color: #F97316; font-weight: bold; margin-top: 4px; }
+.dimensions-list { display: flex; flex-direction: column; }
+.dimension-item { margin-bottom: 14px; }
+.dim-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
+.dim-label { font-size: 13px; color: #374151; font-weight: 500; }
+.dim-value { font-size: 13px; color: #F97316; font-weight: bold; }
+.bar-track { height: 6px; background: #E5E7EB; border-radius: 3px; margin-bottom: 4px; }
+.bar-fill { height: 100%; border-radius: 3px; }
+.highlight-list { display: flex; flex-direction: column; }
+.highlight-item { padding: 6px 0; border-bottom: 1px solid #F3F4F6; }
+.highlight-item:last-child { border-bottom: none; }
+.highlight-text { font-size: 13px; color: #374151; line-height: 1.5; }
+</style>

+ 110 - 0
cfc-frontend/pages/tianpan/daily-fortune.vue

@@ -0,0 +1,110 @@
+<template>
+<view class="daily-fortune-page">
+  <view class="nav-bar">
+    <text class="nav-back" @click="goBack">←</text>
+    <text class="nav-title">每日宜忌</text>
+    <view class="nav-right"></view>
+  </view>
+  <view v-if="loading" class="loading-state"><text>加载中...</text></view>
+  <view v-else-if="error" class="error-state"><text class="error-text">{{ error }}</text></view>
+  <view v-else-if="fortune" class="content">
+    <view class="date-banner">
+      <text class="date-text">{{ fortune.date || todayStr }}</text>
+      <text class="date-lunar" v-if="fortune.lunarDate">{{ fortune.lunarDate }}</text>
+    </view>
+    <view class="info-card" v-if="fortune.fiveElements">
+      <view class="card-title-bar"><text class="card-title">今日五行</text></view>
+      <view class="five-elements-display">
+        <text class="fe-main">{{ fortune.fiveElements.main || '' }}</text>
+        <text class="fe-detail" v-if="fortune.fiveElements.detail">{{ fortune.fiveElements.detail }}</text>
+      </view>
+    </view>
+    <view class="info-card" v-if="fortune.auspicious && fortune.auspicious.length > 0">
+      <view class="card-title-bar auspicious-bar"><text class="card-title">宜</text></view>
+      <view class="item-tags">
+        <view v-for="(item, idx) in fortune.auspicious" :key="idx" class="tag-item good">
+          <text class="tag-text">{{ item }}</text>
+        </view>
+      </view>
+    </view>
+    <view class="info-card" v-if="fortune.inauspicious && fortune.inauspicious.length > 0">
+      <view class="card-title-bar inauspicious-bar"><text class="card-title">忌</text></view>
+      <view class="item-tags">
+        <view v-for="(item, idx) in fortune.inauspicious" :key="idx" class="tag-item bad">
+          <text class="tag-text">{{ item }}</text>
+        </view>
+      </view>
+    </view>
+    <view class="info-card" v-if="fortune.familyAdvice">
+      <view class="card-title-bar"><text class="card-title">家庭建议</text></view>
+      <view class="advice-content"><text class="advice-text">{{ fortune.familyAdvice }}</text></view>
+    </view>
+    <view class="info-card" v-if="fortune.focusMember">
+      <view class="card-title-bar"><text class="card-title">今日关注</text></view>
+      <view class="focus-member-card">
+        <text class="focus-name">{{ fortune.focusMember.name }}</text>
+        <text class="focus-tip">{{ fortune.focusMember.tip }}</text>
+        <view v-if="fortune.focusMember.wuxingTip" class="focus-wuxing">
+          <text class="wuxing-tip-text">{{ fortune.focusMember.wuxingTip }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</view>
+</template>
+<script>
+export default {
+  data: function() { return { fortune: null, loading: false, error: null, todayStr: '' } },
+  onLoad: function() {
+    var now = new Date()
+    this.todayStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0')
+    this.loadDailyFortune()
+  },
+  methods: {
+    loadDailyFortune: function() {
+      var self = this
+      self.loading = true; self.error = null
+      self.$store.dispatch('tianpan/loadDailyFortune', { date: self.todayStr })
+        .then(function(data) { self.fortune = data })
+        .catch(function(e) { self.error = e.message || '加载失败' })
+        .finally(function() { self.loading = false })
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+</script>
+<style scoped>
+.daily-fortune-page { min-height: 100vh; background: #F3F4F6; }
+.nav-bar { display: flex; align-items: center; justify-content: space-between; padding: 20px 16px 10px; background: #F97316; }
+.nav-back { font-size: 18px; color: #FFF; }
+.nav-title { font-size: 17px; font-weight: bold; color: #FFF; }
+.nav-right { width: 40px; }
+.loading-state, .error-state { padding: 60px 20px; display: flex; justify-content: center; align-items: center; }
+.error-text { color: #EF4444; font-size: 14px; }
+.content { padding: 12px; }
+.date-banner { background: linear-gradient(135deg, #F97316, #FB923C); border-radius: 12px; padding: 20px; margin-bottom: 12px; text-align: center; }
+.date-text { font-size: 22px; font-weight: bold; color: #FFF; display: block; }
+.date-lunar { font-size: 13px; color: rgba(255,255,255,0.8); display: block; margin-top: 4px; }
+.info-card { background: #FFF; border-radius: 12px; padding: 16px; margin-bottom: 12px; }
+.card-title-bar { margin-bottom: 12px; border-bottom: 1px solid #F3F4F6; padding-bottom: 8px; display: flex; align-items: center; }
+.auspicious-bar .card-title { color: #10B981; font-size: 15px; font-weight: bold; }
+.inauspicious-bar .card-title { color: #EF4444; font-size: 15px; font-weight: bold; }
+.card-title { font-size: 15px; font-weight: bold; color: #1F2937; }
+.five-elements-display { text-align: center; padding: 8px 0; }
+.fe-main { font-size: 28px; font-weight: bold; color: #F97316; display: block; }
+.fe-detail { font-size: 13px; color: #9CA3AF; display: block; margin-top: 4px; }
+.item-tags { display: flex; flex-wrap: wrap; gap: 8px; }
+.tag-item { padding: 6px 14px; border-radius: 16px; }
+.tag-item.good { background: #DCFCE7; }
+.tag-item.bad { background: #FEE2E2; }
+.tag-text { font-size: 13px; }
+.tag-item.good .tag-text { color: #166534; }
+.tag-item.bad .tag-text { color: #991B1B; }
+.advice-content { padding: 4px 0; }
+.advice-text { font-size: 14px; color: #374151; line-height: 1.7; }
+.focus-member-card { display: flex; flex-direction: column; }
+.focus-name { font-size: 16px; font-weight: bold; color: #F97316; margin-bottom: 4px; }
+.focus-tip { font-size: 13px; color: #374151; line-height: 1.5; }
+.focus-wuxing { margin-top: 8px; background: #FFF7ED; border-radius: 8px; padding: 8px; }
+.wuxing-tip-text { font-size: 12px; color: #9CA3AF; }
+</style>

+ 890 - 0
cfc-frontend/pages/tianpan/index.vue

@@ -0,0 +1,890 @@
+<template>
+  <view class="tianpan-container">
+    <!-- 自定义导航栏 -->
+    <view class="custom-nav">
+      <view class="nav-header">
+        <text class="nav-title">家庭天盘</text>
+        <picker mode="selector" :range="yearOptions" :value="yearIndex" @change="onYearChange">
+          <view class="year-picker">
+            <text class="year-text">{{ currentYear }}</text>
+            <text class="picker-icon">▼</text>
+          </view>
+        </picker>
+      </view>
+      <view class="family-name" v-if="familyName">{{ familyName }}</view>
+    </view>
+
+    <!-- 天盘画布 -->
+    <view class="canvas-wrapper">
+      <canvas 
+        type="2d" 
+        id="tianpanCanvas" 
+        class="tianpan-canvas"
+        @touchend="onCanvasTouchEnd"
+      ></canvas>
+    </view>
+
+    <!-- 标签栏 -->
+    <view class="tab-bar">
+      <view 
+        class="tab-item" 
+        :class="{ active: currentTab === 0 }"
+        @click="switchTab(0)"
+      >
+        <text>任务</text>
+      </view>
+      <view 
+        class="tab-item" 
+        :class="{ active: currentTab === 1 }"
+        @click="switchTab(1)"
+      >
+        <text>测评</text>
+      </view>
+      <view 
+        class="tab-item" 
+        :class="{ active: currentTab === 2 }"
+        @click="switchTab(2)"
+      >
+        <text>预警</text>
+      </view>
+      <view 
+        class="tab-item" 
+        :class="{ active: currentTab === 3 }"
+        @click="switchTab(3)"
+      >
+        <text>里程碑</text>
+      </view>
+    </view>
+
+    <!-- 相关事项列表 -->
+    <view class="related-items-list">
+      <view 
+        class="item-card" 
+        v-for="item in currentItems" 
+        :key="item.id"
+        @click="onItemClick(item)"
+      >
+        <view class="item-icon">{{ item.icon || '📋' }}</view>
+        <view class="item-content">
+          <text class="item-title">{{ item.title }}</text>
+          <text class="item-desc">{{ item.description || '' }}</text>
+        </view>
+        <view class="item-arrow">›</view>
+      </view>
+      <view class="empty-state" v-if="currentItems.length === 0">
+        <text>暂无相关事项</text>
+      </view>
+    </view>
+
+    <!-- 每日运势卡片 -->
+    <view class="fortune-card" @click="goToDailyFortune">
+      <view class="fortune-header">
+        <text class="fortune-title">今日运势</text>
+        <text class="fortune-date">{{ currentDate }}</text>
+      </view>
+      <view class="fortune-content">
+        <text class="fortune-text">{{ dailyFortuneText || '点击查看今日家庭能量运势' }}</text>
+      </view>
+      <view class="fortune-arrow">查看详情 ›</view>
+    </view>
+
+    <!-- 成员详情弹窗 -->
+    <view class="member-popup" v-if="showMemberPopup" @touchmove.stop>
+      <view class="popup-overlay" @click="closeMemberPopup"></view>
+      <view class="popup-content">
+        <view class="popup-header">
+          <text class="popup-title">{{ selectedMember.nickname || selectedMember.name }}</text>
+          <text class="popup-close" @click="closeMemberPopup">×</text>
+        </view>
+        <view class="popup-body">
+          <view class="member-info-row">
+            <text class="info-label">生肖:</text>
+            <text class="info-value">{{ selectedMember.zodiac || '-' }}</text>
+          </view>
+          <view class="member-info-row">
+            <text class="info-label">星座:</text>
+            <text class="info-value">{{ selectedMember.westernSign || '-' }}</text>
+          </view>
+          <view class="member-info-row">
+            <text class="info-label">代际:</text>
+            <text class="info-value">{{ selectedMember.generation || '-' }}</text>
+          </view>
+          <view class="energy-bars">
+            <view class="energy-bar-item" v-for="energy in selectedMember.energy" :key="energy.type">
+              <text class="energy-label">{{ energy.label }}</text>
+              <view class="energy-bar">
+                <view class="energy-fill" :style="{ width: energy.value + '%' }"></view>
+              </view>
+              <text class="energy-value">{{ energy.value }}</text>
+            </view>
+          </view>
+        </view>
+        <view class="popup-footer">
+          <button class="detail-btn" @click="goToMemberDetail">查看详情</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-overlay" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text>加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex'
+
+export default {
+  data: function() {
+    return {
+      currentYear: new Date().getFullYear(),
+      yearOptions: [],
+      yearIndex: 0,
+      currentTab: 0,
+      showMemberPopup: false,
+      selectedMember: {},
+      canvasContext: null,
+      canvasWidth: 375,
+      canvasHeight: 375,
+      members: [],
+      relationships: [],
+      relatedItems: [],
+      dailyFortuneText: '',
+      currentDate: ''
+    }
+  },
+  computed: {
+    ...mapGetters('tianpan', ['members', 'relationships', 'familyWuxing', 'familyName']),
+    ...mapState('tianpan', ['loading', 'error']),
+    currentItems: function() {
+      var tabMap = ['tasks', 'assessments', 'alerts', 'milestones']
+      var items = this.relatedItems && this.relatedItems[tabMap[this.currentTab]] ? this.relatedItems[tabMap[this.currentTab]] : []
+      return items
+    }
+  },
+  onLoad: function() {
+    this.initYearOptions()
+    this.loadDashboardData()
+    this.loadDailyFortune()
+    this.loadRelatedItems()
+  },
+  onReady: function() {
+    var query = uni.createSelectorQuery()
+    query.select('#tianpanCanvas')
+      .fields({ node: true, size: true })
+      .exec(this.onCanvasReady)
+  },
+  methods: {
+    ...mapActions('tianpan', ['loadDashboard', 'loadDailyFortune', 'loadRelatedItems']),
+    
+    initYearOptions: function() {
+      var current = new Date().getFullYear()
+      for (var i = current - 2; i <= current + 2; i++) {
+        this.yearOptions.push(i)
+        if (i === current) {
+          this.yearIndex = this.yearOptions.length - 1
+        }
+      }
+      this.currentYear = current
+    },
+    
+    onYearChange: function(e) {
+      var index = e.detail.value
+      var year = this.yearOptions[index]
+      if (year !== this.currentYear) {
+        this.currentYear = year
+        this.loadDashboardData()
+      }
+    },
+    
+    loadDashboardData: function() {
+      var familyId = uni.getStorageSync('familyId')
+      if (!familyId) {
+        return
+      }
+      this.loadDashboard({ familyId: familyId, year: this.currentYear, daysAhead: 7 })
+        .then(this.drawCanvas)
+    },
+    
+    onCanvasReady: function(res) {
+      if (!res || !res[0] || !res[0].node) {
+        return
+      }
+      var ctx = res[0].node.getContext('2d')
+      var dpr = uni.getSystemInfoSync().pixelRatio
+      this.canvasContext = ctx
+      this.canvasContext.scale(dpr, dpr)
+      this.drawCanvas()
+    },
+    
+    drawCanvas: function() {
+      if (!this.canvasContext) {
+        return
+      }
+      var ctx = this.canvasContext
+      var w = this.canvasWidth
+      var h = this.canvasHeight
+      var cx = w / 2
+      var cy = h / 2
+      
+      // 清空画布
+      ctx.clearRect(0, 0, w, h)
+      ctx.fillStyle = '#FFF8F0'
+      ctx.fillRect(0, 0, w, h)
+      
+      // 获取数据
+      var members = this.members || []
+      var relationships = this.relationships || []
+      
+      // R0: 中心五维能量雷达图
+      this.drawRadar(ctx, cx, cy, w * 0.11, members)
+      
+      // R1: 内圈成员节点
+      this.drawMemberNodes(ctx, cx, cy, w * 0.18, members)
+      
+      // R2-R3: 外圈相关事项
+      this.drawRelatedItems(ctx, cx, cy, w * 0.32, w * 0.46, members)
+      
+      // 绘制关系连线
+      this.drawRelationshipLines(ctx, cx, cy, relationships)
+    },
+    
+    drawRadar: function(ctx, cx, cy, r, members) {
+      var axes = ['木', '火', '土', '金', '水']
+      var angleStep = (Math.PI * 2) / 5
+      
+      // 绘制五边形背景
+      ctx.beginPath()
+      for (var i = 0; i < 5; i++) {
+        var angle = i * angleStep - Math.PI / 2
+        var x = cx + r * Math.cos(angle)
+        var y = cy + r * Math.sin(angle)
+        if (i === 0) {
+          ctx.moveTo(x, y)
+        } else {
+          ctx.lineTo(x, y)
+        }
+      }
+      ctx.closePath()
+      ctx.fillStyle = 'rgba(249, 114, 22, 0.1)'
+      ctx.fill()
+      ctx.strokeStyle = '#F97316'
+      ctx.lineWidth = 1
+      ctx.stroke()
+      
+      // 绘制轴线
+      for (var j = 0; j < 5; j++) {
+        var angle = j * angleStep - Math.PI / 2
+        var x = cx + r * Math.cos(angle)
+        var y = cy + r * Math.sin(angle)
+        ctx.beginPath()
+        ctx.moveTo(cx, cy)
+        ctx.lineTo(x, y)
+        ctx.strokeStyle = 'rgba(249, 114, 22, 0.3)'
+        ctx.stroke()
+        
+        // 绘制轴标签
+        var labelX = cx + (r + 15) * Math.cos(angle)
+        var labelY = cy + (r + 15) * Math.sin(angle)
+        ctx.fillStyle = '#F97316'
+        ctx.font = '12px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(axes[j], labelX, labelY)
+      }
+    },
+    
+    drawMemberNodes: function(ctx, cx, cy, r, members) {
+      var angleStep = (Math.PI * 2) / (members.length || 1)
+      
+      for (var i = 0; i < members.length; i++) {
+        var member = members[i]
+        var angle = i * angleStep - Math.PI / 2
+        var x = cx + r * Math.cos(angle)
+        var y = cy + r * Math.sin(angle)
+        
+        // 绘制圆形节点
+        ctx.beginPath()
+        ctx.arc(x, y, 15, 0, Math.PI * 2)
+        ctx.fillStyle = this.getMemberColor(member.effectiveRole)
+        ctx.fill()
+        ctx.strokeStyle = '#FFF'
+        ctx.lineWidth = 2
+        ctx.stroke()
+        
+        // 绘制名字
+        ctx.fillStyle = '#FFF'
+        ctx.font = '10px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(member.nickname || member.name, x, y)
+        
+        // 存储点击区域
+        if (!this.memberHitAreas) {
+          this.memberHitAreas = []
+        }
+        this.memberHitAreas.push({
+          type: 'member',
+          data: member,
+          x: x,
+          y: y,
+          r: 15
+        })
+      }
+    },
+    
+    drawRelatedItems: function(ctx, cx, cy, rInner, rOuter, members) {
+      var items = this.relatedItems && this.relatedItems.tasks ? this.relatedItems.tasks : []
+      var angleStep = (Math.PI * 2) / (items.length || 1)
+      var r = (rInner + rOuter) / 2
+      
+      for (var i = 0; i < items.length; i++) {
+        var item = items[i]
+        var angle = i * angleStep - Math.PI / 2
+        var x = cx + r * Math.cos(angle)
+        var y = cy + r * Math.sin(angle)
+        
+        // 绘制 emoji
+        ctx.font = '16px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(item.icon || '📋', x, y)
+        
+        // 存储点击区域
+        if (!this.itemHitAreas) {
+          this.itemHitAreas = []
+        }
+        this.itemHitAreas.push({
+          type: 'item',
+          data: item,
+          x: x,
+          y: y,
+          r: 12
+        })
+      }
+    },
+    
+    drawRelationshipLines: function(ctx, cx, cy, relationships) {
+      for (var i = 0; i < relationships.length; i++) {
+        var rel = relationships[i]
+        if (!rel.fromMember || !rel.toMember) {
+          continue
+        }
+        
+        // 计算起点和终点
+        var memberCount = this.members.length || 1
+        var angleStep = (Math.PI * 2) / memberCount
+        var r = this.canvasWidth * 0.18
+        
+        var fromIndex = -1
+        var toIndex = -1
+        for (var j = 0; j < this.members.length; j++) {
+          if (this.members[j].id === rel.fromMember.id) {
+            fromIndex = j
+          }
+          if (this.members[j].id === rel.toMember.id) {
+            toIndex = j
+          }
+        }
+        
+        if (fromIndex < 0 || toIndex < 0) {
+          continue
+        }
+        
+        var fromAngle = fromIndex * angleStep - Math.PI / 2
+        var toAngle = toIndex * angleStep - Math.PI / 2
+        
+        var x1 = cx + r * Math.cos(fromAngle)
+        var y1 = cy + r * Math.sin(fromAngle)
+        var x2 = cx + r * Math.cos(toAngle)
+        var y2 = cy + r * Math.sin(toAngle)
+        
+        // 贝塞尔曲线
+        ctx.beginPath()
+        ctx.moveTo(x1, y1)
+        var cx1 = (x1 + x2) / 2 + (y2 - y1) * 0.2
+        var cy1 = (y1 + y2) / 2 - (x2 - x1) * 0.2
+        ctx.quadraticCurveTo(cx1, cy1, x2, y2)
+        
+        // 根据分数设置颜色
+        var score = rel.score || 0
+        if (score >= 80) {
+          ctx.strokeStyle = '#10B981'
+        } else if (score >= 60) {
+          ctx.strokeStyle = '#F59E0B'
+        } else {
+          ctx.strokeStyle = '#EF4444'
+        }
+        ctx.lineWidth = 2
+        ctx.stroke()
+      }
+    },
+    
+    getMemberColor: function(role) {
+      var colorMap = {
+        'parent': '#F97316',
+        'child': '#3B82F6',
+        'teacher': '#8B5CF6'
+      }
+      return colorMap[role] || '#6B7280'
+    },
+    
+    onCanvasTouchEnd: function(e) {
+      if (!e || !e.changedTouches || !e.changedTouches[0]) {
+        return
+      }
+      var touch = e.changedTouches[0]
+      var x = touch.x
+      var y = touch.y
+      
+      // 检测成员点击
+      if (this.memberHitAreas) {
+        for (var i = 0; i < this.memberHitAreas.length; i++) {
+          var area = this.memberHitAreas[i]
+          var dx = x - area.x
+          var dy = y - area.y
+          if (dx * dx + dy * dy <= area.r * area.r) {
+            this.showMemberDetail(area.data)
+            return
+          }
+        }
+      }
+      
+      // 检测事项点击
+      if (this.itemHitAreas) {
+        for (var j = 0; j < this.itemHitAreas.length; j++) {
+          var itemArea = this.itemHitAreas[j]
+          var dx2 = x - itemArea.x
+          var dy2 = y - itemArea.y
+          if (dx2 * dx2 + dy2 * dy2 <= itemArea.r * itemArea.r) {
+            this.onItemClick(itemArea.data)
+            return
+          }
+        }
+      }
+    },
+    
+    showMemberDetail: function(member) {
+      this.selectedMember = member
+      this.showMemberPopup = true
+    },
+    
+    closeMemberPopup: function() {
+      this.showMemberPopup = false
+      this.selectedMember = {}
+    },
+    
+    goToMemberDetail: function() {
+      if (!this.selectedMember || !this.selectedMember.id) {
+        return
+      }
+      uni.navigateTo({
+        url: '/pages/tianpan/member-detail?id=' + this.selectedMember.id
+      })
+      this.closeMemberPopup()
+    },
+    
+    switchTab: function(index) {
+      this.currentTab = index
+    },
+    
+    onItemClick: function(item) {
+      if (!item || !item.id) {
+        return
+      }
+      // 根据类型跳转
+      if (item.type === 'task') {
+        uni.navigateTo({ url: '/pages/tasks/detail?id=' + item.id })
+      } else if (item.type === 'assessment') {
+        uni.navigateTo({ url: '/pages/assessment/detail?id=' + item.id })
+      } else if (item.type === 'alert') {
+        uni.navigateTo({ url: '/pages/health/alert-detail?id=' + item.id })
+      } else if (item.type === 'milestone') {
+        uni.navigateTo({ url: '/pages/family/milestone-detail?id=' + item.id })
+      }
+    },
+    
+    goToDailyFortune: function() {
+      uni.navigateTo({
+        url: '/pages/tianpan/daily-fortune'
+      })
+    },
+    
+    loadDailyFortune: function() {
+      var familyId = uni.getStorageSync('familyId')
+      if (!familyId) {
+        return
+      }
+      var today = new Date()
+      var dateStr = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate()
+      this.loadDailyFortune({ familyId: familyId, date: dateStr })
+        .then(this.setFortuneText)
+    },
+    
+    setFortuneText: function(data) {
+      if (data && data.advice) {
+        this.dailyFortuneText = data.advice
+      }
+      var today = new Date()
+      this.currentDate = today.getFullYear() + '年' + (today.getMonth() + 1) + '月' + today.getDate() + '日'
+    },
+    
+    loadRelatedItems: function() {
+      var familyId = uni.getStorageSync('familyId')
+      if (!familyId) {
+        return
+      }
+      this.loadRelatedItems({ familyId: familyId })
+    }
+  },
+  watch: {
+    members: function(newVal) {
+      if (newVal && this.canvasContext) {
+        this.drawCanvas()
+      }
+    },
+    relationships: function(newVal) {
+      if (newVal && this.canvasContext) {
+        this.drawCanvas()
+      }
+    }
+  }
+}
+</script>
+
+<style>
+.tianpan-container {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #FFF8F0 0%, #FFFFFF 100%);
+  padding-bottom: 20px;
+}
+
+/* 自定义导航栏 */
+.custom-nav {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  padding: 44px 16px 12px;
+  color: #FFFFFF;
+}
+
+.nav-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.nav-title {
+  font-size: 20px;
+  font-weight: bold;
+}
+
+.year-picker {
+  display: flex;
+  align-items: center;
+  background: rgba(255, 255, 255, 0.2);
+  padding: 4px 12px;
+  border-radius: 16px;
+}
+
+.year-text {
+  font-size: 14px;
+  margin-right: 4px;
+}
+
+.picker-icon {
+  font-size: 10px;
+}
+
+.family-name {
+  text-align: center;
+  margin-top: 8px;
+  font-size: 14px;
+  opacity: 0.9;
+}
+
+/* 画布区域 */
+.canvas-wrapper {
+  display: flex;
+  justify-content: center;
+  padding: 20px 0;
+}
+
+.tianpan-canvas {
+  width: 375px;
+  height: 375px;
+  background: #FFF8F0;
+  border-radius: 16px;
+  box-shadow: 0 4px 12px rgba(249, 114, 22, 0.1);
+}
+
+/* 标签栏 */
+.tab-bar {
+  display: flex;
+  justify-content: space-around;
+  background: #FFFFFF;
+  margin: 0 16px;
+  padding: 12px 0;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.tab-item {
+  padding: 6px 16px;
+  border-radius: 20px;
+  font-size: 14px;
+  color: #6B7280;
+}
+
+.tab-item.active {
+  background: #F97316;
+  color: #FFFFFF;
+}
+
+/* 相关事项列表 */
+.related-items-list {
+  padding: 16px;
+}
+
+.item-card {
+  display: flex;
+  align-items: center;
+  background: #FFFFFF;
+  padding: 16px;
+  border-radius: 12px;
+  margin-bottom: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.item-icon {
+  font-size: 28px;
+  margin-right: 12px;
+}
+
+.item-content {
+  flex: 1;
+}
+
+.item-title {
+  font-size: 16px;
+  font-weight: 500;
+  color: #1F2937;
+  display: block;
+  margin-bottom: 4px;
+}
+
+.item-desc {
+  font-size: 12px;
+  color: #9CA3AF;
+  display: block;
+}
+
+.item-arrow {
+  font-size: 20px;
+  color: #D1D5DB;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 40px 0;
+  color: #9CA3AF;
+  font-size: 14px;
+}
+
+/* 每日运势卡片 */
+.fortune-card {
+  margin: 16px;
+  padding: 20px;
+  background: linear-gradient(135deg, #FED7AA 0%, #FDBA74 100%);
+  border-radius: 16px;
+  box-shadow: 0 4px 12px rgba(249, 114, 22, 0.2);
+}
+
+.fortune-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.fortune-title {
+  font-size: 18px;
+  font-weight: bold;
+  color: #7C2D12;
+}
+
+.fortune-date {
+  font-size: 12px;
+  color: #9A3412;
+}
+
+.fortune-content {
+  margin-bottom: 12px;
+}
+
+.fortune-text {
+  font-size: 14px;
+  color: #9A3412;
+  line-height: 1.6;
+}
+
+.fortune-arrow {
+  text-align: right;
+  font-size: 12px;
+  color: #7C2D12;
+}
+
+/* 成员弹窗 */
+.member-popup {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1000;
+}
+
+.popup-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+}
+
+.popup-content {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #FFFFFF;
+  border-radius: 20px 20px 0 0;
+  padding: 24px;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+
+.popup-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.popup-title {
+  font-size: 20px;
+  font-weight: bold;
+  color: #1F2937;
+}
+
+.popup-close {
+  font-size: 28px;
+  color: #9CA3AF;
+  line-height: 1;
+}
+
+.popup-body {
+  margin-bottom: 20px;
+}
+
+.member-info-row {
+  display: flex;
+  margin-bottom: 12px;
+}
+
+.info-label {
+  width: 80px;
+  color: #6B7280;
+  font-size: 14px;
+}
+
+.info-value {
+  flex: 1;
+  color: #1F2937;
+  font-size: 14px;
+}
+
+.energy-bars {
+  margin-top: 20px;
+}
+
+.energy-bar-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.energy-label {
+  width: 40px;
+  color: #6B7280;
+  font-size: 14px;
+}
+
+.energy-bar {
+  flex: 1;
+  height: 8px;
+  background: #E5E7EB;
+  border-radius: 4px;
+  margin: 0 12px;
+  overflow: hidden;
+}
+
+.energy-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #F97316 0%, #FB923C 100%);
+  border-radius: 4px;
+}
+
+.energy-value {
+  width: 30px;
+  text-align: right;
+  color: #1F2937;
+  font-size: 14px;
+}
+
+.popup-footer {
+  display: flex;
+  justify-content: center;
+}
+
+.detail-btn {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  color: #FFFFFF;
+  border: none;
+  padding: 12px 48px;
+  border-radius: 24px;
+  font-size: 16px;
+}
+
+/* 加载状态 */
+.loading-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(255, 255, 255, 0.9);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  z-index: 2000;
+}
+
+.loading-spinner {
+  width: 40px;
+  height: 40px;
+  border: 4px solid #E5E7EB;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-bottom: 16px;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 531 - 0
cfc-frontend/pages/tianpan/member-detail.vue

@@ -0,0 +1,531 @@
+<template>
+  <view class="member-detail-container">
+    <!-- 自定义导航栏 -->
+    <view class="custom-nav">
+      <view class="nav-back" @click="goBack">‹ 返回</view>
+      <text class="nav-title">成员详情</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text>加载中...</text>
+    </view>
+
+    <!-- 错误状态 -->
+    <view class="error-state" v-if="error && !loading">
+      <text>{{ error }}</text>
+      <button class="retry-btn" @click="loadData">重试</button>
+    </view>
+
+    <!-- 详情内容 -->
+    <view class="detail-content" v-if="member && !loading">
+      <!-- 基本信息卡片 -->
+      <view class="info-card">
+        <view class="member-header">
+          <view class="avatar" :style="{ backgroundColor: memberColor }">
+            <text class="avatar-text">{{ member.nickname ? member.nickname.charAt(0) : 'M' }}</text>
+          </view>
+          <view class="member-info">
+            <text class="member-name">{{ member.nickname || member.name }}</text>
+            <text class="member-role">{{ getRoleText(member.effectiveRole) }}</text>
+          </view>
+        </view>
+
+        <view class="info-grid">
+          <view class="info-item">
+            <text class="info-label">生肖</text>
+            <text class="info-value">{{ member.zodiac || '-' }}</text>
+          </view>
+          <view class="info-item">
+            <text class="info-label">星座</text>
+            <text class="info-value">{{ member.westernSign || '-' }}</text>
+          </view>
+          <view class="info-item">
+            <text class="info-label">血型</text>
+            <text class="info-value">{{ member.bloodType || '-' }}</text>
+          </view>
+          <view class="info-item">
+            <text class="info-label">代际</text>
+            <text class="info-value">{{ member.generation || '-' }}</text>
+          </view>
+          <view class="info-item">
+            <text class="info-label">生命数字</text>
+            <text class="info-value">{{ member.lifeNumber || '-' }}</text>
+          </view>
+          <view class="info-item">
+            <text class="info-label">五行属性</text>
+            <text class="info-value">{{ member.wuxing || '-' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 五维能量卡片 -->
+      <view class="energy-card">
+        <view class="card-header">
+          <text class="card-title">五维能量</text>
+        </view>
+        <view class="energy-bars">
+          <view class="energy-bar-item" v-for="energy in memberEnergy" :key="energy.type">
+            <view class="energy-info">
+              <text class="energy-label">{{ energy.label }}</text>
+              <text class="energy-desc">{{ energy.description }}</text>
+            </view>
+            <view class="energy-bar">
+              <view class="energy-fill" :style="{ width: energy.value + '%', backgroundColor: energy.color }"></view>
+            </view>
+            <text class="energy-value">{{ energy.value }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 关系质量卡片 -->
+      <view class="relationship-card" v-if="relationships && relationships.length > 0">
+        <view class="card-header">
+          <text class="card-title">关系质量</text>
+        </view>
+        <view class="relationship-list">
+          <view class="relationship-item" v-for="rel in relationships" :key="rel.toMemberId">
+            <view class="rel-member">
+              <text class="rel-name">{{ rel.toMemberName }}</text>
+              <text class="rel-role">{{ getRoleText(rel.toMemberRole) }}</text>
+            </view>
+            <view class="rel-score">
+              <text class="score-value" :class="getScoreClass(rel.score)">{{ rel.score }}</text>
+              <text class="score-label">分</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="action-buttons">
+        <button class="action-btn primary" @click="viewAnnualEnergy">查看年度能量</button>
+        <button class="action-btn secondary" @click="viewCompatibility">查看兼容性分析</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { mapActions } from 'vuex'
+
+export default {
+  data: function() {
+    return {
+      memberId: '',
+      member: null,
+      relationships: [],
+      loading: false,
+      error: null
+    }
+  },
+  computed: {
+    memberColor: function() {
+      if (!this.member || !this.member.effectiveRole) {
+        return '#6B7280'
+      }
+      var colorMap = {
+        'parent': '#F97316',
+        'child': '#3B82F6',
+        'teacher': '#8B5CF6'
+      }
+      return colorMap[this.member.effectiveRole] || '#6B7280'
+    },
+    memberEnergy: function() {
+      if (!this.member || !this.member.energy) {
+        return []
+      }
+      var energyMap = {
+        'body': { label: '身', description: '身体健康', color: '#10B981' },
+        'mind': { label: '心', description: '心理健康', color: '#F59E0B' },
+        'wisdom': { label: '智', description: '智慧成长', color: '#3B82F6' },
+        'action': { label: '行', description: '行动力', color: '#8B5CF6' },
+        'wealth': { label: '富', description: '财富能量', color: '#F97316' }
+      }
+      var result = []
+      for (var key in this.member.energy) {
+        var energy = this.member.energy[key]
+        var info = energyMap[key] || { label: key, description: '', color: '#6B7280' }
+        result.push({
+          type: key,
+          label: info.label,
+          description: info.description,
+          value: energy,
+          color: info.color
+        })
+      }
+      return result
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.id) {
+      this.memberId = options.id
+      this.loadData()
+    }
+  },
+  methods: {
+    ...mapActions('tianpan', ['loadMemberDetail']),
+
+    loadData: function() {
+      this.loading = true
+      this.error = null
+      this.loadMemberDetail({ memberId: this.memberId })
+        .then(this.setMemberData)
+        .catch(this.handleError)
+        .finally(function() {
+          this.loading = false
+        }.bind(this))
+    },
+
+    setMemberData: function(data) {
+      if (data) {
+        this.member = data
+        if (data.relationships) {
+          this.relationships = data.relationships
+        }
+      }
+    },
+
+    handleError: function(e) {
+      this.error = e.message || '加载失败'
+    },
+
+    getRoleText: function(role) {
+      var roleMap = {
+        'parent': '家长',
+        'child': '孩子',
+        'teacher': '规划师'
+      }
+      return roleMap[role] || role
+    },
+
+    getScoreClass: function(score) {
+      if (!score) {
+        return ''
+      }
+      if (score >= 80) {
+        return 'score-good'
+      } else if (score >= 60) {
+        return 'score-medium'
+      } else {
+        return 'score-low'
+      }
+    },
+
+    goBack: function() {
+      uni.navigateBack()
+    },
+
+    viewAnnualEnergy: function() {
+      uni.navigateTo({
+        url: '/pages/tianpan/annual-energy?memberId=' + this.memberId
+      })
+    },
+
+    viewCompatibility: function() {
+      uni.navigateTo({
+        url: '/pages/tianpan/relation-detail?memberId=' + this.memberId
+      })
+    }
+  }
+}
+</script>
+
+<style>
+.member-detail-container {
+  min-height: 100vh;
+  background: #F9FAFB;
+  padding-bottom: 20px;
+}
+
+.custom-nav {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  padding: 44px 16px 16px;
+  display: flex;
+  align-items: center;
+}
+
+.nav-back {
+  color: #FFFFFF;
+  font-size: 14px;
+  margin-right: 16px;
+}
+
+.nav-title {
+  color: #FFFFFF;
+  font-size: 18px;
+  font-weight: bold;
+}
+
+.loading-state, .error-state {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 80px 20px;
+  text-align: center;
+}
+
+.loading-spinner {
+  width: 40px;
+  height: 40px;
+  border: 4px solid #E5E7EB;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-bottom: 16px;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.retry-btn {
+  margin-top: 16px;
+  background: #F97316;
+  color: #FFFFFF;
+  border: none;
+  padding: 10px 32px;
+  border-radius: 20px;
+  font-size: 14px;
+}
+
+.detail-content {
+  padding: 16px;
+}
+
+.info-card {
+  background: #FFFFFF;
+  border-radius: 16px;
+  padding: 20px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.member-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.avatar {
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-right: 16px;
+}
+
+.avatar-text {
+  color: #FFFFFF;
+  font-size: 28px;
+  font-weight: bold;
+}
+
+.member-info {
+  flex: 1;
+}
+
+.member-name {
+  font-size: 20px;
+  font-weight: bold;
+  color: #1F2937;
+  display: block;
+  margin-bottom: 4px;
+}
+
+.member-role {
+  font-size: 14px;
+  color: #6B7280;
+}
+
+.info-grid {
+  display: flex;
+  flex-wrap: wrap;
+}
+
+.info-item {
+  width: 50%;
+  padding: 12px 0;
+  border-bottom: 1px solid #F3F4F6;
+}
+
+.info-item:nth-child(2n) {
+  border-right: none;
+}
+
+.info-label {
+  display: block;
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-bottom: 4px;
+}
+
+.info-value {
+  display: block;
+  font-size: 14px;
+  color: #1F2937;
+  font-weight: 500;
+}
+
+.energy-card, .relationship-card {
+  background: #FFFFFF;
+  border-radius: 16px;
+  padding: 20px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.card-header {
+  margin-bottom: 16px;
+}
+
+.card-title {
+  font-size: 18px;
+  font-weight: bold;
+  color: #1F2937;
+}
+
+.energy-bars {
+  margin-top: 16px;
+}
+
+.energy-bar-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.energy-info {
+  width: 80px;
+}
+
+.energy-label {
+  display: block;
+  font-size: 14px;
+  font-weight: 500;
+  color: #1F2937;
+}
+
+.energy-desc {
+  display: block;
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-top: 2px;
+}
+
+.energy-bar {
+  flex: 1;
+  height: 10px;
+  background: #E5E7EB;
+  border-radius: 5px;
+  margin: 0 12px;
+  overflow: hidden;
+}
+
+.energy-fill {
+  height: 100%;
+  border-radius: 5px;
+  transition: width 0.3s ease;
+}
+
+.energy-value {
+  width: 36px;
+  text-align: right;
+  font-size: 14px;
+  font-weight: bold;
+  color: #1F2937;
+}
+
+.relationship-list {
+  margin-top: 16px;
+}
+
+.relationship-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 0;
+  border-bottom: 1px solid #F3F4F6;
+}
+
+.relationship-item:last-child {
+  border-bottom: none;
+}
+
+.rel-member {
+  flex: 1;
+}
+
+.rel-name {
+  display: block;
+  font-size: 14px;
+  font-weight: 500;
+  color: #1F2937;
+}
+
+.rel-role {
+  display: block;
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-top: 2px;
+}
+
+.rel-score {
+  display: flex;
+  align-items: baseline;
+}
+
+.score-value {
+  font-size: 24px;
+  font-weight: bold;
+}
+
+.score-label {
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-left: 2px;
+}
+
+.score-good {
+  color: #10B981;
+}
+
+.score-medium {
+  color: #F59E0B;
+}
+
+.score-low {
+  color: #EF4444;
+}
+
+.action-buttons {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 16px;
+}
+
+.action-btn {
+  padding: 14px 24px;
+  border-radius: 12px;
+  font-size: 16px;
+  border: none;
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  color: #FFFFFF;
+}
+
+.action-btn.secondary {
+  background: #FFFFFF;
+  color: #F97316;
+  border: 2px solid #F97316;
+}
+</style>

+ 136 - 0
cfc-frontend/pages/tianpan/related-items.vue

@@ -0,0 +1,136 @@
+<template>
+<view class="related-items-page">
+  <view class="nav-bar">
+    <text class="nav-back" @click="goBack">←</text>
+    <text class="nav-title">关注事宜</text>
+    <view class="nav-right"></view>
+  </view>
+  <view class="tab-bar">
+    <view v-for="(tab, idx) in tabs" :key="idx" :class="'tab-item ' + (activeTab === idx ? 'active' : '')" @click="switchTab(idx)">
+      <text class="tab-text">{{ tab.label }}</text>
+      <view v-if="activeTab === idx" class="tab-underline"></view>
+    </view>
+  </view>
+  <view v-if="loading" class="loading-state"><text>加载中...</text></view>
+  <view v-else-if="error" class="error-state"><text class="error-text">{{ error }}</text></view>
+  <view v-else class="content">
+    <view v-if="activeTab === 0" class="tab-content">
+      <view v-if="!items.pendingTasks || items.pendingTasks.length === 0" class="empty-state"><text class="empty-text">暂无待办任务</text></view>
+      <view v-else class="item-list">
+        <view v-for="(item, idx) in items.pendingTasks" :key="idx" class="item-card" @click="goToTask(item)">
+          <view class="item-header">
+            <text class="item-title">{{ item.title }}</text>
+            <text :class="'status-tag ' + statusClass(item.status)">{{ statusText(item.status) }}</text>
+          </view>
+          <view class="item-meta">
+            <text class="meta-text" v-if="item.assignee">负责人: {{ item.assignee }}</text>
+            <text class="meta-text" v-if="item.dueDate">截止: {{ item.dueDate }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+    <view v-if="activeTab === 1" class="tab-content">
+      <view v-if="!items.pendingAssessments || items.pendingAssessments.length === 0" class="empty-state"><text class="empty-text">暂无待测评</text></view>
+      <view v-else class="item-list">
+        <view v-for="(item, idx) in items.pendingAssessments" :key="idx" class="item-card">
+          <view class="item-header">
+            <text class="item-title">{{ item.childName }}的测评</text>
+            <text :class="'status-tag ' + statusClass(item.status)">{{ statusText(item.status) }}</text>
+          </view>
+          <view class="item-meta">
+            <text class="meta-text" v-if="item.appointmentDate">{{ item.appointmentDate }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+    <view v-if="activeTab === 2" class="tab-content">
+      <view v-if="!items.healthAlerts || items.healthAlerts.length === 0" class="empty-state"><text class="empty-text">暂无健康提醒</text></view>
+      <view v-else class="item-list">
+        <view v-for="(item, idx) in items.healthAlerts" :key="idx" class="item-card">
+          <view class="item-header">
+            <text class="item-title">{{ item.message }}</text>
+            <text :class="'alert-level ' + alertLevelClass(item.severity)">{{ item.severity }}</text>
+          </view>
+          <view class="item-meta">
+            <text class="meta-text" v-if="item.members && item.members.length > 0">成员: {{ item.members.join(', ') }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+    <view v-if="activeTab === 3" class="tab-content">
+      <view v-if="!items.upcomingMilestones || items.upcomingMilestones.length === 0" class="empty-state"><text class="empty-text">暂无里程碑</text></view>
+      <view v-else class="item-list">
+        <view v-for="(item, idx) in items.upcomingMilestones" :key="idx" class="item-card">
+          <view class="item-header">
+            <text class="item-title">{{ item.description }}</text>
+          </view>
+          <view class="item-meta">
+            <text class="meta-text" v-if="item.targetDate">目标: {{ item.targetDate }}</text>
+            <text class="meta-text" v-if="item.daysAway !== undefined">{{ item.daysAway }}天后</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</view>
+</template>
+<script>
+export default {
+  data: function() {
+    return {
+      activeTab: 0,
+      tabs: [{ label: '任务' }, { label: '测评' }, { label: '健康' }, { label: '里程碑' }],
+      items: { pendingTasks: [], pendingAssessments: [], healthAlerts: [], upcomingMilestones: [] },
+      loading: false, error: null
+    }
+  },
+  onLoad: function() { this.loadRelatedItems() },
+  methods: {
+    switchTab: function(idx) { this.activeTab = idx },
+    loadRelatedItems: function() {
+      var self = this
+      self.loading = true; self.error = null
+      self.$store.dispatch('tianpan/loadRelatedItems', {})
+        .then(function(data) { self.items = data || self.items })
+        .catch(function(e) { self.error = e.message || '加载失败' })
+        .finally(function() { self.loading = false })
+    },
+    goBack: function() { uni.navigateBack() },
+    statusText: function(s) { var map = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }; return map[s] || s || '' },
+    statusClass: function(s) { var map = { pending: 'pending', in_progress: 'in-progress', completed: 'completed', cancelled: 'cancelled' }; return map[s] || 'pending' },
+    alertLevelClass: function(l) { var map = { low: 'alert-low', medium: 'alert-medium', high: 'alert-high' }; return map[l] || 'alert-low' },
+    goToTask: function(item) { if (item.id) { uni.navigateTo({ url: '/pages/tasks/detail?id=' + item.id }) } }
+  }
+}
+</script>
+<style scoped>
+.related-items-page { min-height: 100vh; background: #F3F4F6; }
+.nav-bar { display: flex; align-items: center; justify-content: space-between; padding: 20px 16px 10px; background: #F97316; }
+.nav-back { font-size: 18px; color: #FFF; }
+.nav-title { font-size: 17px; font-weight: bold; color: #FFF; }
+.nav-right { width: 40px; }
+.tab-bar { display: flex; background: #FFF; border-bottom: 1px solid #E5E7EB; position: sticky; top: 0; z-index: 10; }
+.tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 12px 0; position: relative; }
+.tab-text { font-size: 14px; color: #6B7280; }
+.tab-item.active .tab-text { color: #F97316; font-weight: 500; }
+.tab-underline { position: absolute; bottom: 0; width: 40px; height: 2px; background: #F97316; border-radius: 1px; }
+.loading-state, .error-state, .empty-state { padding: 60px 20px; display: flex; justify-content: center; align-items: center; }
+.error-text, .empty-text { font-size: 14px; color: #9CA3AF; }
+.content { padding: 12px; }
+.tab-content { min-height: 300px; }
+.item-list { display: flex; flex-direction: column; }
+.item-card { background: #FFF; border-radius: 12px; padding: 14px; margin-bottom: 10px; }
+.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+.item-title { font-size: 14px; font-weight: 500; color: #1F2937; flex: 1; }
+.status-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; }
+.status-tag.pending { background: #FEF3C7; color: #92400E; }
+.status-tag.in-progress { background: #DBEAFE; color: #1E40AF; }
+.status-tag.completed { background: #DCFCE7; color: #166534; }
+.status-tag.cancelled { background: #F3F4F6; color: #6B7280; }
+.alert-level { font-size: 11px; padding: 2px 8px; border-radius: 10px; }
+.alert-low { background: #DCFCE7; color: #166534; }
+.alert-medium { background: #FEF3C7; color: #92400E; }
+.alert-high { background: #FEE2E2; color: #991B1B; }
+.item-meta { display: flex; gap: 12px; margin-bottom: 4px; }
+.meta-text { font-size: 12px; color: #9CA3AF; }
+</style>

+ 702 - 0
cfc-frontend/pages/tianpan/relation-detail.vue

@@ -0,0 +1,702 @@
+<template>
+  <view class="relation-detail-container">
+    <!-- 自定义导航栏 -->
+    <view class="custom-nav">
+      <view class="nav-back" @click="goBack">‹ 返回</view>
+      <text class="nav-title">关系详情</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text>加载中...</text>
+    </view>
+
+    <!-- 错误状态 -->
+    <view class="error-state" v-if="error && !loading">
+      <text>{{ error }}</text>
+      <button class="retry-btn" @click="loadData">重试</button>
+    </view>
+
+    <!-- 详情内容 -->
+    <view class="detail-content" v-if="relation && !loading">
+      <!-- 关系评分卡片 -->
+      <view class="score-card">
+        <view class="score-circle">
+          <canvas id="scoreCanvas" class="score-canvas"></canvas>
+          <view class="score-content">
+            <text class="score-value" :class="getScoreClass(relation.score)">{{ relation.score }}</text>
+            <text class="score-label">关系质量分</text>
+          </view>
+        </view>
+        <view class="score-level">
+          <text class="level-text">{{ getScoreLevel(relation.score) }}</text>
+        </view>
+      </view>
+
+      <!-- 双方信息 -->
+      <view class="members-card">
+        <view class="card-header">
+          <text class="card-title">关系双方</text>
+        </view>
+        <view class="members-row">
+          <view class="member-item">
+            <view class="member-avatar" :style="{ backgroundColor: member1Color }">
+              <text class="member-avatar-text">{{ relation.fromMemberName ? relation.fromMemberName.charAt(0) : 'A' }}</text>
+            </view>
+            <text class="member-name">{{ relation.fromMemberName }}</text>
+            <text class="member-role">{{ getRoleText(relation.fromMemberRole) }}</text>
+          </view>
+          <view class="relation-icon">
+            <text>↔</text>
+          </view>
+          <view class="member-item">
+            <view class="member-avatar" :style="{ backgroundColor: member2Color }">
+              <text class="member-avatar-text">{{ relation.toMemberName ? relation.toMemberName.charAt(0) : 'B' }}</text>
+            </view>
+            <text class="member-name">{{ relation.toMemberName }}</text>
+            <text class="member-role">{{ getRoleText(relation.toMemberRole) }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 维度分解 -->
+      <view class="dimensions-card">
+        <view class="card-header">
+          <text class="card-title">维度分解</text>
+        </view>
+        <view class="dimension-list">
+          <view class="dimension-item" v-for="dim in dimensions" :key="dim.type">
+            <view class="dim-info">
+              <text class="dim-label">{{ dim.label }}</text>
+              <text class="dim-desc">{{ dim.description }}</text>
+            </view>
+            <view class="dim-bar">
+              <view class="dim-fill" :style="{ width: dim.value + '%', backgroundColor: dim.color }"></view>
+            </view>
+            <text class="dim-value">{{ dim.value }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 关系趋势 -->
+      <view class="trend-card" v-if="relation.trend && relation.trend.length > 0">
+        <view class="card-header">
+          <text class="card-title">关系趋势</text>
+        </view>
+        <view class="trend-chart">
+          <canvas id="trendCanvas" class="trend-canvas"></canvas>
+        </view>
+      </view>
+
+      <!-- 改进建议 -->
+      <view class="advice-card" v-if="relation.advice">
+        <view class="card-header">
+          <text class="card-title">改进建议</text>
+        </view>
+        <view class="advice-content">
+          <text class="advice-text">{{ relation.advice }}</text>
+        </view>
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="action-buttons">
+        <button class="action-btn primary" @click="startQuestionnaire">填写关系问卷</button>
+        <button class="action-btn secondary" @click="viewInteractionLog">查看互动记录</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { mapActions } from 'vuex'
+
+export default {
+  data: function() {
+    return {
+      memberId: '',
+      relation: null,
+      loading: false,
+      error: null,
+      scoreCanvasContext: null,
+      trendCanvasContext: null
+    }
+  },
+  computed: {
+    member1Color: function() {
+      if (!this.relation || !this.relation.fromMemberRole) {
+        return '#6B7280'
+      }
+      var colorMap = {
+        'parent': '#F97316',
+        'child': '#3B82F6',
+        'teacher': '#8B5CF6'
+      }
+      return colorMap[this.relation.fromMemberRole] || '#6B7280'
+    },
+    member2Color: function() {
+      if (!this.relation || !this.relation.toMemberRole) {
+        return '#6B7280'
+      }
+      var colorMap = {
+        'parent': '#F97316',
+        'child': '#3B82F6',
+        'teacher': '#8B5CF6'
+      }
+      return colorMap[this.relation.toMemberRole] || '#6B7280'
+    },
+    dimensions: function() {
+      if (!this.relation || !this.relation.dimensions) {
+        return []
+      }
+      var dimMap = {
+        'communication': { label: '沟通', description: '沟通频率与质量', color: '#10B981' },
+        'trust': { label: '信任', description: '相互信任程度', color: '#3B82F6' },
+        'understanding': { label: '理解', description: '相互理解程度', color: '#8B5CF6' },
+        'support': { label: '支持', description: '相互支持力度', color: '#F59E0B' },
+        'harmony': { label: '和谐', description: '关系和谐度', color: '#F97316' }
+      }
+      var result = []
+      for (var key in this.relation.dimensions) {
+        var value = this.relation.dimensions[key]
+        var info = dimMap[key] || { label: key, description: '', color: '#6B7280' }
+        result.push({
+          type: key,
+          label: info.label,
+          description: info.description,
+          value: value,
+          color: info.color
+        })
+      }
+      return result
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.memberId) {
+      this.memberId = options.memberId
+      this.loadData()
+    }
+  },
+  onReady: function() {
+    if (!this.loading && this.relation) {
+      this.drawScoreCanvas()
+      this.drawTrendCanvas()
+    }
+  },
+  methods: {
+    ...mapActions('tianpan', ['loadCompatibility']),
+
+    loadData: function() {
+      this.loading = true
+      this.error = null
+      var familyId = uni.getStorageSync('familyId')
+      if (!familyId || !this.memberId) {
+        this.error = '参数错误'
+        this.loading = false
+        return
+      }
+      this.loadCompatibility({ familyId: familyId, memberId: this.memberId })
+        .then(this.setRelationData)
+        .catch(this.handleError)
+        .finally(function() {
+          this.loading = false
+        }.bind(this))
+    },
+
+    setRelationData: function(data) {
+      if (data) {
+        this.relation = data
+        this.$nextTick(function() {
+          this.drawScoreCanvas()
+          this.drawTrendCanvas()
+        }.bind(this))
+      }
+    },
+
+    handleError: function(e) {
+      this.error = e.message || '加载失败'
+    },
+
+    drawScoreCanvas: function() {
+      var query = uni.createSelectorQuery()
+      query.select('#scoreCanvas')
+        .fields({ node: true, size: true })
+        .exec(this.onScoreCanvasReady)
+    },
+
+    onScoreCanvasReady: function(res) {
+      if (!res || !res[0] || !res[0].node) {
+        return
+      }
+      var ctx = res[0].node.getContext('2d')
+      var dpr = uni.getSystemInfoSync().pixelRatio
+      this.scoreCanvasContext = ctx
+      this.scoreCanvasContext.scale(dpr, dpr)
+      
+      var w = res[0].width / dpr
+      var h = res[0].height / dpr
+      var cx = w / 2
+      var cy = h / 2
+      var r = Math.min(w, h) / 2 - 10
+      
+      // 清空画布
+      ctx.clearRect(0, 0, w, h)
+      
+      // 绘制背景圆
+      ctx.beginPath()
+      ctx.arc(cx, cy, r, 0, Math.PI * 2)
+      ctx.strokeStyle = '#E5E7EB'
+      ctx.lineWidth = 20
+      ctx.stroke()
+      
+      // 绘制进度圆
+      var score = this.relation && this.relation.score ? this.relation.score : 0
+      var startAngle = -Math.PI / 2
+      var endAngle = startAngle + (score / 100) * Math.PI * 2
+      
+      ctx.beginPath()
+      ctx.arc(cx, cy, r, startAngle, endAngle)
+      ctx.strokeStyle = this.getScoreColor(score)
+      ctx.lineWidth = 20
+      ctx.lineCap = 'round'
+      ctx.stroke()
+    },
+
+    drawTrendCanvas: function() {
+      if (!this.relation || !this.relation.trend || this.relation.trend.length === 0) {
+        return
+      }
+      var query = uni.createSelectorQuery()
+      query.select('#trendCanvas')
+        .fields({ node: true, size: true })
+        .exec(this.onTrendCanvasReady)
+    },
+
+    onTrendCanvasReady: function(res) {
+      if (!res || !res[0] || !res[0].node) {
+        return
+      }
+      var ctx = res[0].node.getContext('2d')
+      var dpr = uni.getSystemInfoSync().pixelRatio
+      this.trendCanvasContext = ctx
+      this.trendCanvasContext.scale(dpr, dpr)
+      
+      var w = res[0].width / dpr
+      var h = res[0].height / dpr
+      
+      // 清空画布
+      ctx.clearRect(0, 0, w, h)
+      
+      var trend = this.relation.trend
+      var padding = 20
+      var chartW = w - padding * 2
+      var chartH = h - padding * 2
+      
+      // 绘制坐标轴
+      ctx.beginPath()
+      ctx.moveTo(padding, padding)
+      ctx.lineTo(padding, h - padding)
+      ctx.lineTo(w - padding, h - padding)
+      ctx.strokeStyle = '#E5E7EB'
+      ctx.lineWidth = 1
+      ctx.stroke()
+      
+      // 绘制折线
+      if (trend.length < 2) {
+        return
+      }
+      
+      var stepX = chartW / (trend.length - 1)
+      var maxScore = 100
+      
+      ctx.beginPath()
+      for (var i = 0; i < trend.length; i++) {
+        var point = trend[i]
+        var x = padding + i * stepX
+        var y = h - padding - (point.score / maxScore) * chartH
+        
+        if (i === 0) {
+          ctx.moveTo(x, y)
+        } else {
+          ctx.lineTo(x, y)
+        }
+      }
+      ctx.strokeStyle = '#F97316'
+      ctx.lineWidth = 2
+      ctx.stroke()
+      
+      // 绘制数据点
+      for (var j = 0; j < trend.length; j++) {
+        var point2 = trend[j]
+        var x2 = padding + j * stepX
+        var y2 = h - padding - (point2.score / maxScore) * chartH
+        
+        ctx.beginPath()
+        ctx.arc(x2, y2, 4, 0, Math.PI * 2)
+        ctx.fillStyle = '#F97316'
+        ctx.fill()
+      }
+    },
+
+    getScoreColor: function(score) {
+      if (!score) {
+        return '#6B7280'
+      }
+      if (score >= 80) {
+        return '#10B981'
+      } else if (score >= 60) {
+        return '#F59E0B'
+      } else {
+        return '#EF4444'
+      }
+    },
+
+    getScoreClass: function(score) {
+      if (!score) {
+        return ''
+      }
+      if (score >= 80) {
+        return 'score-good'
+      } else if (score >= 60) {
+        return 'score-medium'
+      } else {
+        return 'score-low'
+      }
+    },
+
+    getScoreLevel: function(score) {
+      if (!score) {
+        return '未知'
+      }
+      if (score >= 90) {
+        return '卓越'
+      } else if (score >= 80) {
+        return '优秀'
+      } else if (score >= 70) {
+        return '良好'
+      } else if (score >= 60) {
+        return '合格'
+      } else {
+        return '待改善'
+      }
+    },
+
+    getRoleText: function(role) {
+      var roleMap = {
+        'parent': '家长',
+        'child': '孩子',
+        'teacher': '规划师'
+      }
+      return roleMap[role] || role
+    },
+
+    goBack: function() {
+      uni.navigateBack()
+    },
+
+    startQuestionnaire: function() {
+      uni.navigateTo({
+        url: '/pages/family/questionnaire?memberId=' + this.memberId
+      })
+    },
+
+    viewInteractionLog: function() {
+      uni.navigateTo({
+        url: '/pages/family/interaction-log?memberId=' + this.memberId
+      })
+    }
+  }
+}
+</script>
+
+<style>
+.relation-detail-container {
+  min-height: 100vh;
+  background: #F9FAFB;
+  padding-bottom: 20px;
+}
+
+.custom-nav {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  padding: 44px 16px 16px;
+  display: flex;
+  align-items: center;
+}
+
+.nav-back {
+  color: #FFFFFF;
+  font-size: 14px;
+  margin-right: 16px;
+}
+
+.nav-title {
+  color: #FFFFFF;
+  font-size: 18px;
+  font-weight: bold;
+}
+
+.loading-state, .error-state {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 80px 20px;
+  text-align: center;
+}
+
+.loading-spinner {
+  width: 40px;
+  height: 40px;
+  border: 4px solid #E5E7EB;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-bottom: 16px;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.retry-btn {
+  margin-top: 16px;
+  background: #F97316;
+  color: #FFFFFF;
+  border: none;
+  padding: 10px 32px;
+  border-radius: 20px;
+  font-size: 14px;
+}
+
+.detail-content {
+  padding: 16px;
+}
+
+.score-card {
+  background: #FFFFFF;
+  border-radius: 16px;
+  padding: 32px 20px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+  text-align: center;
+}
+
+.score-circle {
+  position: relative;
+  width: 180px;
+  height: 180px;
+  margin: 0 auto 16px;
+}
+
+.score-canvas {
+  width: 180px;
+  height: 180px;
+}
+
+.score-content {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+}
+
+.score-value {
+  display: block;
+  font-size: 48px;
+  font-weight: bold;
+  line-height: 1;
+}
+
+.score-label {
+  display: block;
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-top: 8px;
+}
+
+.score-level {
+  margin-top: 12px;
+}
+
+.level-text {
+  font-size: 18px;
+  font-weight: bold;
+  color: #1F2937;
+}
+
+.score-good {
+  color: #10B981;
+}
+
+.score-medium {
+  color: #F59E0B;
+}
+
+.score-low {
+  color: #EF4444;
+}
+
+.members-card, .dimensions-card, .trend-card, .advice-card {
+  background: #FFFFFF;
+  border-radius: 16px;
+  padding: 20px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.card-header {
+  margin-bottom: 16px;
+}
+
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #1F2937;
+}
+
+.members-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-around;
+}
+
+.member-item {
+  text-align: center;
+  flex: 1;
+}
+
+.member-avatar {
+  width: 56px;
+  height: 56px;
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin: 0 auto 8px;
+}
+
+.member-avatar-text {
+  color: #FFFFFF;
+  font-size: 24px;
+  font-weight: bold;
+}
+
+.member-name {
+  display: block;
+  font-size: 14px;
+  font-weight: 500;
+  color: #1F2937;
+}
+
+.member-role {
+  display: block;
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-top: 2px;
+}
+
+.relation-icon {
+  font-size: 24px;
+  color: #D1D5DB;
+  padding: 0 8px;
+}
+
+.dimension-list {
+  margin-top: 16px;
+}
+
+.dimension-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.dim-info {
+  width: 80px;
+}
+
+.dim-label {
+  display: block;
+  font-size: 14px;
+  font-weight: 500;
+  color: #1F2937;
+}
+
+.dim-desc {
+  display: block;
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-top: 2px;
+}
+
+.dim-bar {
+  flex: 1;
+  height: 8px;
+  background: #E5E7EB;
+  border-radius: 4px;
+  margin: 0 12px;
+  overflow: hidden;
+}
+
+.dim-fill {
+  height: 100%;
+  border-radius: 4px;
+  transition: width 0.3s ease;
+}
+
+.dim-value {
+  width: 32px;
+  text-align: right;
+  font-size: 14px;
+  font-weight: bold;
+  color: #1F2937;
+}
+
+.trend-chart {
+  height: 150px;
+}
+
+.trend-canvas {
+  width: 100%;
+  height: 100%;
+}
+
+.advice-content {
+  padding: 12px;
+  background: #FFF8F0;
+  border-radius: 8px;
+}
+
+.advice-text {
+  font-size: 14px;
+  line-height: 1.6;
+  color: #7C2D12;
+}
+
+.action-buttons {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 16px;
+}
+
+.action-btn {
+  padding: 14px 24px;
+  border-radius: 12px;
+  font-size: 16px;
+  border: none;
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  color: #FFFFFF;
+}
+
+.action-btn.secondary {
+  background: #FFFFFF;
+  color: #F97316;
+  border: 2px solid #F97316;
+}
+</style>

+ 169 - 165
cfc-frontend/store/index.js

@@ -1,166 +1,170 @@
-import Vue from 'vue'
-import Vuex from 'vuex'
-
-Vue.use(Vuex)
-
-const store = new Vuex.Store({
-  state: {
-    token: '',
-    userId: '',
-    role: 'parent', // 用户的实际角色(数据库中的角色)
-    currentRole: 'parent', // 当前视图模式(用于切换查看家长/孩子界面)
-    familyId: '',
-    nickname: '',
-    currentChildId: '',
-    currentMemberId: '', // 当前切换到的家庭成员ID(family_members表)
-    currentMemberRole: '', // 当前切换到的家庭成员effectiveRole
-    children: [],
-    isSwitchedChild: false, // 家长切换到孩子状态的标记(已废弃,使用 currentView)
-    isSwitchedMember: false, // 通过family_members切换的标记(已废弃,使用 currentView)
-    currentView: 'self' // 当前视角: 'self' | 'member'
-  },
-  mutations: {
-    setToken(state, token) {
-      state.token = token
-      uni.setStorageSync('token', token)
-    },
-    setUserInfo(state, userInfo) {
-      state.userId = userInfo.userId
-      state.role = userInfo.role
-      state.currentRole = userInfo.role
-      state.familyId = userInfo.familyId
-      state.nickname = userInfo.nickname
-      uni.setStorageSync('userId', userInfo.userId)
-      uni.setStorageSync('role', userInfo.role)
-      uni.setStorageSync('currentRole', userInfo.role)
-      uni.setStorageSync('familyId', userInfo.familyId)
-      state.isSwitchedChild = false
-      state.currentView = 'self'
-      uni.setStorageSync('isSwitchedChild', false)
-      uni.setStorageSync('currentView', 'self')
-    },
-    setChildren(state, children) {
-      state.children = children
-      if (children.length > 0 && !state.currentChildId) {
-        state.currentChildId = children[0].id
-      }
-    },
-    setCurrentChild(state, childId) {
-      state.currentChildId = childId
-      uni.setStorageSync('currentChildId', childId)
-    },
-    // 切换到孩子模式(家长切换)
-    switchToChild(state, childId) {
-      state.currentRole = 'child'
-      state.currentChildId = childId
-      state.isSwitchedChild = true
-      state.currentView = 'member'
-      uni.setStorageSync('currentRole', 'child')
-      uni.setStorageSync('currentChildId', childId)
-      uni.setStorageSync('isSwitchedChild', true)
-      uni.setStorageSync('currentView', 'member')
-    },
-    // 切换回家长模式
-    switchBackToParent(state) {
-      state.currentRole = 'parent'
-      state.currentChildId = ''
-      state.isSwitchedChild = false
-      state.currentView = 'self'
-      uni.setStorageSync('currentRole', 'parent')
-      uni.removeStorageSync('currentChildId')
-      uni.setStorageSync('isSwitchedChild', false)
-      uni.setStorageSync('currentView', 'self')
-    },
-    // 通过family_members切换到某个成员
-    switchToMember(state, payload) {
-      state.currentRole = payload.effectiveRole
-      state.currentMemberId = payload.memberId
-      state.currentMemberRole = payload.effectiveRole
-      state.isSwitchedMember = true
-      state.currentView = 'member'
-      if (payload.effectiveRole === 'child') {
-        state.isSwitchedChild = true
-        state.currentChildId = payload.memberId
-      }
-      uni.setStorageSync('currentRole', payload.effectiveRole)
-      uni.setStorageSync('currentMemberId', payload.memberId)
-      uni.setStorageSync('currentMemberRole', payload.effectiveRole)
-      uni.setStorageSync('isSwitchedMember', true)
-      uni.setStorageSync('currentView', 'member')
-      if (payload.effectiveRole === '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 = ''
-      state.currentView = 'self'
-      uni.setStorageSync('currentRole', originalRole)
-      uni.removeStorageSync('currentMemberId')
-      uni.removeStorageSync('currentMemberRole')
-      uni.setStorageSync('isSwitchedMember', false)
-      uni.removeStorageSync('currentChildId')
-      uni.setStorageSync('isSwitchedChild', false)
-      uni.setStorageSync('currentView', 'self')
-    },
-    // 切换视图模式(不修改数据库中的实际角色)
-    switchViewMode(state, role) {
-      state.currentRole = role
-      uni.setStorageSync('currentRole', role)
-    },
-    // 兼容旧的方法名
-    switchRole(state, role) {
-      state.currentRole = role
-      uni.setStorageSync('currentRole', role)
-    },
-    logout(state) {
-      state.token = ''
-      state.userId = ''
-      state.role = 'parent'
-      state.currentRole = 'parent'
-      state.familyId = ''
-      state.nickname = ''
-      state.currentChildId = ''
-      state.currentMemberId = ''
-      state.currentMemberRole = ''
-      state.children = []
-      state.isSwitchedChild = false
-      state.isSwitchedMember = false
-      state.isSwitchedTeacher = false
-      state.currentView = 'self'
-      uni.removeStorageSync('token')
-      uni.removeStorageSync('currentRole')
-      uni.removeStorageSync('isSwitchedChild')
-      uni.removeStorageSync('isSwitchedTeacher')
-      uni.removeStorageSync('userId')
-      uni.removeStorageSync('role')
-      uni.removeStorageSync('familyId')
-      uni.removeStorageSync('openid')
-      uni.removeStorageSync('userInfo')
-      uni.removeStorageSync('nickname')
-      uni.removeStorageSync('currentChildId')
-      uni.removeStorageSync('currentMemberId')
-      uni.removeStorageSync('currentMemberRole')
-      uni.removeStorageSync('isSwitchedMember')
+import Vue from 'vue'
+import Vuex from 'vuex'
+
+import tianpan from './modules/tianpan'
+
+Vue.use(Vuex)
+
+const store = new Vuex.Store({
+  state: {
+    token: '',
+    userId: '',
+    role: 'parent', // 用户的实际角色(数据库中的角色)
+    currentRole: 'parent', // 当前视图模式(用于切换查看家长/孩子界面)
+    familyId: '',
+    nickname: '',
+    currentChildId: '',
+    currentMemberId: '', // 当前切换到的家庭成员ID(family_members表)
+    currentMemberRole: '', // 当前切换到的家庭成员effectiveRole
+    children: [],
+    isSwitchedChild: false, // 家长切换到孩子状态的标记(已废弃,使用 currentView)
+    isSwitchedMember: false, // 通过family_members切换的标记(已废弃,使用 currentView)
+    currentView: 'self' // 当前视角: 'self' | 'member'
+  },
+  mutations: {
+    setToken(state, token) {
+      state.token = token
+      uni.setStorageSync('token', token)
+    },
+    setUserInfo(state, userInfo) {
+      state.userId = userInfo.userId
+      state.role = userInfo.role
+      state.currentRole = userInfo.role
+      state.familyId = userInfo.familyId
+      state.nickname = userInfo.nickname
+      uni.setStorageSync('userId', userInfo.userId)
+      uni.setStorageSync('role', userInfo.role)
+      uni.setStorageSync('currentRole', userInfo.role)
+      uni.setStorageSync('familyId', userInfo.familyId)
+      state.isSwitchedChild = false
+      state.currentView = 'self'
+      uni.setStorageSync('isSwitchedChild', false)
+      uni.setStorageSync('currentView', 'self')
+    },
+    setChildren(state, children) {
+      state.children = children
+      if (children.length > 0 && !state.currentChildId) {
+        state.currentChildId = children[0].id
+      }
+    },
+    setCurrentChild(state, childId) {
+      state.currentChildId = childId
+      uni.setStorageSync('currentChildId', childId)
+    },
+    // 切换到孩子模式(家长切换)
+    switchToChild(state, childId) {
+      state.currentRole = 'child'
+      state.currentChildId = childId
+      state.isSwitchedChild = true
+      state.currentView = 'member'
+      uni.setStorageSync('currentRole', 'child')
+      uni.setStorageSync('currentChildId', childId)
+      uni.setStorageSync('isSwitchedChild', true)
+      uni.setStorageSync('currentView', 'member')
+    },
+    // 切换回家长模式
+    switchBackToParent(state) {
+      state.currentRole = 'parent'
+      state.currentChildId = ''
+      state.isSwitchedChild = false
+      state.currentView = 'self'
+      uni.setStorageSync('currentRole', 'parent')
+      uni.removeStorageSync('currentChildId')
+      uni.setStorageSync('isSwitchedChild', false)
+      uni.setStorageSync('currentView', 'self')
+    },
+    // 通过family_members切换到某个成员
+    switchToMember(state, payload) {
+      state.currentRole = payload.effectiveRole
+      state.currentMemberId = payload.memberId
+      state.currentMemberRole = payload.effectiveRole
+      state.isSwitchedMember = true
+      state.currentView = 'member'
+      if (payload.effectiveRole === 'child') {
+        state.isSwitchedChild = true
+        state.currentChildId = payload.memberId
+      }
+      uni.setStorageSync('currentRole', payload.effectiveRole)
+      uni.setStorageSync('currentMemberId', payload.memberId)
+      uni.setStorageSync('currentMemberRole', payload.effectiveRole)
+      uni.setStorageSync('isSwitchedMember', true)
+      uni.setStorageSync('currentView', 'member')
+      if (payload.effectiveRole === '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 = ''
+      state.currentView = 'self'
+      uni.setStorageSync('currentRole', originalRole)
+      uni.removeStorageSync('currentMemberId')
+      uni.removeStorageSync('currentMemberRole')
+      uni.setStorageSync('isSwitchedMember', false)
+      uni.removeStorageSync('currentChildId')
+      uni.setStorageSync('isSwitchedChild', false)
+      uni.setStorageSync('currentView', 'self')
+    },
+    // 切换视图模式(不修改数据库中的实际角色)
+    switchViewMode(state, role) {
+      state.currentRole = role
+      uni.setStorageSync('currentRole', role)
+    },
+    // 兼容旧的方法名
+    switchRole(state, role) {
+      state.currentRole = role
+      uni.setStorageSync('currentRole', role)
+    },
+    logout(state) {
+      state.token = ''
+      state.userId = ''
+      state.role = 'parent'
+      state.currentRole = 'parent'
+      state.familyId = ''
+      state.nickname = ''
+      state.currentChildId = ''
+      state.currentMemberId = ''
+      state.currentMemberRole = ''
+      state.children = []
+      state.isSwitchedChild = false
+      state.isSwitchedMember = false
+      state.isSwitchedTeacher = false
+      state.currentView = 'self'
+      uni.removeStorageSync('token')
+      uni.removeStorageSync('currentRole')
+      uni.removeStorageSync('isSwitchedChild')
+      uni.removeStorageSync('isSwitchedTeacher')
+      uni.removeStorageSync('userId')
+      uni.removeStorageSync('role')
+      uni.removeStorageSync('familyId')
+      uni.removeStorageSync('openid')
+      uni.removeStorageSync('userInfo')
+      uni.removeStorageSync('nickname')
+      uni.removeStorageSync('currentChildId')
+      uni.removeStorageSync('currentMemberId')
+      uni.removeStorageSync('currentMemberRole')
+      uni.removeStorageSync('isSwitchedMember')
       uni.removeStorageSync('currentView')
-    }
-  },
-  actions: {
-    login({ commit }, userInfo) {
-      commit('setToken', userInfo.token)
-      commit('setUserInfo', userInfo)
-    },
-    updateChildren({ commit }, children) {
-      commit('setChildren', children)
-    }
-  }
-})
-
-export default store
+    }
+  },
+  actions: {
+    login({ commit }, userInfo) {
+      commit('setToken', userInfo.token)
+      commit('setUserInfo', userInfo)
+    },
+    updateChildren({ commit }, children) {
+      commit('setChildren', children)
+    }
+  }
+,
+  modules: { tianpan }
+})
+
+export default store

+ 115 - 0
cfc-frontend/store/modules/tianpan.js

@@ -0,0 +1,115 @@
+import { tianpanDashboard, tianpanMemberDetail, tianpanCompatibility, tianpanAnnualEnergy, tianpanDailyFortune, tianpanRelatedItems } from '@/utils/api.js'
+
+export default {
+  namespaced: true,
+  state: {
+    dashboard: null,
+    memberDetail: null,
+    compatibility: null,
+    annualEnergy: null,
+    dailyFortune: null,
+    relatedItems: null,
+    loading: false,
+    error: null,
+    currentYear: new Date().getFullYear(),
+    daysAhead: 7
+  },
+  mutations: {
+    SET_DASHBOARD(state, v) { state.dashboard = v },
+    SET_MEMBER_DETAIL(state, v) { state.memberDetail = v },
+    SET_COMPATIBILITY(state, v) { state.compatibility = v },
+    SET_ANNUAL_ENERGY(state, v) { state.annualEnergy = v },
+    SET_DAILY_FORTUNE(state, v) { state.dailyFortune = v },
+    SET_RELATED_ITEMS(state, v) { state.relatedItems = v },
+    SET_LOADING(state, v) { state.loading = v },
+    SET_ERROR(state, v) { state.error = v },
+    SET_YEAR(state, v) { state.currentYear = v }
+  },
+  actions: {
+    loadDashboard({ commit, state }) {
+      commit('SET_LOADING', true)
+      commit('SET_ERROR', null)
+      const familyId = uni.getStorageSync('familyId')
+      if (!familyId) {
+        commit('SET_LOADING', false)
+        return Promise.reject(new Error('no familyId'))
+      }
+      return tianpanDashboard({ familyId: familyId, year: state.currentYear, daysAhead: state.daysAhead })
+        .then(function(res) {
+          if (res && res.code === 0) {
+            commit('SET_DASHBOARD', res.data)
+          }
+        })
+        .catch(function(e) {
+          commit('SET_ERROR', e.message)
+        })
+        .finally(function() {
+          commit('SET_LOADING', false)
+        })
+    },
+    switchYear({ commit, dispatch }, year) {
+      commit('SET_YEAR', year)
+      return dispatch('loadDashboard')
+    },
+    loadMemberDetail({ commit }, payload) {
+      return tianpanMemberDetail(payload.memberId, { memberType: payload.memberType || 'family_member' })
+        .then(function(res) {
+          if (res && res.code === 0) {
+            commit('SET_MEMBER_DETAIL', res.data)
+            return res.data
+          }
+          return null
+        })
+    },
+    loadCompatibility({ commit }, payload) {
+      return tianpanCompatibility(payload).then(function(res) {
+        if (res && res.code === 0) {
+          commit('SET_COMPATIBILITY', res.data)
+          return res.data
+        }
+        return null
+      })
+    },
+    loadAnnualEnergy({ commit }, payload) {
+      return tianpanAnnualEnergy(payload).then(function(res) {
+        if (res && res.code === 0) {
+          commit('SET_ANNUAL_ENERGY', res.data)
+          return res.data
+        }
+        return null
+      })
+    },
+    loadDailyFortune({ commit }, payload) {
+      return tianpanDailyFortune(payload).then(function(res) {
+        if (res && res.code === 0) {
+          commit('SET_DAILY_FORTUNE', res.data)
+          return res.data
+        }
+        return null
+      })
+    },
+    loadRelatedItems({ commit }, payload) {
+      return tianpanRelatedItems(payload).then(function(res) {
+        if (res && res.code === 0) {
+          commit('SET_RELATED_ITEMS', res.data)
+          return res.data
+        }
+        return null
+      })
+    }
+  },
+  getters: {
+    members: function(state) {
+      return state.dashboard && state.dashboard.members ? state.dashboard.members : []
+    },
+    relationships: function(state) {
+      return state.dashboard && state.dashboard.relationships ? state.dashboard.relationships : []
+    },
+    familyWuxing: function(state) {
+      return state.dashboard && state.dashboard.familyWuxing ? state.dashboard.familyWuxing : null
+    },
+    familyName: function(state) {
+      return state.dashboard && state.dashboard.familyName ? state.dashboard.familyName : ''
+    }
+  }
+}

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

@@ -1683,3 +1683,28 @@ export const listFamilyPlans = (data) => request('/api/guide/plans/family-list',
 export const reviewPlan = (data) => request('/api/guide/plans/review', 'POST', data)
 export const activatePlan = (data) => request('/api/guide/plans/activate', 'POST', data)
 export const getGuidePlanDetail = (data) => request('/api/guide/plans/detail', 'POST', data)
+
+// ===== 家庭天盘相关 =====
+export const tianpanDashboard = (data) => {
+  return request('/api/tianpan/dashboard', 'POST', data)
+}
+
+export const tianpanMemberDetail = (memberId, data) => {
+  return request('/api/tianpan/member/' + memberId, 'POST', data)
+}
+
+export const tianpanCompatibility = (data) => {
+  return request('/api/tianpan/compatibility', 'POST', data)
+}
+
+export const tianpanAnnualEnergy = (data) => {
+  return request('/api/tianpan/annual-energy', 'POST', data)
+}
+
+export const tianpanDailyFortune = (data) => {
+  return request('/api/tianpan/daily-fortune', 'POST', data)
+}
+
+export const tianpanRelatedItems = (data) => {
+  return request('/api/tianpan/related-items', 'POST', data)
+}

部分文件因为文件数量过多而无法显示