Kaynağa Gözat

merge: resolve DatabaseInitializer conflict, add cart specDesc + order SKU price

Xiaogang Liao 2 ay önce
ebeveyn
işleme
a936eb8da0

+ 155 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java

@@ -3,11 +3,14 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.service.*;
+import com.etotem.cfc.service.DanReportParseService.DanParsedReport;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -30,6 +33,12 @@ public class DanAssessmentController {
     @Resource
     private com.etotem.cfc.mapper.DanAssessmentResultMapper danAssessmentResultMapper;
 
+    @Resource
+    private DanReportParseService danReportParseService;
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
     // ===== 测评材料 =====
 
     @Operation(summary = "获取当前测评材料")
@@ -270,4 +279,150 @@ public class DanAssessmentController {
         data.put("structuredAnalysis", result.getStructuredAnalysis());
         return Result.success(data);
     }
+
+    // ===== DAN 报告上传与解析 =====
+
+    @Operation(summary = "上传 DAN 报告并预览解析结果(Phase 1)")
+    @PostMapping("/report/parse-preview")
+    public Result<Map<String, Object>> parsePreview(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam("dimension") String dimension,
+            @RequestParam("memberId") Long memberId,
+            @RequestAttribute("userId") Long userId) {
+        if (file.isEmpty()) {
+            return Result.error("请选择文件");
+        }
+        if (!"mind".equals(dimension) && !"wisdom".equals(dimension)) {
+            return Result.error("dimension 必须为 mind 或 wisdom");
+        }
+        try {
+            // 1. 解析 PDF
+            DanParsedReport parsed = danReportParseService.parse(file.getInputStream(), dimension);
+
+            // 2. 创建草稿记录(draft_status = pending)
+            DanAssessmentResult draft = new DanAssessmentResult();
+            draft.setChildName(""); // 前端预览后再填
+            draft.setAssessmentDate(new java.util.Date());
+            draft.setStructuredAnalysis(parsedToJson(parsed));
+            draft.setDimension(dimension);
+            draft.setReportFileId(file.getOriginalFilename());
+            draft.setDraftStatus("pending");
+            draft.setCreatedAt(new java.util.Date());
+            draft.setUpdatedAt(new java.util.Date());
+            danAssessmentResultMapper.insert(draft);
+
+            // 3. 返回预览数据
+            Map<String, Object> data = new java.util.LinkedHashMap<>();
+            data.put("draftId", draft.getId());
+            data.put("dimension", dimension);
+            data.put("summary", parsed.getSummary() != null ? parsed.getSummary()
+                    : (parsed.getItems() != null ? parsed.getItems().size() + " 项数据" : ""));
+            data.put("items", parsed.getItems() != null
+                    ? parsed.getItems().stream().map(item -> {
+                        Map<String, Object> m = new java.util.LinkedHashMap<>();
+                        m.put("code", item.getCode());
+                        m.put("name", item.getName());
+                        m.put("value", item.getValue());
+                        m.put("category", item.getCategory());
+                        return m;
+                    }).collect(java.util.stream.Collectors.toList())
+                    : java.util.Collections.emptyList());
+            data.put("suggestions", parsed.getSuggestions());
+            data.put("message", "解析完成,请确认数据准确性");
+            return Result.success(data);
+        } catch (Exception e) {
+            return Result.error("解析失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "确认并保存 DAN 报告(Phase 2)")
+    @PostMapping("/report/confirm")
+    public Result<String> confirmReport(@RequestBody Map<String, Object> body) {
+        Long draftId = body.get("draftId") != null ? Long.valueOf(body.get("draftId").toString()) : null;
+        String childName = (String) body.get("childName");
+        String assessmentDateStr = (String) body.get("assessmentDate");
+        String dimension = (String) body.get("dimension");
+        String structuredAnalysis = (String) body.get("structuredAnalysis");
+        Long memberId = body.get("memberId") != null ? Long.valueOf(body.get("memberId").toString()) : null;
+
+        if (draftId == null || memberId == null) {
+            return Result.error("draftId 和 memberId 不能为空");
+        }
+
+        DanAssessmentResult draft = danAssessmentResultMapper.selectById(draftId);
+        if (draft == null) {
+            return Result.error("草稿不存在");
+        }
+
+        // 更新草稿信息
+        if (childName != null && !childName.isEmpty()) {
+            draft.setChildName(childName);
+        }
+        if (assessmentDateStr != null && !assessmentDateStr.isEmpty()) {
+            try {
+                draft.setAssessmentDate(new java.text.SimpleDateFormat("yyyy-MM-dd").parse(assessmentDateStr));
+            } catch (Exception ignored) {}
+        }
+        if (structuredAnalysis != null && !structuredAnalysis.isEmpty()) {
+            draft.setStructuredAnalysis(structuredAnalysis);
+        }
+        if (dimension != null) {
+            draft.setDimension(dimension);
+        }
+        draft.setDraftStatus("confirmed");
+        draft.setUpdatedAt(new java.util.Date());
+        danAssessmentResultMapper.updateById(draft);
+
+        // 更新维度分数
+        dimensionScoreService.refreshFromDanAssessment(memberId, draftId,
+                dimension != null ? dimension : draft.getDimension());
+
+        return Result.success("报告已确认并保存");
+    }
+
+    @Operation(summary = "获取某孩子某维度的 DAN 报告列表")
+    @PostMapping("/report/child")
+    public Result<List<DanAssessmentResult>> getChildDanReports(@RequestBody Map<String, Object> body) {
+        Long memberId = body.get("memberId") != null ? Long.valueOf(body.get("memberId").toString()) : null;
+        String dimension = (String) body.get("dimension");
+        if (memberId == null || dimension == null) {
+            return Result.error("memberId 和 dimension 不能为空");
+        }
+        List<DanAssessmentResult> list = danAssessmentResultMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DanAssessmentResult>()
+                        .eq(DanAssessmentResult::getDimension, dimension)
+                        .eq(DanAssessmentResult::getDraftStatus, "confirmed")
+                        .orderByDesc(DanAssessmentResult::getCreatedAt));
+        return Result.success(list);
+    }
+
+    /**
+     * 将 DanParsedReport 序列化为 JSON 字符串
+     */
+    private String parsedToJson(DanParsedReport parsed) {
+        try {
+            return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(parsed);
+        } catch (Exception e) {
+            // fallback: 手动构建简单 JSON
+            StringBuilder sb = new StringBuilder();
+            sb.append("{\"reportType\":\"").append(parsed.getReportType()).append("\",");
+            sb.append("\"dimension\":\"").append(parsed.getDimension()).append("\",");
+            sb.append("\"items\":[");
+            if (parsed.getItems() != null) {
+                boolean first = true;
+                for (DanReportParseService.DataItem item : parsed.getItems()) {
+                    if (!first) sb.append(",");
+                    sb.append("{\"code\":\"").append(item.getCode()).append("\",");
+                    sb.append("\"name\":\"").append(item.getName()).append("\",");
+                    sb.append("\"value\":\"").append(item.getValue()).append("\",");
+                    sb.append("\"category\":\"").append(item.getCategory()).append("\"}");
+                    first = false;
+                }
+            }
+            sb.append("],");
+            sb.append("\"summary\":\"").append(parsed.getSummary() != null ? parsed.getSummary().replace("\"", "\\\"") : "").append("\",");
+            sb.append("\"suggestions\":\"").append(parsed.getSuggestions() != null ? parsed.getSuggestions().replace("\"", "\\\"") : "").append("\"}");
+            return sb.toString();
+        }
+    }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -12,6 +12,8 @@ public class ActivityDTO {
     private String description;
     private String coverImage;
     private String dimensionCode;
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
     private String activityType;
     private String status;
     private String startTime;
@@ -38,6 +40,7 @@ public class ActivityDTO {
         dto.setDescription(activity.getDescription());
         dto.setCoverImage(activity.getCoverImage());
         dto.setDimensionCode(activity.getDimensionCode());
+        dto.setDimensionWeights(activity.getDimensionWeights());
         dto.setActivityType(activity.getActivityType());
         dto.setStatus(activity.getStatus());
         dto.setStartTime(activity.getStartTime() != null ? activity.getStartTime().toString() : null);

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -24,6 +24,9 @@ public class Activity implements Serializable {
     /** body/mind/wisdom/action/wealth */
     private String dimensionCode;
 
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
+
     /** offline/online/campaign */
     private String activityType;
 

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DanAssessmentResult.java

@@ -127,4 +127,9 @@ public class DanAssessmentResult implements Serializable {
     private String resultDesc; // 结果描述(简评)
 
     private String structuredAnalysis; // 结构化分析JSON(summary/dimensions/strengths/concerns/suggestions)
+
+    // === DAN 报告上传字段 ===
+    private String dimension; // mind(心-A2)/wisdom(智-B4)
+    private String reportFileId; // 上传文件媒资ID
+    private String draftStatus; // pending(待确认)/confirmed(已确认)
 }

+ 589 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportParseService.java

@@ -0,0 +1,589 @@
+package com.etotem.cfc.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * DAN 测评报告 PDF 解析服务
+ * 支持:
+ * - A2报告(心/mind):大五人格、社会关系、情绪状态、综合能力
+ * - B4报告(智/wisdom):自我概念、成长型思维、自驱力
+ *
+ * 解析结果以结构化数据项列表返回,灵活存储于 structured_analysis JSON 字段。
+ */
+@Slf4j
+@Service
+public class DanReportParseService {
+
+    /**
+     * 解析 PDF 输入流,返回结构化数据
+     */
+    public DanParsedReport parse(InputStream inputStream, String dimension) throws IOException {
+        try (PDDocument document = PDDocument.load(inputStream)) {
+            PDFTextStripper stripper = new PDFTextStripper();
+            stripper.setSortByPosition(true);
+            String fullText = stripper.getText(document);
+            return parseText(fullText, dimension);
+        }
+    }
+
+    /**
+     * 从纯文本解析 DAN 报告
+     */
+    public DanParsedReport parseText(String fullText, String dimension) {
+        String[] lines = fullText.split("\\r?\\n");
+        List<String> lineList = new ArrayList<>();
+        for (String line : lines) {
+            String trimmed = line.trim();
+            if (!trimmed.isEmpty()) {
+                // 去除PDF提取中的特殊Unicode字符
+                trimmed = trimmed.replaceAll("[\\uF000-\\uFFFF]", "").trim();
+                if (!trimmed.isEmpty()) {
+                    lineList.add(trimmed);
+                }
+            }
+        }
+
+        if ("mind".equals(dimension)) {
+            return parseA2Report(lineList, fullText);
+        } else if ("wisdom".equals(dimension)) {
+            return parseB4Report(lineList, fullText);
+        }
+        return DanParsedReport.empty();
+    }
+
+    // ======================== A2 报告解析(心/mind) ========================
+
+    private DanParsedReport parseA2Report(List<String> lines, String fullText) {
+        List<DataItem> items = new ArrayList<>();
+        Map<String, Object> extra = new LinkedHashMap<>();
+        StringBuilder summary = new StringBuilder();
+        StringBuilder suggestions = new StringBuilder();
+
+        // 1. 基本信息和日期
+        String reportDate = findFieldValue(lines, "测评日期", "报告日期", "评估日期");
+        String name = findFieldValue(lines, "姓名", "学生姓名", "被评估人");
+        String gender = findFieldValue(lines, "性别");
+        String age = findFieldValue(lines, "年龄");
+
+        extra.put("name", name);
+        extra.put("gender", gender);
+        extra.put("age", age);
+        extra.put("reportDate", reportDate);
+
+        // 2. 大五人格 (Big Five)
+        // 查找大五人格区域
+        int bigFiveStart = findSectionStart(lines, "大五人格", "人格特质", "人格分析");
+        if (bigFiveStart >= 0) {
+            Map<String, String> bigFive = parseBigFive(lines, bigFiveStart);
+            for (Map.Entry<String, String> entry : bigFive.entrySet()) {
+                items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "bigFive"));
+            }
+            extra.put("bigFive", bigFive);
+        } else {
+            // 全局搜索大五维度
+            Map<String, String> bigFive = searchBigFiveGlobally(lines);
+            if (!bigFive.isEmpty()) {
+                for (Map.Entry<String, String> entry : bigFive.entrySet()) {
+                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "bigFive"));
+                }
+                extra.put("bigFive", bigFive);
+            }
+        }
+
+        // 3. 社会关系(与父母/同伴)
+        int socialStart = findSectionStart(lines, "社会关系", "人际关系", "家庭关系", "同伴关系");
+        if (socialStart >= 0) {
+            Map<String, String> social = parseSocialRelations(lines, socialStart);
+            if (!social.isEmpty()) {
+                for (Map.Entry<String, String> entry : social.entrySet()) {
+                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "social"));
+                }
+                extra.put("social", social);
+            }
+        }
+
+        // 4. 情绪状态
+        int emotionStart = findSectionStart(lines, "情绪状态", "情绪", "情感");
+        if (emotionStart >= 0) {
+            Map<String, String> emotion = parseEmotionState(lines, emotionStart);
+            if (!emotion.isEmpty()) {
+                for (Map.Entry<String, String> entry : emotion.entrySet()) {
+                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "emotion"));
+                }
+                extra.put("emotion", emotion);
+            }
+        }
+
+        // 5. 综合能力
+        int abilityStart = findSectionStart(lines, "综合能力", "综合评估", "综合");
+        if (abilityStart >= 0) {
+            Map<String, String> ability = parseComprehensiveAbility(lines, abilityStart);
+            if (!ability.isEmpty()) {
+                for (Map.Entry<String, String> entry : ability.entrySet()) {
+                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "ability"));
+                }
+                extra.put("ability", ability);
+            }
+        }
+
+        // 6. 通用正则:提取所有 "维度名: 分数" 模式
+        Pattern scorePattern = Pattern.compile("([\\u4e00-\\u9fa5]{2,8})[::]\\s*(\\d+(\\.\\d+)?)");
+        Matcher matcher = scorePattern.matcher(fullText);
+        while (matcher.find()) {
+            String key = matcher.group(1).trim();
+            String val = matcher.group(2).trim();
+            // 去重:避免与已解析项重复
+            boolean exists = false;
+            for (DataItem item : items) {
+                if (item.getName().equals(key)) {
+                    exists = true;
+                    break;
+                }
+            }
+            if (!exists && !key.contains("日期") && !key.contains("姓名")) {
+                items.add(new DataItem("score_" + key.hashCode(), key, val, "auto"));
+            }
+        }
+
+        // 总结和建议
+        summary.append(extractSection(fullText, "测评总结", "成长建议"));
+        suggestions.append(extractSection(fullText, "成长建议", null));
+
+        return new DanParsedReport("A2", "mind", items, extra, summary.toString(), suggestions.toString());
+    }
+
+    // ======================== B4 报告解析(智/wisdom) ========================
+
+    private DanParsedReport parseB4Report(List<String> lines, String fullText) {
+        List<DataItem> items = new ArrayList<>();
+        Map<String, Object> extra = new LinkedHashMap<>();
+        StringBuilder summary = new StringBuilder();
+        StringBuilder suggestions = new StringBuilder();
+
+        // 1. 基本信息和日期
+        String reportDate = findFieldValue(lines, "测评日期", "报告日期", "评估日期");
+        String name = findFieldValue(lines, "姓名", "学生姓名", "被评估人");
+        extra.put("name", name);
+        extra.put("reportDate", reportDate);
+
+        // 2. 自我概念 (Self-Concept)
+        int selfConceptStart = findSectionStart(lines, "自我概念", "自我认知", "自我意识");
+        if (selfConceptStart >= 0) {
+            Map<String, String> selfConcept = parseSelfConcept(lines, selfConceptStart);
+            if (!selfConcept.isEmpty()) {
+                for (Map.Entry<String, String> entry : selfConcept.entrySet()) {
+                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "selfConcept"));
+                }
+                extra.put("selfConcept", selfConcept);
+            }
+        }
+
+        // 3. 成长型思维 (Growth Mindset)
+        int mindsetStart = findSectionStart(lines, "成长思维", "成长型思维", "思维模式");
+        if (mindsetStart >= 0) {
+            Map<String, String> mindset = parseGrowthMindset(lines, mindsetStart);
+            if (!mindset.isEmpty()) {
+                for (Map.Entry<String, String> entry : mindset.entrySet()) {
+                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "growthMindset"));
+                }
+                extra.put("growthMindset", mindset);
+            }
+        }
+
+        // 4. 自驱力 (Self-Driving)
+        int drivingStart = findSectionStart(lines, "自驱力", "自主性", "内驱力", "自我驱动");
+        if (drivingStart >= 0) {
+            Map<String, String> selfDriving = parseSelfDriving(lines, drivingStart);
+            if (!selfDriving.isEmpty()) {
+                for (Map.Entry<String, String> entry : selfDriving.entrySet()) {
+                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "selfDriving"));
+                }
+                extra.put("selfDriving", selfDriving);
+            }
+        }
+
+        // 5. 通用正则提取
+        Pattern scorePattern = Pattern.compile("([\\u4e00-\\u9fa5]{2,8})[::]\\s*(\\d+(\\.\\d+)?)");
+        Matcher matcher = scorePattern.matcher(fullText);
+        while (matcher.find()) {
+            String key = matcher.group(1).trim();
+            String val = matcher.group(2).trim();
+            boolean exists = false;
+            for (DataItem item : items) {
+                if (item.getName().equals(key)) {
+                    exists = true;
+                    break;
+                }
+            }
+            if (!exists && !key.contains("日期") && !key.contains("姓名")) {
+                items.add(new DataItem("score_" + key.hashCode(), key, val, "auto"));
+            }
+        }
+
+        summary.append(extractSection(fullText, "测评总结", "成长建议"));
+        suggestions.append(extractSection(fullText, "成长建议", null));
+
+        return new DanParsedReport("B4", "wisdom", items, extra, summary.toString(), suggestions.toString());
+    }
+
+    // ======================== 解析辅助方法 ========================
+
+    /**
+     * 在大五人格区域内解析维度:开放/尽责/外倾/宜人/神经质
+     */
+    private Map<String, String> parseBigFive(List<String> lines, int startIdx) {
+        Map<String, String> result = new LinkedHashMap<>();
+        String[] labels = {"开放性", "尽责性", "外倾性", "宜人性", "神经质"};
+        Set<String> labelSet = new HashSet<>(Arrays.asList(labels));
+
+        for (int i = startIdx; i < Math.min(startIdx + 30, lines.size()); i++) {
+            String line = lines.get(i);
+            for (String label : labels) {
+                if (line.contains(label)) {
+                    String score = extractScore(line);
+                    if (score != null) {
+                        result.put(label, score);
+                    }
+                    break;
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 全局搜索大五维度(当找不到明确的大五区域时)
+     */
+    private Map<String, String> searchBigFiveGlobally(List<String> lines) {
+        Map<String, String> result = new LinkedHashMap<>();
+        String[] labels = {"开放性", "尽责性", "外倾性", "宜人性", "神经质",
+                "开放", "尽责", "外倾", "宜人", "神经"};
+        for (String line : lines) {
+            for (String label : labels) {
+                if (line.contains(label)) {
+                    String score = extractScore(line);
+                    if (score != null) {
+                        // 使用更精确的维度名
+                        String displayName = label;
+                        if (label.equals("开放")) displayName = "开放性";
+                        else if (label.equals("尽责")) displayName = "尽责性";
+                        else if (label.equals("外倾")) displayName = "外倾性";
+                        else if (label.equals("宜人")) displayName = "宜人性";
+                        else if (label.equals("神经")) displayName = "神经质";
+                        result.put(displayName, score);
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析社会关系:与父母信任、沟通、亲近等
+     */
+    private Map<String, String> parseSocialRelations(List<String> lines, int startIdx) {
+        Map<String, String> result = new LinkedHashMap<>();
+        String[] keywords = {"母亲", "父亲", "同伴", "信任", "沟通", "亲近", "疏远"};
+        for (int i = startIdx; i < Math.min(startIdx + 40, lines.size()); i++) {
+            String line = lines.get(i);
+            for (String kw : keywords) {
+                if (line.contains(kw)) {
+                    String score = extractScore(line);
+                    if (score != null) {
+                        // 提取更精确的键名
+                        String key = line.replaceAll("\\d+", "").replaceAll("[::()()]", "").trim();
+                        if (key.length() > 2 && key.length() < 20) {
+                            result.put(key, score);
+                        } else {
+                            result.put(kw, score);
+                        }
+                    }
+                    break;
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析情绪状态
+     */
+    private Map<String, String> parseEmotionState(List<String> lines, int startIdx) {
+        Map<String, String> result = new LinkedHashMap<>();
+        String[] keywords = {"自卑", "自信", "抑郁", "愉快", "焦虑", "安详", "无力感", "掌控感"};
+        for (int i = startIdx; i < Math.min(startIdx + 30, lines.size()); i++) {
+            String line = lines.get(i);
+            for (String kw : keywords) {
+                if (line.contains(kw)) {
+                    String score = extractScore(line);
+                    if (score != null) {
+                        result.put(kw, score);
+                    }
+                    break;
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析综合能力
+     */
+    private Map<String, String> parseComprehensiveAbility(List<String> lines, int startIdx) {
+        Map<String, String> result = new LinkedHashMap<>();
+        String[] keywords = {"情绪调节", "抗挫折", "内驱力", "社会适应", "同理心", "自律"};
+        for (int i = startIdx; i < Math.min(startIdx + 30, lines.size()); i++) {
+            String line = lines.get(i);
+            for (String kw : keywords) {
+                if (line.contains(kw)) {
+                    String score = extractScore(line);
+                    if (score != null) {
+                        result.put(kw, score);
+                    }
+                    break;
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析自我概念(B4)
+     */
+    private Map<String, String> parseSelfConcept(List<String> lines, int startIdx) {
+        Map<String, String> result = new LinkedHashMap<>();
+        // 自我概念通常包含6个维度
+        for (int i = startIdx; i < Math.min(startIdx + 30, lines.size()); i++) {
+            String line = lines.get(i);
+            if (line.length() > 1 && line.length() < 15 && !line.matches(".*[\\d]{2,}.*")) {
+                String nextLine = i + 1 < lines.size() ? lines.get(i + 1) : "";
+                String score = extractScore(nextLine.isEmpty() ? line : nextLine);
+                if (score != null) {
+                    result.put(line, score);
+                    i++;
+                }
+            } else {
+                String score = extractScore(line);
+                if (score != null) {
+                    String name = line.replaceAll("\\d+", "").replaceAll("[::()().%]", "").trim();
+                    if (name.length() >= 2) {
+                        result.put(name, score);
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析成长型思维(B4)
+     */
+    private Map<String, String> parseGrowthMindset(List<String> lines, int startIdx) {
+        Map<String, String> result = new LinkedHashMap<>();
+        Pattern pattern = Pattern.compile("(成长[型思维]*|思维模式|智力[观看法]*|能力[观看法]*)[^\\d]*(\\d+)");
+        for (int i = startIdx; i < Math.min(startIdx + 20, lines.size()); i++) {
+            String line = lines.get(i);
+            Matcher m = pattern.matcher(line);
+            if (m.find()) {
+                result.put(m.group(1).trim(), m.group(2));
+            } else {
+                String score = extractScore(line);
+                if (score != null) {
+                    String name = line.replaceAll("\\d+\\.?\\d*", "").replaceAll("[::()()]", "").trim();
+                    if (name.length() >= 2 && name.length() < 20) {
+                        result.put(name, score);
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析自驱力(B4)
+     */
+    private Map<String, String> parseSelfDriving(List<String> lines, int startIdx) {
+        Map<String, String> result = new LinkedHashMap<>();
+        String[] keywords = {"自主性", "胜任感", "归属感", "自驱力", "自我驱动", "主动性"};
+        for (int i = startIdx; i < Math.min(startIdx + 30, lines.size()); i++) {
+            String line = lines.get(i);
+            for (String kw : keywords) {
+                if (line.contains(kw)) {
+                    String score = extractScore(line);
+                    if (score != null) {
+                        result.put(kw, score);
+                    }
+                    break;
+                }
+            }
+        }
+        return result;
+    }
+
+    // ======================== 通用工具方法 ========================
+
+    /**
+     * 从行中提取数字分数
+     */
+    private String extractScore(String line) {
+        if (line == null) return null;
+        // 先尝试匹配 "名称: 分数" 格式
+        Pattern p1 = Pattern.compile("[::]\\s*(\\d+(\\.\\d+)?)");
+        Matcher m1 = p1.matcher(line);
+        if (m1.find()) {
+            return m1.group(1);
+        }
+        // 再尝试单纯提取数字
+        Pattern p2 = Pattern.compile("(\\d+(\\.\\d+)?)");
+        Matcher m2 = p2.matcher(line);
+        if (m2.find()) {
+            String num = m2.group(1);
+            // 避免提取年份(4位数)或明显不是分数的数字
+            int val = Integer.parseInt(num.replace(".",""));
+            if (val >= 0 && val <= 100) {
+                return num;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 查找section标题所在行
+     */
+    private int findSectionStart(List<String> lines, String... headers) {
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i);
+            for (String header : headers) {
+                if (line.contains(header)) {
+                    return i + 1;
+                }
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * 查找字段值(支持多个候选关键词)
+     */
+    private String findFieldValue(List<String> lines, String... keywords) {
+        for (String keyword : keywords) {
+            for (int i = 0; i < lines.size() - 1; i++) {
+                String line = lines.get(i);
+                if (line.contains(keyword + ":") || line.contains(keyword + ":")) {
+                    // 同行模式: "姓名: 张三"
+                    int colonIdx = Math.max(line.indexOf(':'), line.indexOf(':'));
+                    if (colonIdx >= 0 && colonIdx + 1 < line.length()) {
+                        String val = line.substring(colonIdx + 1).trim();
+                        if (!val.isEmpty()) return val;
+                    }
+                } else if (line.trim().equals(keyword) || line.trim().equals(keyword + ":") || line.trim().equals(keyword + ":")) {
+                    // 下一行模式
+                    String val = lines.get(i + 1).trim();
+                    if (!val.isEmpty() && val.length() < 50) return val;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 提取文本段落
+     */
+    private String extractSection(String text, String startMarker, String endMarker) {
+        int start = text.indexOf(startMarker);
+        if (start < 0) return "";
+        start += startMarker.length();
+        if (endMarker == null || endMarker.isEmpty()) {
+            return text.substring(start).replaceAll("^[\\s:\\n]+", "").trim();
+        }
+        int end = text.indexOf(endMarker, start);
+        if (end > start) {
+            return text.substring(start, end).replaceAll("^[\\s:\\n]+", "").trim();
+        }
+        return "";
+    }
+
+    // ======================== 内部数据类 ========================
+
+    /**
+     * 单项数据
+     */
+    public static class DataItem {
+        private String code;
+        private String name;
+        private String value;
+        private String category;
+
+        public DataItem() {}
+
+        public DataItem(String code, String name, String value, String category) {
+            this.code = code;
+            this.name = name;
+            this.value = value;
+            this.category = category;
+        }
+
+        public String getCode() { return code; }
+        public void setCode(String code) { this.code = code; }
+        public String getName() { return name; }
+        public void setName(String name) { this.name = name; }
+        public String getValue() { return value; }
+        public void setValue(String value) { this.value = value; }
+        public String getCategory() { return category; }
+        public void setCategory(String category) { this.category = category; }
+    }
+
+    /**
+     * 解析结果
+     */
+    public static class DanParsedReport {
+        private String reportType; // A2 / B4
+        private String dimension;  // mind / wisdom
+        private List<DataItem> items;
+        private Map<String, Object> extra;
+        private String summary;
+        private String suggestions;
+
+        public DanParsedReport() {}
+
+        public DanParsedReport(String reportType, String dimension, List<DataItem> items,
+                                Map<String, Object> extra, String summary, String suggestions) {
+            this.reportType = reportType;
+            this.dimension = dimension;
+            this.items = items;
+            this.extra = extra;
+            this.summary = summary;
+            this.suggestions = suggestions;
+        }
+
+        public static DanParsedReport empty() {
+            return new DanParsedReport("", "", new ArrayList<>(), new LinkedHashMap<>(), "", "");
+        }
+
+        public boolean isEmpty() {
+            return items == null || items.isEmpty();
+        }
+
+        public String getReportType() { return reportType; }
+        public void setReportType(String reportType) { this.reportType = reportType; }
+        public String getDimension() { return dimension; }
+        public void setDimension(String dimension) { this.dimension = dimension; }
+        public List<DataItem> getItems() { return items; }
+        public void setItems(List<DataItem> items) { this.items = items; }
+        public Map<String, Object> getExtra() { return extra; }
+        public void setExtra(Map<String, Object> extra) { this.extra = extra; }
+        public String getSummary() { return summary; }
+        public void setSummary(String summary) { this.summary = summary; }
+        public String getSuggestions() { return suggestions; }
+        public void setSuggestions(String suggestions) { this.suggestions = suggestions; }
+    }
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java

@@ -12,4 +12,12 @@ public interface DimensionScoreService {
     List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit);
 
     void refreshFromGutReport(Long memberId, Long reportId);
+
+    /**
+     * 根据 DAN 测评报告更新维度分数
+     * @param memberId 家庭成员ID
+     * @param resultId DanAssessmentResult ID
+     * @param dimension 维度: mind / wisdom
+     */
+    void refreshFromDanAssessment(Long memberId, Long resultId, String dimension);
 }

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionWeightService.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.DimensionWeight;
 import com.etotem.cfc.mapper.DimensionWeightMapper;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -16,6 +17,9 @@ public class DimensionWeightService {
     @Resource
     private DimensionWeightMapper dimensionWeightMapper;
 
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
     public List<DimensionWeight> getByTarget(String targetType, Long targetId) {
         return dimensionWeightMapper.selectList(
                 new LambdaQueryWrapper<DimensionWeight>()
@@ -25,6 +29,7 @@ public class DimensionWeightService {
 
     @Transactional
     public void saveWeights(String targetType, Long targetId, List<DimensionWeight> weights) {
+        // Save to dimension_weights table
         dimensionWeightMapper.delete(
                 new LambdaQueryWrapper<DimensionWeight>()
                         .eq(DimensionWeight::getTargetType, targetType)
@@ -40,6 +45,37 @@ public class DimensionWeightService {
                 dimensionWeightMapper.insert(w);
             }
         }
+
+        // Also update the dimension_weights JSON column on the main entity table
+        // so that frontend filtering queries (JSON_EXTRACT) reflect the changes
+        String tableName = getTableName(targetType);
+        if (tableName != null) {
+            if (weights != null && !weights.isEmpty()) {
+                StringBuilder json = new StringBuilder("{");
+                for (int i = 0; i < weights.size(); i++) {
+                    DimensionWeight w = weights.get(i);
+                    if (i > 0) json.append(",");
+                    json.append("\"").append(w.getDimension()).append("\":");
+                    json.append(w.getWeight() != null ? w.getWeight() : 0);
+                }
+                json.append("}");
+                String sql = "UPDATE " + tableName + " SET dimension_weights = CAST(? AS JSON) WHERE id = ?";
+                jdbcTemplate.update(sql, json.toString(), targetId);
+            } else {
+                // No weights = clear the JSON column
+                String sql = "UPDATE " + tableName + " SET dimension_weights = NULL WHERE id = ?";
+                jdbcTemplate.update(sql, targetId);
+            }
+        }
+    }
+
+    private String getTableName(String targetType) {
+        switch (targetType) {
+            case "activity": return "activities";
+            case "product": return "products";
+            case "article": return "articles";
+            default: return null;
+        }
     }
 
     @Transactional

+ 89 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -17,7 +17,10 @@ import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.HealthDimensionScoreService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.HealthDataSourceRecordService;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.service.DimensionEnergySyncService;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
@@ -27,6 +30,7 @@ import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 public class DimensionScoreServiceImpl implements DimensionScoreService {
 
@@ -49,6 +53,9 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
     @Resource
     private HealthReportService healthReportService;
 
@@ -241,6 +248,88 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
         return items;
     }
 
+    @Override
+    @Transactional
+    public void refreshFromDanAssessment(Long memberId, Long resultId, String dimension) {
+        DanAssessmentResult result = danAssessmentResultMapper.selectById(resultId);
+        if (result == null) return;
+
+        // 从 structured_analysis JSON 中提取分数
+        String analysisJson = result.getStructuredAnalysis();
+        if (analysisJson == null || analysisJson.isEmpty()) return;
+
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+            java.util.Map<String, Object> analysis = mapper.readValue(analysisJson,
+                    new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, Object>>() {});
+
+            @SuppressWarnings("unchecked")
+            java.util.List<java.util.Map<String, Object>> items =
+                    (java.util.List<java.util.Map<String, Object>>) analysis.getOrDefault("items", new java.util.ArrayList<>());
+
+            if (items.isEmpty()) return;
+
+            // 按 category 分组求平均,更新对应维度
+            java.util.Map<String, java.util.List<Integer>> categoryScores = new java.util.LinkedHashMap<>();
+            for (java.util.Map<String, Object> item : items) {
+                String category = (String) item.getOrDefault("category", "general");
+                String valueStr = (String) item.get("value");
+                if (valueStr != null) {
+                    try {
+                        int val = Integer.parseInt(valueStr.replaceAll("[^0-9]", ""));
+                        categoryScores.computeIfAbsent(category, k -> new java.util.ArrayList<>()).add(val);
+                    } catch (NumberFormatException ignored) {}
+                }
+            }
+
+            if (categoryScores.isEmpty()) return;
+
+            Date assessDate = result.getAssessmentDate() != null
+                    ? result.getAssessmentDate() : new java.sql.Date(System.currentTimeMillis());
+
+            // Mind 维度映射
+            java.util.Map<String, String> dimMapping = new java.util.LinkedHashMap<>();
+            if ("mind".equals(dimension)) {
+                dimMapping.put("bigFive", "emotion");
+                dimMapping.put("emotion", "emotion");
+                dimMapping.put("social", "emotion");
+                dimMapping.put("ability", "emotion");
+                dimMapping.put("auto", "emotion");
+                dimMapping.put("general", "emotion");
+            } else if ("wisdom".equals(dimension)) {
+                dimMapping.put("selfConcept", "cognitive");
+                dimMapping.put("growthMindset", "cognitive");
+                dimMapping.put("selfDriving", "cognitive");
+                dimMapping.put("auto", "cognitive");
+                dimMapping.put("general", "cognitive");
+            }
+
+            for (java.util.Map.Entry<String, java.util.List<Integer>> entry : categoryScores.entrySet()) {
+                String category = entry.getKey();
+                java.util.List<Integer> scores = entry.getValue();
+                int avg = (int) scores.stream().mapToInt(v -> v).average().orElse(0);
+                String targetDim = dimMapping.getOrDefault(category, "emotion");
+
+                HealthDimensionScore score = new HealthDimensionScore();
+                score.setMemberId(memberId);
+                score.setDimension(targetDim);
+                score.setScore(Math.min(100, Math.max(0, avg)));
+                score.setPercentile(calcPercentile(targetDim, avg));
+                score.setDataSource("REPORT");
+                score.setTier(1);
+                score.setAssessDate(assessDate);
+                java.util.Calendar cal = java.util.Calendar.getInstance();
+                cal.setTime(assessDate);
+                cal.add(java.util.Calendar.MONTH, 6);
+                score.setExpireDate(cal.getTime());
+                score.setRawData("{\"resultId\":" + resultId + ",\"category\":\"" + category + "\"}");
+                scoreService.saveScore(score);
+            }
+        } catch (Exception e) {
+            log.warn("Failed to parse structured_analysis for resultId={}: {}", resultId, e.getMessage());
+        }
+    }
+
     @Override
     @Transactional
     public void refreshFromGutReport(Long memberId, Long reportId) {

+ 3 - 0
cfc-backend/src/main/resources/schema.sql

@@ -413,6 +413,9 @@ CREATE TABLE IF NOT EXISTS dan_assessment_results (
     process_desc TEXT COMMENT '过程描述',
     result_desc TEXT COMMENT '结果简评',
     source VARCHAR(20) DEFAULT 'planner_entry' COMMENT '报告来源: planner_entry/parent_upload/auto_fetch',
+    dimension VARCHAR(10) DEFAULT 'mind' COMMENT '报告维度: mind(心-A2)/wisdom(智-B4)',
+    report_file_id VARCHAR(100) COMMENT '上传文件媒资ID',
+    draft_status VARCHAR(10) DEFAULT 'pending' COMMENT '草稿状态: pending(待确认)/confirmed(已确认)',
     INDEX idx_child_id (child_id),
     INDEX idx_teacher_id (teacher_id),
     INDEX idx_assessment_date (assessment_date)

+ 19 - 4
cfc-frontend/components/DimensionArticles.vue

@@ -4,11 +4,26 @@
       <text class="section-title">🔥 推荐阅读</text>
       <text class="section-more" @click="$emit('moreArticles')">更多 ›</text>
     </view>
-    <view v-if="!displayArticles || displayArticles.length === 0" class="empty-state">
-      <text class="empty-tip">{{ isLoggedIn ? '暂无阅读推荐' : '登录后查看更多文章' }}</text>
-    </view>
     <view class="article-list">
-      <view class="article-card" v-for="article in displayArticles" :key="article.id" @click="$emit('articleClick', article)">
+      <!-- 无数据时显示占位卡片 -->
+      <template v-if="!displayArticles || displayArticles.length === 0">
+        <view class="article-card" v-for="i in 3" :key="'ph-' + i">
+          <view class="article-category" :style="{ background: colors[(i-1) % colors.length] }">
+            <text class="article-category-text">推荐文章</text>
+          </view>
+          <text class="article-title">暂无推荐文章</text>
+          <text class="article-summary">精彩内容即将上线,敬请期待</text>
+          <view class="article-meta">
+            <view class="meta-item">
+              <text class="meta-icon">&#x1F4D6;</text>
+              <text class="meta-text">0</text>
+            </view>
+            <text class="article-date">--</text>
+          </view>
+        </view>
+      </template>
+      <!-- 有数据时显示文章卡片 -->
+      <view v-else class="article-card" v-for="article in displayArticles" :key="article.id" @click="$emit('articleClick', article)">
         <view class="article-category" :style="{ background: article.categoryColor || defaultColors[0] }">
           <text class="article-category-text">{{ article.category || '推荐文章' }}</text>
         </view>

+ 469 - 401
cfc-frontend/components/FamilyRelationGraph.vue

@@ -1,103 +1,35 @@
 <template>
-  <!-- 家庭关系图谱 -->
-  <view class="family-relation-graph" v-if="members && members.length >= 1">
-    <!-- Canvas 连线层 -->
+  <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
+  <view class="family-graph-wrapper" v-if="simNodes && simNodes.length >= 1">
     <canvas
-      class="graph-canvas"
-      canvas-id="relationGraphCanvas"
-      id="relationGraphCanvas"
+      class="family-graph-canvas"
+      canvas-id="forceGraphCanvas"
+      id="forceGraphCanvas"
       type="2d"
-      :style="{
-        width: canvasWidth + 'px',
-        height: canvasHeight + 'px'
-      }" />
-    <!-- View 节点层 -->
-    <view class="graph-nodes">
-      <!-- 2-3人: 单行布局 -->
-      <template v-if="layoutMode === 'row'">
-        <view class="row-layout">
-          <view
-            v-for="m in displayOrder"
-            :key="m.id"
-            class="member-node"
-            :class="{ 'is-self': m.isSelf, 'is-parent': m.memberType === 'parent' }"
-            :style="{ width: m.avatarSize + 'rpx' }"
-            :data-member-id="m.id"
-            :data-member-type="m.memberType"
-            :data-member-nickname="m.nickname"
-            @tap="onMemberTap">
-            <view
-              class="member-avatar"
-              :class="{ 'avatar-square': m.memberType === 'parent' }"
-              :style="{
-                width: m.avatarSize + 'rpx',
-                height: m.avatarSize + 'rpx',
-                backgroundColor: m.isSelf ? '#FFFFFF' : themeColorRgba,
-                borderColor: m.isSelf ? themeColor : 'transparent'
-              }">
-              <text class="avatar-letter" :style="{ color: m.isSelf ? themeColor : '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
-            </view>
-            <text class="member-name" :style="{ color: m.isSelf ? themeColor : '#94A3B8', fontWeight: m.isSelf ? 'bold' : 'normal' }">{{ m.nickname }}</text>
-          </view>
-        </view>
-      </template>
-      <!-- 4+人: 多行布局 -->
-      <template v-else>
-        <view class="self-row">
-          <view
-            class="member-node is-self"
-            :style="{ width: selfMember.avatarSize + 'rpx' }"
-            :data-member-id="selfMember.id"
-            :data-member-type="selfMember.memberType"
-            :data-member-nickname="selfMember.nickname"
-            @tap="onMemberTap">
-            <view
-              class="member-avatar"
-              :style="{
-                width: selfMember.avatarSize + 'rpx',
-                height: selfMember.avatarSize + 'rpx',
-                backgroundColor: '#FFFFFF',
-                borderColor: themeColor
-              }">
-              <text class="avatar-letter" :style="{ color: themeColor, fontSize: selfMember.avatarSize * 0.45 + 'rpx' }">{{ selfMember.displayName }}</text>
-            </view>
-            <text class="member-name" :style="{ color: themeColor, fontWeight: 'bold' }">{{ selfMember.nickname }}</text>
-          </view>
-        </view>
-        <view class="others-row">
-          <view
-            v-for="m in otherMembers"
-            :key="m.id"
-            class="member-node"
-            :class="{ 'is-parent': m.memberType === 'parent' }"
-            :style="{ width: m.avatarSize + 'rpx' }"
-            :data-member-id="m.id"
-            :data-member-type="m.memberType"
-            :data-member-nickname="m.nickname"
-            @tap="onMemberTap">
-            <view
-              class="member-avatar"
-              :class="{ 'avatar-square': m.memberType === 'parent' }"
-              :style="{
-                width: m.avatarSize + 'rpx',
-                height: m.avatarSize + 'rpx',
-                backgroundColor: themeColorRgba
-              }">
-              <text class="avatar-letter" :style="{ color: '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
-            </view>
-            <text class="member-name" :style="{ color: '#94A3B8' }">{{ m.nickname }}</text>
-          </view>
-        </view>
-      </template>
-    </view>
+      :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      @touchstart="onTouchStart"
+      @touchmove="onTouchMove"
+      @touchend="onTouchEnd" />
   </view>
-  <!-- 0人空状态 -->
-  <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 0">
+  <!-- 空状态 -->
+  <view class="family-graph-empty" v-else-if="members && members.length === 0">
     <text class="empty-text">暂无家庭成员</text>
   </view>
 </template>
 
 <script>
+/**
+ * FamilyRelationGraph - 全 Canvas force-directed 家庭关系图
+ *
+ * 物理引擎: 手动实现的 force-directed 布局
+ * - 节点间斥力 (repulsion)
+ * - 边弹簧引力 (spring attraction)
+ * - 中心引力 (centering)
+ * - 阻尼 (damping)
+ *
+ * 渲染: 全 Canvas 绘制 (节点 + 连线)
+ * 交互: 节点拖拽 + 点击检测
+ */
 export default {
   name: 'FamilyRelationGraph',
   props: {
@@ -135,9 +67,33 @@ export default {
   },
   data: function() {
     return {
-      canvasWidth: 300,
-      canvasHeight: 300,
+      canvasWidth: 340,
+      canvasHeight: 280,
       dpr: 1,
+
+      // 物理模拟节点
+      simNodes: [],
+
+      // 物理参数
+      physics: {
+        repulsion: 800,
+        springLength: 120,
+        springStrength: 0.04,
+        centering: 0.03,
+        damping: 0.88,
+        maxSpeed: 12,
+        minDistance: 30
+      },
+
+      // 拖拽状态
+      dragging: null,
+      dragStartPos: null,
+
+      // 动画
+      animFrameId: null,
+      isSimulating: false,
+
+      // 主题色
       themeColors: {
         body: '#8D6E63',
         mind: '#FF6B35',
@@ -151,373 +107,485 @@ export default {
     themeColor: function() {
       return this.themeColors[this.dimensionCode] || '#8D6E63'
     },
-    themeColorRgba: function() {
-      return this.themeColor + '26'
-    },
-    layoutMode: function() {
-      if (!this.members || this.members.length <= 3) return 'row'
-      return 'multi-row'
-    },
-    selfMember: function() {
+
+    // 将 members 转换为 simNodes
+    nodeData: function() {
       var self = this
-      var found = null
-      for (var i = 0; i < this.members.length; i++) {
-        var mid = this.members[i].memberId || this.members[i].id
-        if (mid != null && mid == this.selfId) {
-          found = this.members[i]
-          break
+      if (!this.members || this.members.length === 0) return []
+
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+
+      return this.members.map(function(m, idx) {
+        var fid = m.memberId || m.id
+        var energy = self.getEnergy(fid)
+        var radius = 24 + (Math.max(0, Math.min(100, energy)) / 100) * 16
+
+        var nick = m.nickname || m.name || '成员'
+        if (nick === '用户') nick = '成员'
+
+        var isSelf = (fid == self.selfId)
+        var displayName = nick.charAt(0)
+
+        // 圆形布局初始位置
+        var angle = (2 * Math.PI * idx) / Math.max(1, self.members.length)
+        var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
+        var initX = centerX + r * Math.cos(angle)
+        var initY = centerY + r * Math.sin(angle)
+
+        return {
+          id: fid,
+          nickname: nick,
+          displayName: displayName,
+          isSelf: isSelf,
+          memberType: m.memberType || 'child',
+          radius: radius,
+          x: initX,
+          y: initY,
+          vx: 0,
+          vy: 0,
+          fx: 0,
+          fy: 0
         }
-      }
-      if (!found && this.members.length > 0) {
-        found = this.members[0]
-      }
-      if (!found) return { id: null, nickname: '我', memberType: 'child', isSelf: true, displayName: '我', avatarSize: 120 }
-      var fid = found.memberId || found.id
-      var energy = this.getEnergy(fid)
-      var size = Math.max(96, 80 + (Math.max(0, Math.min(100, energy)) / 100) * 56)
-      var nick = found.nickname || found.name || '我'
-      if (nick === '用户') nick = '我'
-      return {
-        id: fid,
-        nickname: nick,
-        memberType: found.memberType || 'child',
-        isSelf: true,
-        displayName: (found.nickname && found.nickname.charAt(0)) || (found.name && found.name.charAt(0)) || '我',
-        avatarSize: size
-      }
+      })
     },
-    otherMembers: function() {
+
+    // 构建边 (self 连接到其他所有成员)
+    edges: function() {
       var self = this
-      return this.members
-        .filter(function(m) {
-          var mid = m.memberId || m.id
-          return mid != self.selfId
-        })
-        .map(function(m) {
-          var fid = m.memberId || m.id
-          var energy = self.getEnergy(fid)
-          var size = 64 + (Math.max(0, Math.min(100, energy)) / 100) * 56
-          var nick = m.nickname || m.name || '成员'
-          if (nick === '用户') nick = '成员'
-          return {
-            id: fid,
-            nickname: nick,
-            memberType: m.memberType || 'child',
-            isSelf: false,
-            displayName: (m.nickname && m.nickname.charAt(0)) || (m.name && m.name.charAt(0)) || '?',
-            avatarSize: size
-          }
-        })
-    },
-    displayOrder: function() {
-      if (this.layoutMode !== 'row') return []
-      var others = this.otherMembers
-      if (this.members.length === 2) {
-        return [this.selfMember].concat(others)
-      }
-      if (this.members.length === 3 && others.length >= 2) {
-        return [others[0], this.selfMember, others[1]]
-      }
-      return [this.selfMember].concat(others)
+      var selfNode = this.nodeData.find(function(n) { return n.isSelf })
+      if (!selfNode) return []
+
+      var result = []
+      this.nodeData.forEach(function(n) {
+        if (n.id !== selfNode.id) {
+          var pairKey = selfNode.id < n.id
+            ? selfNode.id + '-' + n.id
+            : n.id + '-' + selfNode.id
+          var comp = self.compatibilityMap[pairKey]
+          var trust = self.getIntimacy(n.id, 'trust')
+          var comm = self.getIntimacy(n.id, 'communication')
+          var closeness = self.getIntimacy(n.id, 'closeness')
+
+          result.push({
+            source: selfNode,
+            target: n,
+            trust: trust,
+            communication: comm,
+            closeness: closeness,
+            compatibility: comp && comp.overallScore !== undefined ? comp.overallScore : null
+          })
+        }
+      })
+      return result
     }
   },
   watch: {
-    members: function() { this.scheduleDraw() },
-    energyMap: function() { this.scheduleDraw() },
-    intimacyMap: function() { this.scheduleDraw() }
+    members: function() {
+      this.resetSimulation()
+    },
+    canvasWidth: function() {
+      this.resetSimulation()
+    },
+    canvasHeight: function() {
+      this.resetSimulation()
+    }
   },
   mounted: function() {
+    var self = this
     this.dpr = uni.getSystemInfoSync().pixelRatio || 1
-    this.scheduleDraw()
+
+    this.$nextTick(function() {
+      self.updateCanvasSize()
+      self.$nextTick(function() {
+        self.initSimulation()
+      })
+    })
+  },
+  beforeDestroy: function() {
+    this.stopSimulation()
   },
   methods: {
-    // 获取能量值
-    getEnergy: function(memberId) {
-      var data = this.energyMap[memberId]
-      if (!data) return 0
-      var key = this.dimensionCode + 'Score'
-      var val = data[key]
-      return (typeof val !== 'undefined' && val !== null) ? val : 0
+    // ===== 物理引擎 =====
+
+    resetSimulation: function() {
+      this.stopSimulation()
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+      var self = this
+      this.nodeData.forEach(function(n, idx) {
+        var angle = (2 * Math.PI * idx) / Math.max(1, self.nodeData.length)
+        var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
+        n.x = centerX + r * Math.cos(angle)
+        n.y = centerY + r * Math.sin(angle)
+        n.vx = 0
+        n.vy = 0
+        n.fx = 0
+        n.fy = 0
+      })
+      this.startSimulation()
     },
 
-    // 获取亲密度数据
-    getIntimacy: function(memberId, key) {
-      var data = this.intimacyMap[memberId]
-      if (!data) {
-        var defaults = { closeness: 75, communication: 50, trust: 85 }
-        return defaults[key]
+    initSimulation: function() {
+      if (!this.nodeData || this.nodeData.length === 0) return
+      // 复制到 simNodes
+      this.simNodes = this.nodeData.slice()
+      this.startSimulation()
+    },
+
+    startSimulation: function() {
+      if (this.isSimulating) return
+      this.isSimulating = true
+      this.simulationTick()
+    },
+
+    stopSimulation: function() {
+      this.isSimulating = false
+      if (this.animFrameId) {
+        cancelAnimationFrame(this.animFrameId)
+        this.animFrameId = null
       }
-      var val = data[key]
-      return (typeof val !== 'undefined' && val !== null) ? val : 75
     },
 
-    // 触发重绘:等 View 布局完成 → 读坐标 → 在 Canvas 画线
-    scheduleDraw: function() {
+    simulationTick: function() {
+      if (!this.isSimulating) return
+
       var self = this
-      this.$nextTick(function() {
-        self.updateCanvasSize()
-        self.$nextTick(function() {
-          self.drawLines()
-        })
+      var nodes = this.simNodes
+      var p = this.physics
+
+      // 1. 清零力
+      for (var i = 0; i < nodes.length; i++) {
+        nodes[i].fx = 0
+        nodes[i].fy = 0
+      }
+
+      // 2. 斥力
+      for (var a = 0; a < nodes.length; a++) {
+        for (var b = a + 1; b < nodes.length; b++) {
+          var dx = nodes[b].x - nodes[a].x
+          var dy = nodes[b].y - nodes[a].y
+          var distSq = dx * dx + dy * dy
+          var dist = Math.sqrt(distSq) || 0.1
+          var force = p.repulsion / distSq
+          var fx = (dx / dist) * force
+          var fy = (dy / dist) * force
+          nodes[a].fx -= fx
+          nodes[a].fy -= fy
+          nodes[b].fx += fx
+          nodes[b].fy += fy
+        }
+      }
+
+      // 3. 弹簧引力
+      for (var e = 0; e < this.edges.length; e++) {
+        var edge = this.edges[e]
+        var s = edge.source
+        var t = edge.target
+        var dx = t.x - s.x
+        var dy = t.y - s.y
+        var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
+        var displacement = dist - p.springLength
+        var force = p.springStrength * displacement
+        var fx = (dx / dist) * force
+        var fy = (dy / dist) * force
+        s.fx += fx
+        s.fy += fy
+        t.fx -= fx
+        t.fy -= fy
+      }
+
+      // 4. 中心引力
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      for (var n = 0; n < nodes.length; n++) {
+        nodes[n].fx += (cx - nodes[n].x) * p.centering
+        nodes[n].fy += (cy - nodes[n].y) * p.centering
+      }
+
+      // 5. 更新速度 + 位置
+      for (var i = 0; i < nodes.length; i++) {
+        var node = nodes[i]
+        node.vx += node.fx
+        node.vy += node.fy
+        node.vx *= p.damping
+        node.vy *= p.damping
+        var speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
+        if (speed > p.maxSpeed) {
+          node.vx = (node.vx / speed) * p.maxSpeed
+          node.vy = (node.vy / speed) * p.maxSpeed
+        }
+        node.x += node.vx
+        node.y += node.vy
+        // 边界
+        var r = node.radius + 4
+        if (node.x < r) { node.x = r; node.vx *= -0.5 }
+        if (node.x > this.canvasWidth - r) { node.x = this.canvasWidth - r; node.vx *= -0.5 }
+        if (node.y < r) { node.y = r; node.vy *= -0.5 }
+        if (node.y > this.canvasHeight - r) { node.y = this.canvasHeight - r; node.vy *= -0.5 }
+      }
+
+      // 6. 渲染
+      this.render()
+
+      // 7. 下一帧
+      this.animFrameId = requestAnimationFrame(function() {
+        self.simulationTick()
       })
     },
 
-    // 读取 View 节点层尺寸,设置 Canvas 覆盖尺寸
-    updateCanvasSize: function() {
+    // ===== 渲染 =====
+
+    render: function() {
       var self = this
       var query = uni.createSelectorQuery().in(this)
-      query.select('.graph-nodes').boundingClientRect(function(rect) {
-        if (!rect) return
-        self.canvasWidth = rect.width
-        self.canvasHeight = rect.height
-      }).exec()
+      query.select('#forceGraphCanvas')
+        .fields({ node: true, size: true })
+        .exec(function(res) {
+          if (!res || !res[0] || !res[0].node) {
+            self.renderLegacy()
+            return
+          }
+          var canvas = res[0].node
+          var ctx = canvas.getContext('2d')
+          canvas.width = self.canvasWidth * self.dpr
+          canvas.height = self.canvasHeight * self.dpr
+          ctx.scale(self.dpr, self.dpr)
+          ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
+          self.drawEdges(ctx)
+          self.drawNodes(ctx)
+        })
+    },
+
+    drawEdges: function(ctx) {
+      var edges = this.edges
+      for (var i = 0; i < edges.length; i++) {
+        var edge = edges[i]
+        var s = edge.source
+        var t = edge.target
+        var trust = edge.trust || 75
+        var comm = edge.communication || 50
+        var hue = 120 * trust / 100
+        ctx.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
+        ctx.lineWidth = 1 + (comm / 100) * 4
+        ctx.beginPath()
+        ctx.moveTo(s.x, s.y)
+        ctx.lineTo(t.x, t.y)
+        ctx.stroke()
+      }
     },
 
-    // Canvas 连线绘制(type=2d 优先,降级 CanvasContext)
-    drawLines: function() {
+    drawNodes: function(ctx) {
+      var nodes = this.simNodes
+      for (var i = 0; i < nodes.length; i++) {
+        var node = nodes[i]
+        var r = node.radius
+        ctx.beginPath()
+        ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
+        if (node.isSelf) {
+          ctx.fillStyle = '#FFFFFF'
+          ctx.fill()
+          ctx.strokeStyle = this.themeColor
+          ctx.lineWidth = 3
+          ctx.stroke()
+        } else {
+          ctx.fillStyle = this.themeColor + '22'
+          ctx.fill()
+        }
+        ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
+        ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(node.displayName, node.x, node.y)
+        ctx.fillStyle = node.isSelf ? this.themeColor : '#94A3B8'
+        ctx.font = (r * 0.4) + 'px sans-serif'
+        ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
+      }
+    },
+
+    renderLegacy: function() {
+      var ctx = uni.createCanvasContext('forceGraphCanvas', this)
       var self = this
-      if (!this.members || this.members.length < 2) return
+      ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+      var edges = this.edges
+      for (var i = 0; i < edges.length; i++) {
+        var edge = edges[i]
+        var trust = edge.trust || 75
+        var comm = edge.communication || 50
+        var hue = 120 * trust / 100
+        ctx.setStrokeStyle('hsla(' + hue + ', 75%, 45%, 0.6)')
+        ctx.setLineWidth(1 + (comm / 100) * 4)
+        ctx.beginPath()
+        ctx.moveTo(edge.source.x, edge.source.y)
+        ctx.lineTo(edge.target.x, edge.target.y)
+        ctx.stroke()
+      }
+      var nodes = this.simNodes
+      for (var j = 0; j < nodes.length; j++) {
+        var node = nodes[j]
+        var r = node.radius
+        ctx.beginPath()
+        ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
+        if (node.isSelf) {
+          ctx.setFillStyle('#FFFFFF')
+          ctx.fill()
+          ctx.setStrokeStyle(this.themeColor)
+          ctx.setLineWidth(3)
+          ctx.stroke()
+        } else {
+          ctx.setFillStyle(this.themeColor + '22')
+          ctx.fill()
+        }
+        ctx.setFillStyle(node.isSelf ? this.themeColor : '#4A5568')
+        ctx.setFont('bold ' + (r * 0.9) + 'px sans-serif')
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('middle')
+        ctx.fillText(node.displayName, node.x, node.y)
+        ctx.setFillStyle(node.isSelf ? this.themeColor : '#94A3B8')
+        ctx.setFont((r * 0.4) + 'px sans-serif')
+        ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
+      }
+      ctx.draw()
+    },
+
+    // ===== 交互 =====
 
+    getTouchNode: function(touchX, touchY) {
+      var nodes = this.simNodes
+      for (var i = 0; i < nodes.length; i++) {
+        var node = nodes[i]
+        var dx = touchX - node.x
+        var dy = touchY - node.y
+        var dist = Math.sqrt(dx * dx + dy * dy)
+        if (dist <= node.radius + 8) {
+          return node
+        }
+      }
+      return null
+    },
+
+    onTouchStart: function(e) {
+      if (!this.interactive) return
+      var touch = e.touches ? e.touches[0] : e.detail
+      if (!touch) return
+      var self = this
+      var rect = {}
       var query = uni.createSelectorQuery().in(this)
-      // 查 self 头像、other 头像、容器 三个 rect
-      query.select('.is-self .member-avatar').boundingClientRect()
-      query.selectAll('.member-node:not(.is-self) .member-avatar').boundingClientRect()
-      query.select('.graph-nodes').boundingClientRect()
+      query.select('#forceGraphCanvas').boundingClientRect()
       query.exec(function(res) {
-        if (!res || !res[0]) return
-
-        var selfAvatarRect = res[0]
-        var otherAvatarRects = res[1] || []
-        var containerRect = res[2]
-        if (!selfAvatarRect || otherAvatarRects.length === 0) return
-
-        var containerLeft = containerRect ? containerRect.left : 0
-        var containerTop = containerRect ? containerRect.top : 0
-        var selfCX = selfAvatarRect.left + selfAvatarRect.width / 2 - containerLeft
-        var selfCY = selfAvatarRect.top + selfAvatarRect.height / 2 - containerTop
-        var selfR = selfAvatarRect.width / 2
-
-        // Canvas type=2d
-        var canvasQuery = uni.createSelectorQuery().in(self)
-        canvasQuery.select('#relationGraphCanvas')
-          .fields({ node: true, size: true })
-          .exec(function(canvasRes) {
-            if (!canvasRes || !canvasRes[0] || !canvasRes[0].node) {
-              self.drawLinesLegacy(selfCX, selfCY, selfR, otherAvatarRects, containerLeft, containerTop)
-              return
-            }
-            var canvas = canvasRes[0].node
-            var ctx = canvas.getContext('2d')
-
-            canvas.width = self.canvasWidth * self.dpr
-            canvas.height = self.canvasHeight * self.dpr
-            ctx.scale(self.dpr, self.dpr)
-            ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
-
-            for (var j = 0; j < otherAvatarRects.length; j++) {
-              var rect = otherAvatarRects[j]
-              var otherCX = rect.left + rect.width / 2 - containerLeft
-              var otherCY = rect.top + rect.height / 2 - containerTop
-              var otherR = rect.width / 2
-
-              var otherMember = self.otherMembers[j]
-              if (!otherMember) continue
-
-              var selfId = self.selfMember && self.selfMember.id
-              var pairKey = ''
-              if (selfId) {
-                pairKey = selfId < otherMember.id ? selfId + '-' + otherMember.id : otherMember.id + '-' + selfId
-              }
-              var comp = self.compatibilityMap[pairKey]
-
-              if (comp && comp.overallScore !== undefined) {
-                // 先天兼容度 → 线颜色 (红0→绿120, 紫90)
-                var cScore = Math.max(0, Math.min(100, comp.overallScore))
-                ctx.strokeStyle = 'hsl(' + (120 * cScore / 100) + ', 70%, 50%)'
-                ctx.globalAlpha = 0.55
-                ctx.lineWidth = 3 + (cScore / 100) * 6
-              } else {
-                // 信任度 → 线颜色 (红0°→绿120°)
-                var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
-                var hue = (120 * trust / 100)
-                ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
-                ctx.globalAlpha = 0.6
-
-                // 沟通度 → 线粗细 (2~10px)
-                var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
-                ctx.lineWidth = 2 + (comm / 100) * 8
-              }
-
-              // 边缘起止点:沿中心向量缩进半径
-              var dx = otherCX - selfCX
-              var dy = otherCY - selfCY
-              var dist = Math.sqrt(dx * dx + dy * dy)
-              if (dist < 1) continue
-              var ux = dx / dist
-              var uy = dy / dist
-
-              ctx.beginPath()
-              ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
-              ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
-              ctx.stroke()
-            }
-            ctx.globalAlpha = 1.0
-          })
+        if (res && res[0]) {
+          rect = res[0]
+          var x = touch.clientX - rect.left
+          var y = touch.clientY - rect.top
+          var node = self.getTouchNode(x, y)
+          if (node) {
+            self.dragging = { node: node, offsetX: x - node.x, offsetY: y - node.y }
+            self.dragStartPos = { x: x, y: y }
+          }
+        }
       })
     },
 
-    // 降级连线(CanvasContext)
-    drawLinesLegacy: function(selfCX, selfCY, selfR, otherRects, containerLeft, containerTop) {
-      var ctx = uni.createCanvasContext('relationGraphCanvas', this)
+    onTouchMove: function(e) {
+      if (!this.dragging) return
+      e.preventDefault && e.preventDefault()
+      var touch = e.touches ? e.touches[0] : e.detail
+      if (!touch) return
       var self = this
-
-      for (var j = 0; j < otherRects.length; j++) {
-        var rect = otherRects[j]
-        var otherCX = rect.left + rect.width / 2 - containerLeft
-        var otherCY = rect.top + rect.height / 2 - containerTop
-        var otherR = rect.width / 2
-
-        var otherMember = self.otherMembers[j]
-        if (!otherMember) continue
-
-        var selfId = self.selfMember && self.selfMember.id
-        var pairKey = ''
-        if (selfId) {
-          pairKey = selfId < otherMember.id ? selfId + '-' + otherMember.id : otherMember.id + '-' + selfId
+      var rect = {}
+      var query = uni.createSelectorQuery().in(this)
+      query.select('#forceGraphCanvas').boundingClientRect()
+      query.exec(function(res) {
+        if (res && res[0]) {
+          rect = res[0]
+          var x = touch.clientX - rect.left
+          var y = touch.clientY - rect.top
+          var node = self.dragging.node
+          node.x = x - self.dragging.offsetX
+          node.y = y - self.dragging.offsetY
         }
-        var comp = self.compatibilityMap[pairKey]
+      })
+    },
 
-        if (comp && comp.overallScore !== undefined) {
-          var cScore = Math.max(0, Math.min(100, comp.overallScore))
-          ctx.setStrokeStyle('hsl(' + (120 * cScore / 100) + ', 70%, 50%)')
-          ctx.globalAlpha = 0.55
-          ctx.setLineWidth(3 + (cScore / 100) * 6)
-        } else {
-          var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
-          var hue = (120 * trust / 100)
-          ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
-          ctx.globalAlpha = 0.6
+    onTouchEnd: function(e) {
+      if (!this.dragging) return
+      var wasDragged = this.dragStartPos && (
+        Math.abs(this.dragging.node.x - this.dragStartPos.x) > 5 ||
+        Math.abs(this.dragging.node.y - this.dragStartPos.y) > 5
+      )
+      var node = this.dragging.node
+      if (!wasDragged && this.interactive) {
+        this.$emit('memberTap', {
+          memberId: node.id,
+          memberType: node.memberType,
+          nickname: node.nickname
+        })
+      }
+      this.dragging = null
+      this.dragStartPos = null
+    },
 
-          var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
-          ctx.setLineWidth(2 + (comm / 100) * 8)
-        }
+    // ===== 工具方法 =====
 
-        var dx = otherCX - selfCX
-        var dy = otherCY - selfCY
-        var dist = Math.sqrt(dx * dx + dy * dy)
-        if (dist < 1) continue
-        var ux = dx / dist
-        var uy = dy / dist
+    getEnergy: function(memberId) {
+      var data = this.energyMap[memberId]
+      if (!data) return 0
+      var key = this.dimensionCode + 'Score'
+      var val = data[key]
+      return (typeof val !== 'undefined' && val !== null) ? val : 0
+    },
 
-        ctx.beginPath()
-        ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
-        ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
-        ctx.stroke()
+    getIntimacy: function(memberId, key) {
+      var data = this.intimacyMap[memberId]
+      if (!data) {
+        var defaults = { closeness: 75, communication: 50, trust: 85 }
+        return defaults[key]
       }
-      ctx.draw()
+      var val = data[key]
+      return (typeof val !== 'undefined' && val !== null) ? val : 75
     },
 
-    // View 节点点击事件
-    onMemberTap: function(e) {
-      if (!this.interactive) return
-      var target = e.currentTarget
-      if (!target || !target.dataset) return
-      var ds = target.dataset
-      this.$emit('memberTap', {
-        memberId: ds.memberId,
-        memberType: ds.memberType,
-        nickname: ds.memberNickname
-      })
+    updateCanvasSize: function() {
+      var self = this
+      var query = uni.createSelectorQuery().in(this)
+      query.select('.family-graph-wrapper').boundingClientRect(function(rect) {
+        if (rect) {
+          self.canvasWidth = rect.width || 340
+          self.canvasHeight = rect.height || 280
+        }
+      }).exec()
     }
   }
 }
 </script>
 
 <style scoped>
-.family-relation-graph {
+.family-graph-wrapper {
   position: relative;
-  padding: 24rpx 30rpx;
+  width: 100%;
+  height: 280px;
   margin: 10rpx 0;
   background: #FFFFFF;
   border-radius: 24rpx;
+  overflow: hidden;
 }
-.graph-empty {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 60rpx 0;
-}
-.empty-text {
-  font-size: 28rpx;
-  color: #94A3B8;
-}
-/* Canvas 层: absolute, 只画线 */
-.graph-canvas {
+.family-graph-canvas {
   position: absolute;
   top: 0;
   left: 0;
   z-index: 1;
-  pointer-events: none;
 }
-/* View 节点层: relative, flex 布局 */
-.graph-nodes {
-  position: relative;
-  z-index: 2;
-}
-/* 2-3人单行 */
-.row-layout {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-around;
-}
-/* 4+人多行 */
-.self-row {
-  display: flex;
-  justify-content: center;
-  margin-bottom: 16rpx;
-}
-.others-row {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: center;
-  align-items: flex-start;
-}
-/* 成员节点 */
-.member-node {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin: 0 12rpx;
-  flex-shrink: 0;
-}
-/* 头像 */
-.member-avatar {
-  border-radius: 50%;
-  border: 2rpx solid transparent;
+.family-graph-empty {
   display: flex;
   align-items: center;
   justify-content: center;
-  box-sizing: border-box;
-}
-.member-avatar.avatar-square {
-  border-radius: 16rpx;
-}
-.is-self .member-avatar {
-  border-style: solid;
-}
-.avatar-letter {
-  font-weight: bold;
-  text-align: center;
+  padding: 60rpx 0;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  margin: 10rpx 0;
 }
-/* 昵称 */
-.member-name {
-  font-size: 22rpx;
-  margin-top: 6rpx;
-  max-width: 120rpx;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  text-align: center;
+.empty-text {
+  font-size: 28rpx;
+  color: #94A3B8;
 }
-</style>
+</style>

+ 21 - 8
cfc-frontend/components/HealthDimensionsSection.vue

@@ -10,16 +10,11 @@
       <text class="loading-text">加载中...</text>
     </view>
 
-    <!-- 空数据 -->
-    <view v-else-if="!healthData.dimensions || healthData.dimensions.length === 0" class="empty-state">
-      <text class="empty-tip">暂无健康数据</text>
-    </view>
-
-    <!-- 维度列表 -->
+    <!-- 维度列表(无数据时显示7项默认值,分数为0) -->
     <view v-else class="dimension-grid">
       <view
         class="dimension-item"
-        v-for="dim in healthData.dimensions"
+        v-for="dim in displayDimensions"
         :key="dim.dimension || dim.key"
         @click="goDimensionDetail(dim)">
         <text class="dimension-icon">{{ getDimIcon(dim.dimension || dim.key) }}</text>
@@ -27,7 +22,7 @@
         <view class="dimension-score-bar">
           <view
             class="dimension-score-fill"
-            :style="'width:' + Math.min(100, dim.score || 0) + '%'"></view>
+            :style="'width:' + Math.min(100, (dim.score || 0)) + '%'"></view>
         </view>
         <text class="dimension-score">{{ dim.score || 0 }}分</text>
       </view>
@@ -53,11 +48,29 @@ var DIM_ICONS = {
   family: '\u{1F46A}'
 }
 
+var DEFAULT_DIMENSIONS = [
+  { dimension: 'growth', label: '成长发育', score: 0 },
+  { dimension: 'sleep', label: '睡眠', score: 0 },
+  { dimension: 'vision', label: '视力', score: 0 },
+  { dimension: 'immunity', label: '免疫力', score: 0 },
+  { dimension: 'nutrition', label: '营养', score: 0 },
+  { dimension: 'gut', label: '肠道', score: 0 },
+  { dimension: 'exercise', label: '运动', score: 0 }
+]
+
 export default {
   props: {
     healthData: { type: Object, default: null },
     activeChildId: { type: [Number, String], default: null }
   },
+  computed: {
+    displayDimensions: function() {
+      if (this.healthData && this.healthData.dimensions && this.healthData.dimensions.length > 0) {
+        return this.healthData.dimensions
+      }
+      return DEFAULT_DIMENSIONS
+    }
+  },
   methods: {
     getDimIcon: function(key) {
       return DIM_ICONS[key] || '\u{2B50}'

+ 0 - 0
cfc-frontend/components/FamilyRelationshipSection.vue → cfc-frontend/components/OctopusGraph.vue


+ 24 - 0
cfc-frontend/pages.json

@@ -538,6 +538,18 @@
         }
       ]
     },
+    {
+      "root": "pages/dan-assessment",
+      "pages": [
+        {
+          "path": "report-upload",
+          "style": {
+            "navigationBarTitleText": "DAN 报告上传",
+            "navigationStyle": "custom"
+          }
+        }
+      ]
+    },
     {
       "root": "pages/growth",
       "pages": [
@@ -935,6 +947,18 @@
           "style": {
             "navigationBarTitleText": "冥想"
           }
+        },
+        {
+          "path": "gut-index",
+          "style": {
+            "navigationBarTitleText": "菌群"
+          }
+        },
+        {
+          "path": "gut-flora-detail",
+          "style": {
+            "navigationBarTitleText": "菌群报告详情"
+          }
         }
       ]
     },

+ 13 - 92
cfc-frontend/pages/action/index.vue

@@ -67,35 +67,27 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-<!-- 商品推荐 -->
-    <DimensionProductList
-      v-if="isLoggedIn"
+    <DimensionProducts
       dimensionCode="action"
-      :familyId="activeChildId"
-      title="为你推荐" />
+      :products="dimensionProducts"
+      :isLoggedIn="isLoggedIn"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
 
     <!-- 推荐阅读 -->
-    <ActionArticleRecommend
+    <DimensionArticles
+      dimensionCode="action"
       :articles="actionArticles"
       :isLoggedIn="isLoggedIn"
       @articleClick="goArticleDetail"
       @moreArticles="goMoreArticles" />
 
-    <!-- 珍珠图/圈子(登录后可见) -->
-    <PearlDiagram
-      v-if="isLoggedIn"
-      :circles="myCircles"
-      :isLoggedIn="isLoggedIn"
-      @circleClick="onCircleClick"
-      @discover="goDiscoverCircles" />
-
     <!-- ===== 重要关系维护 + 关系健康概览 ===== -->
-    <FamilyRelationshipSection
+    <OctopusGraph
       :contactList="contactList"
       :healthAlerts="healthAlerts"
       :milestones="milestones"
-      :isLoggedIn="isLoggedIn"
-      @manage="goFamilyMembers" />
+      :isLoggedIn="isLoggedIn" />
 
     <!-- 功能入口(4个:活动、商品、课程) -->
     <view class="func-section" v-if="sectionVisible('func_entries')">
@@ -115,15 +107,6 @@
       @close="onCloseImport"
       @success="onImportSuccess" />
 
-    <!-- 圈子详情弹窗 -->
-    <CircleDetail
-      :visible="showCircleDetail"
-      :circle="selectedCircle"
-      :isMember="isCircleMember"
-      @close="showCircleDetail = false"
-      @join="onCircleJoin"
-      @leave="onCircleLeave" />
-
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
   </view>
@@ -137,18 +120,16 @@ import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
-import ActionArticleRecommend from '../../components/ActionArticleRecommend.vue'
-import PearlDiagram from '../../components/PearlDiagram.vue'
-import CircleDetail from '../../components/CircleDetail.vue'
+import DimensionArticles from '../../components/DimensionArticles.vue'
 import ContactCard from '../../components/ContactCard.vue'
 import ContactImport from '../../components/ContactImport.vue'
-import FamilyRelationshipSection from '../../components/FamilyRelationshipSection.vue'
+import OctopusGraph from '../../components/OctopusGraph.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
-import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles, getHealthAlerts, getMilestones, getMyCircles, discoverCircles, joinCircle, leaveCircle } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles, getHealthAlerts, getMilestones } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, ActionArticleRecommend, PearlDiagram, CircleDetail, ContactCard, ContactImport, FamilyRelationGraph, FamilyRelationshipSection, WuxingSandbox },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, ContactCard, ContactImport, FamilyRelationGraph, OctopusGraph, WuxingSandbox },
   data() {
     return {
       showTabTransition: true,
@@ -168,10 +149,6 @@ export default {
       dimensionProducts: [],
       articles: [],
       contactList: [],
-      myCircles: [],
-      showCircleDetail: false,
-      selectedCircle: null,
-      isCircleMember: false,
       showImportModal: false,
       healthAlerts: [],
       milestones: [],
@@ -232,7 +209,6 @@ export default {
       })
       this.loadFamilyMembersVisible()
       this.loadRelationshipHealth()
-      this.loadMyCircles()
     }
     // 游客也能浏览活动和商品
     this.loadDimensionActivities()
@@ -488,9 +464,6 @@ export default {
     goMoreArticles: function() {
       uni.navigateTo({ url: '/pages/article-center/index?dimension=action' })
     },
-    goFamilyMembers: function() {
-      uni.navigateTo({ url: '/pages/profile/family-members' })
-    },
     loadRelationshipHealth: function() {
       var self = this
       getHealthAlerts({}).then(function(res) {
@@ -536,58 +509,6 @@ export default {
         if (unwatch) unwatch()
         self.showTabTransition = false
       }, 5000)
-    },
-    loadMyCircles: function() {
-      var self = this
-      getMyCircles({}).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.myCircles = res.data
-        }
-      }).catch(function() {
-        self.myCircles = []
-      })
-    },
-    onCircleClick: function(circle) {
-      this.selectedCircle = circle
-      this.isCircleMember = true
-      this.showCircleDetail = true
-    },
-    goDiscoverCircles: function() {
-      var self = this
-      var childId = this.currentChildId || uni.getStorageSync('currentChildId')
-      discoverCircles({ childId: childId }).then(function(res) {
-        if (res.code === 200 && res.data && res.data.length > 0) {
-          self.myCircles = res.data
-        }
-      }).catch(function() {})
-    },
-    onCircleJoin: function(circleId) {
-      var self = this
-      joinCircle({ circleId: circleId }).then(function(res) {
-        if (res.code === 200) {
-          uni.showToast({ title: '加入成功', icon: 'success' })
-          self.showCircleDetail = false
-          self.loadMyCircles()
-        }
-      }).catch(function() {})
-    },
-    onCircleLeave: function(circleId) {
-      var self = this
-      uni.showModal({
-        title: '退出圈子',
-        content: '确定退出该圈子吗?',
-        success: function(res) {
-          if (res.confirm) {
-            leaveCircle({ circleId: circleId }).then(function(res2) {
-              if (res2.code === 200) {
-                uni.showToast({ title: '已退出', icon: 'success' })
-                self.showCircleDetail = false
-                self.loadMyCircles()
-              }
-            }).catch(function() {})
-          }
-        }
-      })
     }
   }
 }

+ 1 - 1
cfc-frontend/pages/body/index.vue

@@ -76,7 +76,7 @@
 
     <!-- 七维健康全景(嵌入卡片) -->
     <HealthDimensionsSection
-      v-if="isLoggedIn && dimensionData && dimensionData.dimensions"
+      v-if="isLoggedIn && dimensionData"
       :healthData="dimensionData"
       :activeChildId="activeChildId" />
 

+ 505 - 0
cfc-frontend/pages/dan-assessment/report-upload.vue

@@ -0,0 +1,505 @@
+<template>
+  <view class="page">
+    <view class="header">
+      <view class="back-btn" @click="goBack">
+        <text class="back-icon">&#x2190;</text>
+        <text class="back-text">返回</text>
+      </view>
+      <text class="header-title">DAN 报告上传</text>
+    </view>
+
+    <!-- Step 1: 选择维度 + 选择文件 -->
+    <view class="section" v-if="step === 1">
+      <view class="dimension-select">
+        <text class="dimension-label">报告类型</text>
+        <view class="dimension-options">
+          <view
+            :class="['dimension-option', dimension === 'mind' ? 'active' : '']"
+            @click="dimension = 'mind'">
+            <text class="dimension-name">&#x2764; 心维度 (A2)</text>
+          </view>
+          <view
+            :class="['dimension-option', dimension === 'wisdom' ? 'active' : '']"
+            @click="dimension = 'wisdom'">
+            <text class="dimension-name">&#x1F9E0; 智维度 (B4)</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="upload-area" @click="chooseFile">
+        <view class="upload-icon" v-if="!selectedFile">&#x1F4C4;</view>
+        <view class="upload-icon" v-else>&#x2705;</view>
+        <text class="upload-text" v-if="!selectedFile">点击上传 PDF 文件</text>
+        <text class="upload-text" v-else>{{ selectedFile.name }}</text>
+        <text class="upload-hint">支持 PDF 格式的 DAN 测评报告</text>
+      </view>
+
+      <view class="action-bar">
+        <view class="upload-btn" @click="startUpload" v-if="selectedFile">
+          <text class="upload-btn-text">上传并解析</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- Step 2: 预览解析结果 -->
+    <view class="section" v-if="step === 2">
+      <view class="preview-header">
+        <text class="preview-title">解析预览</text>
+        <text class="preview-desc">请确认以下数据是否准确</text>
+      </view>
+
+      <view class="preview-card" v-if="previewData">
+        <view class="preview-row">
+          <text class="preview-label">维度</text>
+          <text class="preview-value">{{ dimension === 'mind' ? '心维度 (A2)' : '智维度 (B4)' }}</text>
+        </view>
+        <view class="preview-row">
+          <text class="preview-label">解析概要</text>
+          <text class="preview-value">{{ previewData.summary || '无' }}</text>
+        </view>
+      </view>
+
+      <!-- 解析项列表 -->
+      <view class="items-section" v-if="previewData && previewData.items && previewData.items.length > 0">
+        <text class="items-title">数据项 ({{ previewData.items.length }})</text>
+        <view class="item-card" v-for="(item, idx) in previewData.items" :key="idx">
+          <view class="item-header">
+            <text class="item-name">{{ item.name || item.code }}</text>
+            <text class="item-category">{{ item.category }}</text>
+          </view>
+          <view class="item-value-bar">
+            <view class="item-value-fill" :style="'width:' + item.value + '%'"></view>
+          </view>
+          <text class="item-value-text">{{ item.value }}/100</text>
+        </view>
+      </view>
+
+      <!-- 建议 -->
+      <view class="suggestions-card" v-if="previewData && previewData.suggestions">
+        <text class="suggestions-title">成长建议</text>
+        <text class="suggestions-text">{{ previewData.suggestions }}</text>
+      </view>
+
+      <view class="action-bar">
+        <view class="confirm-btn" @click="doConfirm" v-if="!confirming">
+          <text class="confirm-btn-text">确认并保存</text>
+        </view>
+        <view class="confirm-btn disabled" v-else>
+          <text class="confirm-btn-text">保存中...</text>
+        </view>
+        <view class="retry-btn" @click="resetUpload">
+          <text class="retry-btn-text">重新上传</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- Step 3: 完成 -->
+    <view class="section" v-if="step === 3">
+      <view class="success-card">
+        <text class="success-icon">&#x2705;</text>
+        <text class="success-title">报告已保存</text>
+        <text class="success-desc">DAN 测评数据已更新至五维能量体系</text>
+      </view>
+      <view class="action-bar">
+        <view class="upload-btn" @click="goBack">
+          <text class="upload-btn-text">返回</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- Loading -->
+    <view class="loading-overlay" v-if="loading">
+      <view class="loading-box">
+        <text class="loading-text">解析中...</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { danParsePreview, danConfirmReport } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      step: 1,
+      dimension: 'mind',
+      selectedFile: null,
+      previewData: null,
+      draftId: null,
+      loading: false,
+      confirming: false
+    }
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    },
+    chooseFile: function() {
+      var self = this
+      uni.chooseMessageFile({
+        count: 1,
+        type: 'file',
+        extension: ['pdf'],
+        success: function(res) {
+          if (res.tempFiles && res.tempFiles.length > 0) {
+            self.selectedFile = res.tempFiles[0]
+          }
+        },
+        fail: function() {
+          // 降级:使用相册/相机
+          uni.chooseImage({
+            count: 1,
+            success: function(res) {
+              if (res.tempFiles && res.tempFiles.length > 0) {
+                self.selectedFile = res.tempFiles[0]
+              }
+            }
+          })
+        }
+      })
+    },
+    startUpload: function() {
+      if (!this.selectedFile) return
+      var self = this
+      self.loading = true
+
+      var memberId = this.$store.state && this.$store.state.currentChildId
+          ? this.$store.state.currentChildId
+          : uni.getStorageSync('currentChildId')
+
+      danParsePreview(self.selectedFile.path || self.selectedFile.tempFilePath, self.dimension, memberId)
+        .then(function(res) {
+          self.loading = false
+          if (res.code === 200 && res.data) {
+            self.previewData = res.data
+            self.draftId = res.data.draftId
+            self.step = 2
+          } else {
+            uni.showToast({ title: res.message || '解析失败', icon: 'none' })
+          }
+        })
+        .catch(function(err) {
+          self.loading = false
+          uni.showToast({ title: err && err.message ? err.message : '上传失败', icon: 'none' })
+        })
+    },
+    doConfirm: function() {
+      var self = this
+      self.confirming = true
+
+      var memberId = this.$store.state && this.$store.state.currentChildId
+          ? this.$store.state.currentChildId
+          : uni.getStorageSync('currentChildId')
+
+      danConfirmReport({
+        draftId: self.draftId,
+        dimension: self.dimension,
+        memberId: memberId
+      })
+        .then(function(res) {
+          self.confirming = false
+          if (res.code === 200) {
+            self.step = 3
+          } else {
+            uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+          }
+        })
+        .catch(function(err) {
+          self.confirming = false
+          uni.showToast({ title: '保存失败', icon: 'none' })
+        })
+    },
+    resetUpload: function() {
+      this.step = 1
+      this.selectedFile = null
+      this.previewData = null
+      this.draftId = null
+    }
+  }
+}
+</script>
+
+<style>
+.page {
+  min-height: 100vh;
+  background: #F5F5F7;
+  padding-bottom: 40rpx;
+}
+.header {
+  display: flex;
+  align-items: center;
+  padding: 88rpx 32rpx 24rpx;
+  background: linear-gradient(135deg, #667EEA, #764BA2);
+}
+.back-btn {
+  display: flex;
+  align-items: center;
+  margin-right: 24rpx;
+  padding: 8rpx 16rpx;
+}
+.back-icon {
+  font-size: 36rpx;
+  color: #fff;
+  margin-right: 8rpx;
+}
+.back-text {
+  font-size: 28rpx;
+  color: #fff;
+}
+.header-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #fff;
+}
+.section {
+  margin: 24rpx 24rpx 0;
+}
+.dimension-select {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 32rpx;
+}
+.dimension-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 20rpx;
+  display: block;
+}
+.dimension-options {
+  display: flex;
+  gap: 20rpx;
+}
+.dimension-option {
+  flex: 1;
+  padding: 24rpx;
+  border-radius: 12rpx;
+  background: #F5F5F7;
+  text-align: center;
+  border: 2rpx solid transparent;
+}
+.dimension-option.active {
+  background: #EEF2FF;
+  border-color: #6366F1;
+}
+.dimension-name {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+.dimension-option.active .dimension-name {
+  color: #6366F1;
+}
+.upload-area {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 60rpx 32rpx;
+  text-align: center;
+  margin-top: 24rpx;
+  border: 2rpx dashed #D1D5DB;
+}
+.upload-icon {
+  font-size: 64rpx;
+  margin-bottom: 16rpx;
+}
+.upload-text {
+  font-size: 30rpx;
+  color: #333;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.upload-hint {
+  font-size: 24rpx;
+  color: #999;
+}
+.action-bar {
+  margin-top: 32rpx;
+  display: flex;
+  gap: 20rpx;
+}
+.upload-btn {
+  flex: 1;
+  background: #6366F1;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  text-align: center;
+}
+.upload-btn-text {
+  font-size: 30rpx;
+  color: #fff;
+  font-weight: 600;
+}
+.preview-header {
+  margin-bottom: 24rpx;
+}
+.preview-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #333;
+  display: block;
+}
+.preview-desc {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.preview-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.preview-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.preview-row:last-child {
+  border-bottom: none;
+}
+.preview-label {
+  font-size: 26rpx;
+  color: #666;
+}
+.preview-value {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+.items-section {
+  margin-top: 24rpx;
+}
+.items-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.item-card {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  margin-bottom: 12rpx;
+}
+.item-header {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.item-name {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+.item-category {
+  font-size: 22rpx;
+  color: #999;
+  background: #F3F4F6;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+.item-value-bar {
+  height: 16rpx;
+  background: #F3F4F6;
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+.item-value-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #667EEA, #764BA2);
+  border-radius: 8rpx;
+}
+.item-value-text {
+  font-size: 24rpx;
+  color: #666;
+  margin-top: 8rpx;
+  display: block;
+  text-align: right;
+}
+.suggestions-card {
+  background: #FFFBEB;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-top: 24rpx;
+}
+.suggestions-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #92400E;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.suggestions-text {
+  font-size: 26rpx;
+  color: #78350F;
+  line-height: 1.6;
+}
+.confirm-btn {
+  flex: 1;
+  background: #10B981;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  text-align: center;
+}
+.confirm-btn.disabled {
+  opacity: 0.6;
+}
+.confirm-btn-text {
+  font-size: 30rpx;
+  color: #fff;
+  font-weight: 600;
+}
+.retry-btn {
+  padding: 24rpx 32rpx;
+  border-radius: 12rpx;
+  border: 2rpx solid #D1D5DB;
+  text-align: center;
+}
+.retry-btn-text {
+  font-size: 28rpx;
+  color: #666;
+}
+.success-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 60rpx 32rpx;
+  text-align: center;
+  margin-top: 120rpx;
+}
+.success-icon {
+  font-size: 80rpx;
+  display: block;
+  margin-bottom: 24rpx;
+}
+.success-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #10B981;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.success-desc {
+  font-size: 28rpx;
+  color: #666;
+}
+.loading-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+}
+.loading-box {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 48rpx 64rpx;
+}
+.loading-text {
+  font-size: 30rpx;
+  color: #333;
+}
+</style>

+ 7 - 6
cfc-frontend/pages/health/report-upload.vue

@@ -231,12 +231,8 @@ export default {
     if (options.reportType && ['physical_exam', 'gut_flora', 'tongue'].indexOf(options.reportType) !== -1) {
       this.form.reportType = options.reportType
     }
-    // 默认设为今天
-    var now = new Date()
-    var y = now.getFullYear()
-    var m = ('' + (now.getMonth() + 1)).padStart(2, '0')
-    var d = ('' + now.getDate()).padStart(2, '0')
-    this.form.reportDate = y + '-' + m + '-' + d
+    // 不默认填充日期 — 待解析后从报告中提取
+    this.form.reportDate = ''
   },
   onShow: function() {
     // 每次显示时从store获取familyId和家庭成员
@@ -390,6 +386,10 @@ export default {
           self.showMemberSelector = true
           self.loadFamilyMembers()
         }
+        // 从解析结果自动填充报告日期
+        if (detail.extractedReportDate) {
+          self.form.reportDate = detail.extractedReportDate
+        }
       }).catch(function(e) {
         var msg = (e && e.message) || '解析失败'
         uni.showToast({ title: msg, icon: 'none' })
@@ -467,6 +467,7 @@ export default {
           + '&matchConfidence=' + (self.parsedInfo.confidence || '')
           + '&matchLabel=' + encodeURIComponent(self.parsedInfo.matchLabel || '')
           + '&candidates=' + candidatesStr
+          + '&extractedReportDate=' + encodeURIComponent(self.parsedInfo.reportDate || '')
         if (self.parsedInfo.matchedMemberId) {
           params += '&matchedMemberId=' + self.parsedInfo.matchedMemberId
         }

+ 43 - 19
cfc-frontend/pages/index/index.vue

@@ -131,27 +131,44 @@
         <text class="section-title">📖 推荐阅读</text>
         <text class="section-more" @click="goArticles">更多 ›</text>
       </view>
-      <view v-if="articlesLoading" class="loading-state">
-        <text class="loading-text">加载中...</text>
-      </view>
-      <view v-else-if="indexArticles.length === 0" class="empty-state" style="padding: 40rpx 0;">
-        <text class="empty-text">暂无文章</text>
-      </view>
-      <view class="article-list" v-else>
-        <view class="article-card" v-for="article in indexArticles" :key="article.id" @click="goArticleDetail(article.id)">
-          <view class="article-category" :style="{ background: article.categoryColor }">
-            <text class="article-category-text">{{ article.category }}</text>
+      <view class="article-list">
+        <view v-if="articlesLoading" class="loading-state" style="padding: 40rpx 0;">
+          <text class="loading-text">加载中...</text>
+        </view>
+        <!-- 无数据时显示占位卡片 -->
+        <template v-else-if="indexArticles.length === 0">
+          <view class="article-card" v-for="i in 3" :key="'ph-' + i">
+            <view class="article-category" :style="{ background: gradientColors[(i-1) % gradientColors.length] }">
+              <text class="article-category-text">推荐文章</text>
+            </view>
+            <text class="article-title">暂无推荐文章</text>
+            <text class="article-summary">精彩内容即将上线,敬请期待</text>
+            <view class="article-meta">
+              <view class="meta-item">
+                <text class="meta-icon">&#x1F4D6;</text>
+                <text class="meta-text">0</text>
+              </view>
+              <text class="article-date">--</text>
+            </view>
           </view>
-          <text class="article-title">{{ article.title }}</text>
-          <text class="article-summary">{{ article.summary }}</text>
-          <view class="article-meta">
-            <view class="meta-item">
-              <text class="meta-icon">&#x1F4D6;</text>
-              <text class="meta-text">{{ article.views }}</text>
+        </template>
+        <!-- 有数据时显示文章卡片 -->
+        <template v-else>
+          <view class="article-card" v-for="article in indexArticles" :key="article.id" @click="goArticleDetail(article.id)">
+            <view class="article-category" :style="{ background: article.categoryColor }">
+              <text class="article-category-text">{{ article.category }}</text>
+            </view>
+            <text class="article-title">{{ article.title }}</text>
+            <text class="article-summary">{{ article.summary }}</text>
+            <view class="article-meta">
+              <view class="meta-item">
+                <text class="meta-icon">&#x1F4D6;</text>
+                <text class="meta-text">{{ article.views }}</text>
+              </view>
+              <text class="article-date">{{ article.date }}</text>
             </view>
-            <text class="article-date">{{ article.date }}</text>
           </view>
-        </view>
+        </template>
       </view>
     </view>
 
@@ -268,7 +285,14 @@ export default {
       loading: false,
       dimensionActivities: [],
       indexArticles: [],
-      articlesLoading: false
+      articlesLoading: false,
+      gradientColors: [
+        'linear-gradient(135deg, #10B981, #34D399)',
+        'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
+        'linear-gradient(135deg, #8B5CF6, #C084FC)',
+        'linear-gradient(135deg, #F97316, #FB923C)',
+        'linear-gradient(135deg, #6366F1, #818CF8)'
+      ]
     }
   },
   onLoad(options) {

+ 175 - 11
cfc-frontend/pages/index/parent-index.vue

@@ -248,6 +248,54 @@
         <text class="error-state-text">商品推荐加载失败</text>
       </view>
 
+      <!-- ===== 推荐阅读 ===== -->
+      <view class="article-section animate-fade-in animate-stagger-12">
+        <view class="article-header">
+          <text class="section-title">📖 推荐阅读</text>
+          <text class="article-more" @click="goArticles">更多 ›</text>
+        </view>
+        <view class="article-list">
+          <!-- 加载中 -->
+          <view v-if="articlesLoading" class="loading-state" style="padding: 40rpx 0;">
+            <text class="loading-text">加载中...</text>
+          </view>
+          <!-- 无数据时显示占位卡片 -->
+          <template v-else-if="indexArticles.length === 0">
+            <view class="article-card" v-for="i in 3" :key="'ph-' + i">
+              <view class="article-category" :style="{ background: gradientColors[(i-1) % gradientColors.length] }">
+                <text class="article-category-text">推荐文章</text>
+              </view>
+              <text class="article-title">暂无推荐文章</text>
+              <text class="article-summary">精彩内容即将上线,敬请期待</text>
+              <view class="article-meta">
+                <view class="meta-item">
+                  <text class="meta-icon">📖</text>
+                  <text class="meta-text">0</text>
+                </view>
+                <text class="article-date">--</text>
+              </view>
+            </view>
+          </template>
+          <!-- 有数据时显示文章卡片 -->
+          <template v-else>
+            <view class="article-card" v-for="article in indexArticles" :key="article.id" @click="goArticleDetail(article.id)">
+              <view class="article-category" :style="{ background: article.categoryColor }">
+                <text class="article-category-text">{{ article.category }}</text>
+              </view>
+              <text class="article-title">{{ article.title }}</text>
+              <text class="article-summary">{{ article.summary }}</text>
+              <view class="article-meta">
+                <view class="meta-item">
+                  <text class="meta-icon">📖</text>
+                  <text class="meta-text">{{ article.views }}</text>
+                </view>
+                <text class="article-date">{{ article.date }}</text>
+              </view>
+            </view>
+          </template>
+        </view>
+      </view>
+
       <!-- ===== h) 待审核任务 ===== -->
       <view class="review-section animate-fade-in animate-stagger-11" v-if="pendingReviewTasks.length > 0">
         <view class="section-title">待审核任务</view>
@@ -293,7 +341,7 @@
               <view class="action-icon act-child">
                 <text class="action-icon-text">👨‍👩‍👧</text>
               </view>
-              <text class="action-label">孩子管理</text>
+              <text class="action-label">成员管理</text>
             </view>
             <view class="action-item" @click="goToRewards">
               <view class="action-icon act-reward">
@@ -301,12 +349,6 @@
               </view>
               <text class="action-label">奖励中心</text>
             </view>
-            <view class="action-item" @click="inviteFriend">
-              <view class="action-icon act-invite">
-                <text class="action-icon-text">📤</text>
-              </view>
-              <text class="action-label">邀请家人</text>
-            </view>
           </view>
         </PlayfulCard>
       </view>
@@ -315,7 +357,7 @@
 </template>
 
 <script>
-import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList } from '../../utils/api.js'
+import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles } from '../../utils/api.js'
 import PageBanner from '../../components/PageBanner.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -367,7 +409,17 @@ export default {
       errorActivities: false,
       errorProducts: false,
       contactList: [],
-      showImportModal: false
+      showImportModal: false,
+      // 推荐阅读
+      indexArticles: [],
+      articlesLoading: false,
+      gradientColors: [
+        'linear-gradient(135deg, #10B981, #34D399)',
+        'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
+        'linear-gradient(135deg, #8B5CF6, #C084FC)',
+        'linear-gradient(135deg, #F97316, #FB923C)',
+        'linear-gradient(135deg, #6366F1, #818CF8)'
+      ]
     }
   },
   computed: {
@@ -629,6 +681,8 @@ export default {
         this.loadFamilyMembersVisible()
         // 加载外部联系人
         this.loadContacts()
+        // 加载推荐阅读
+        this.loadFeaturedArticles()
 
         // 加载五维能量概览(账本模式)
         var overviewChildId = this.currentChildId
@@ -747,8 +801,7 @@ export default {
       })
     },
     goToTaskList() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
-    manageChildren() { uni.navigateTo({ url: '/pages/profile/children' }) },
-    inviteFriend() { uni.navigateTo({ url: '/pages/parent/invite/index' }) },
+    manageChildren() { uni.navigateTo({ url: '/pages/profile/family-members' }) },
 
     loadContacts: function() {
       var self = this
@@ -772,6 +825,37 @@ export default {
     onImportSuccess: function() {
       this.showImportModal = false
       this.loadContacts()
+    },
+    // ===== 推荐阅读 =====
+    loadFeaturedArticles: function() {
+      var self = this
+      this.articlesLoading = true
+      getFeaturedArticles({ size: 5 }).then(function(res) {
+        self.articlesLoading = false
+        if (res && res.code === 200) {
+          var rawList = res.data && (Array.isArray(res.data) ? res.data : (res.data.data || res.data.records || []))
+          var list = Array.isArray(rawList) ? rawList : []
+          self.indexArticles = list.map(function(item, index) {
+            return {
+              id: item.id,
+              category: item.categoryName || item.category || '热门文章',
+              categoryColor: self.gradientColors[index % self.gradientColors.length],
+              title: item.title || '',
+              summary: item.summary || '',
+              views: item.viewCount ? String(item.viewCount) : '0',
+              date: item.publishedAt ? item.publishedAt.slice(0, 10) : ''
+            }
+          })
+        }
+      }).catch(function() {
+        self.articlesLoading = false
+      })
+    },
+    goArticles: function() {
+      uni.navigateTo({ url: '/pages/mind-detail/articles' })
+    },
+    goArticleDetail: function(id) {
+      uni.navigateTo({ url: '/pages/mind-detail/article-detail?id=' + id })
     }
   }
 }
@@ -1293,4 +1377,84 @@ export default {
   font-size: 26rpx;
   color: #ccc;
 }
+
+/* ===== 推荐阅读 ===== */
+.article-section {
+  margin-bottom: 48rpx;
+}
+.article-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: var(--space-md);
+}
+.article-more {
+  font-size: 26rpx;
+  color: var(--color-primary, #F97316);
+}
+.article-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.article-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.article-card:active {
+  opacity: 0.8;
+}
+.article-category {
+  display: inline-block;
+  padding: 4rpx 14rpx;
+  border-radius: 8rpx;
+  margin-bottom: 10rpx;
+}
+.article-category-text {
+  font-size: 20rpx;
+  color: #fff;
+}
+.article-title {
+  display: block;
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 8rpx;
+  line-height: 1.4;
+}
+.article-summary {
+  display: block;
+  font-size: 24rpx;
+  color: #888;
+  line-height: 1.5;
+  margin-bottom: 12rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+.article-meta {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.meta-item {
+  display: flex;
+  align-items: center;
+  gap: 4rpx;
+}
+.meta-icon {
+  font-size: 24rpx;
+}
+.meta-text {
+  font-size: 22rpx;
+  color: #999;
+}
+.article-date {
+  font-size: 22rpx;
+  color: #bbb;
+}
 </style>

+ 175 - 20
cfc-frontend/pages/mind/index.vue

@@ -43,7 +43,14 @@
       @memberTap="goMemberDetail" />
 
     <!-- 登录后:用户快捷入口 -->
-    
+    <UserQuickEntry
+      v-if="isLoggedIn"
+      :userName="userName"
+      roleName="家长"
+      :currentChildName="currentChildName"
+      :children="children"
+      @childChanged="onChildChanged"
+      @scrollTo="scrollToSection" />
 
     <!-- ===== 心理能量概况 ===== -->
     <view class="energy-overview" v-if="emiData">
@@ -231,6 +238,26 @@
       </view>
     </view>
 
+    <!-- ===== DAN 报告(A2-心维度) ===== -->
+    <view class="section" v-if="isLoggedIn && danReports.length > 0">
+      <view class="section-header">
+        <text class="section-title">&#x1F4CA; DAN 报告</text>
+        <text class="section-more" @click="goDanUpload('mind')">上传 +</text>
+      </view>
+      <view class="dan-card" v-for="report in danReports" :key="report.id">
+        <view class="dan-card-header">
+          <text class="dan-report-date">{{ formatDate(report.assessmentDate) }}</text>
+          <text :class="['dan-status', report.draftStatus === 'confirmed' ? 'confirmed' : 'pending']">
+            {{ report.draftStatus === 'confirmed' ? '已确认' : '待确认' }}
+          </text>
+        </view>
+        <text class="dan-report-desc">DAN A2 心维度测评报告</text>
+        <view class="dan-card-footer" @click="viewDanReport(report)">
+          <text class="dan-view-link">查看详情 &rarr;</text>
+        </view>
+      </view>
+    </view>
+
     <!-- ===== 底部:维度任务/活动/商品 ===== -->
     <DimensionTasks
       v-if="isLoggedIn"
@@ -246,12 +273,11 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <!-- 商品推荐 -->
-    <DimensionProductList
-      v-if="isLoggedIn"
-      dimensionCode="mind"
-      :familyId="activeChildId"
-      title="为你推荐" />
+    <DimensionProducts
+      :dimensionCode="currentDimensionCode"
+      :products="dimensionProducts"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
 
     <!-- ===== Phase 1.2: 心理告警横幅 ===== -->
     <PsychCrisisBanner v-if="isLoggedIn" :childId="currentChildId" source="screening" />
@@ -278,8 +304,6 @@
       </view>
     </view>
 
-    
-
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <AIFloatingAvatar />
@@ -292,7 +316,7 @@ import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-
+import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -301,11 +325,11 @@ import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 
 import PsychCrisisBanner from '../../components/PsychCrisisBanner.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
-import { getVisibleSections, getFeaturedArticles, getEmiReport, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers, getEnergyOverview } from '../../utils/api.js'
+import { getVisibleSections, getFeaturedArticles, getEmiReport, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers, getEnergyOverview, getContactList, getHealthAlerts, getMilestones, getDanReportByChild } from '../../utils/api.js'
 import config from '../../config.js'
 
 export default {
-  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner, AIFloatingAvatar },
+  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner, AIFloatingAvatar },
   data() {
     return {
       isLoggedIn: false,
@@ -371,6 +395,10 @@ export default {
       dimensionTasks: [],
       dimensionActivities: [],
       dimensionProducts: [],
+      contactList: [],
+      healthAlerts: [],
+      milestones: [],
+      danReports: []
     }
   },
   computed: {
@@ -546,7 +574,8 @@ export default {
     this.isLoggedIn = !!token
     if (this.isLoggedIn) {
       this.role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || null
-      this.currentChildId = uni.getStorageSync('currentChildId') || null
+      // FIX: 从 Vuex store 读取 currentChildId,而非仅从 localStorage
+      this.currentChildId = (this.$store.state && this.$store.state.currentChildId) || uni.getStorageSync('currentChildId') || null
       this.userName = uni.getStorageSync('nickname') || uni.getStorageSync('userName') || '家长'
     }
     this.loadSectionConfig()
@@ -559,6 +588,10 @@ export default {
       this.loadGutData()
       this.loadEnergyData()
       this.loadTraditionalCultureData()
+      this.loadContacts()
+      this.loadHealthAlerts()
+      this.loadMilestones()
+      this.loadDanReports()
     }
     // 游客也能浏览活动和商品
     this.loadDimensionActivities()
@@ -671,7 +704,7 @@ export default {
     },
     loadFeaturedArticles: function() {
       var self = this
-      getFeaturedArticles({ size: 5 }).then(function(res) {
+      getFeaturedArticles({ size: 5, dimensionCode: 'mind' }).then(function(res) {
         if (res.code === 200 && res.data) {
           var gradientColors = [
             'linear-gradient(135deg, #8B5CF6, #A78BFA)',
@@ -750,12 +783,10 @@ export default {
               break
             }
           }
-          if (!found && self.children.length > 0) {
-            self.currentChildId = self.children[0].childId
-            uni.setStorageSync('currentChildId', self.currentChildId)
-          }
-          if (self.currentChildId) {
+          // FIX: 不再自动选择第一个孩子。仅在用户通过 UserQuickEntry 切换时设置 currentChildId
+          if (found) {
             self.loadDimensionData()
+            self.loadEmiData()
           }
         }
       }).catch(function(e) {
@@ -827,7 +858,8 @@ export default {
     },
     onChildChanged: function(child) {
       this.currentChildId = child.childId
-      uni.setStorageSync('currentChildId', child.childId)
+      // FIX: 同步提交到 Vuex store
+      this.$store.commit('switchToChild', child.childId)
       this.loadDimensionData()
       this.loadEmiData()
       this.loadTraditionalCultureData()
@@ -896,6 +928,44 @@ export default {
     goAssessment: function() { uni.navigateTo({ url: '/pages/assessment/apply' }) },
     goLogin: function() { uni.navigateTo({ url: '/pages/login/login' }) },
 
+    // ===== 传统文化数据 =====
+    // ===== 联系人 / 关系健康 =====
+    loadContacts: function() {
+      var self = this
+      getContactList({}).then(function(res) {
+        if (res.code === 200) {
+          self.contactList = Array.isArray(res.data) ? res.data : []
+        } else {
+          self.contactList = []
+        }
+      }).catch(function() {
+        self.contactList = []
+      })
+    },
+    loadHealthAlerts: function() {
+      var self = this
+      getHealthAlerts({}).then(function(res) {
+        if (res.code === 200) {
+          self.healthAlerts = Array.isArray(res.data) ? res.data : []
+        } else {
+          self.healthAlerts = []
+        }
+      }).catch(function() {
+        self.healthAlerts = []
+      })
+    },
+    loadMilestones: function() {
+      var self = this
+      getMilestones({}, 30).then(function(res) {
+        if (res.code === 200) {
+          self.milestones = Array.isArray(res.data) ? res.data : []
+        } else {
+          self.milestones = []
+        }
+      }).catch(function() {
+        self.milestones = []
+      })
+    },
     loadTraditionalCultureData: function() {
       var self = this
       var memberId = this.currentChildId
@@ -944,6 +1014,29 @@ export default {
         if (unwatch) unwatch()
         self.showTabTransition = false
       }, 5000)
+    },
+
+    // ===== DAN 报告 =====
+    loadDanReports: function() {
+      var self = this
+      var memberId = self.currentChildId
+      if (!memberId) return
+      getDanReportByChild(memberId, 'mind').then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.danReports = res.data
+        }
+      }).catch(function() {})
+    },
+    goDanUpload: function(dimension) {
+      uni.navigateTo({ url: '/pages/dan-assessment/report-upload' })
+    },
+    viewDanReport: function(report) {
+      uni.navigateTo({ url: '/pages/dan-assessment/report-upload?draftId=' + report.id })
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
     }
   }
 }
@@ -1673,4 +1766,66 @@ export default {
 .ki-footer:active {
   opacity: 0.7;
 }
+
+/* DAN 报告卡片 */
+.dan-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+}
+.dan-card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.dan-report-date {
+  font-size: 26rpx;
+  color: #666;
+}
+.dan-status {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 8rpx;
+}
+.dan-status.confirmed {
+  background: #D1FAE5;
+  color: #065F46;
+}
+.dan-status.pending {
+  background: #FEF3C7;
+  color: #92400E;
+}
+.dan-report-desc {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.dan-card-footer {
+  text-align: right;
+}
+.dan-view-link {
+  font-size: 26rpx;
+  color: #6366F1;
+}
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.section-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #333;
+}
+.section-more {
+  font-size: 26rpx;
+  color: #6366F1;
+  padding: 8rpx 16rpx;
+}
 </style>

+ 166 - 67
cfc-frontend/pages/wisdom/index.vue

@@ -40,24 +40,9 @@
         fillColor="#6366F1"
         gridColor="#E0E7FF"
         labelColor="#3730A3" />
-        </view>
-
-        <!-- 6. 本月阅读卡片(登录后) -->
-        <view class="section" v-if="isLoggedIn">
-          <view class="reading-stats-card" @click="goReadingDetail">
-            <view class="rsc-header">
-              <text class="rsc-title">📚 本月阅读</text>
-            </view>
-            <view class="rsc-body">
-              <text class="rsc-stat-value" v-if="readingStats && readingStats.totalDurationSeconds > 0">
-                {{ (readingStats.totalDurationSeconds / 3600).toFixed(1) }} 小时
-              </text>
-              <text class="rsc-stat-value" v-else-if="readingStats">暂无阅读记录</text>
-            </view>
-          </view>
-        </view>
+    </view>
 
-        <!-- 5. 家庭成员关系图谱(登录后) -->
+    <!-- 5. 家庭成员关系图谱(登录后) -->
     <FamilyRelationGraph
       v-if="isLoggedIn && familyMembersVisible.length > 0"
       dimensionCode="wisdom"
@@ -67,7 +52,15 @@
       :intimacyMap="intimacyMapForGraph"
       @memberTap="goMemberDetail" />
 
-    
+    <!-- 6. 用户快捷入口 -->
+    <UserQuickEntry
+      v-if="isLoggedIn"
+      :userName="userName"
+      roleName="家长"
+      :currentChildName="activeChildName"
+      :children="children"
+      @childChanged="onChildChanged"
+      @scrollTo="scrollToSection" />
 
     <!-- 7. 功能入口 -->
     <view class="func-section" v-if="sectionVisible('func_entries')">
@@ -97,12 +90,12 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <!-- 10. 商品推荐 -->
-    <DimensionProductList
-      v-if="isLoggedIn"
+    <!-- 10. 维度商品 -->
+    <DimensionProducts
       dimensionCode="wisdom"
-      :familyId="activeChildId"
-      title="为你推荐" />
+      :products="dimensionProducts"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
 
     <!-- 11. 推荐阅读 -->
     <DimensionArticles
@@ -126,6 +119,26 @@
       </view>
     </view>
 
+    <!-- ===== DAN 报告(B4-智维度) ===== -->
+    <view class="section" v-if="isLoggedIn && danReports.length > 0">
+      <view class="section-header">
+        <text class="section-title">&#x1F4CA; DAN 报告</text>
+        <text class="section-more" @click="goDanUpload('wisdom')">上传 +</text>
+      </view>
+      <view class="dan-card" v-for="report in danReports" :key="report.id">
+        <view class="dan-card-header">
+          <text class="dan-report-date">{{ formatDate(report.assessmentDate) }}</text>
+          <text :class="['dan-status', report.draftStatus === 'confirmed' ? 'confirmed' : 'pending']">
+            {{ report.draftStatus === 'confirmed' ? '已确认' : '待确认' }}
+          </text>
+        </view>
+        <text class="dan-report-desc">DAN B4 智维度测评报告</text>
+        <view class="dan-card-footer" @click="viewDanReport(report)">
+          <text class="dan-view-link">查看详情 &rarr;</text>
+        </view>
+      </view>
+    </view>
+
     <!-- 13. 快捷操作按钮 -->
     <view class="section quick-actions" v-if="isLoggedIn">
       <view class="action-btn" @click="goCognitiveReport">
@@ -146,8 +159,6 @@
       </view>
     </view>
 
-    
-
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <AIFloatingAvatar />
@@ -160,18 +171,17 @@ import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-
+import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionArticles from '../../components/DimensionArticles.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
-
-import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles, getReadingStats } from '../../utils/api.js'
+import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles, getContactList, getHealthAlerts, getMilestones, getDanReportByChild } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, AIFloatingAvatar },
+  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, AIFloatingAvatar },
   data() {
     return {
       isLoggedIn: false,
@@ -186,7 +196,6 @@ export default {
       dualDimension: null,
       visibleSections: [],
       cognitiveReport: null,
-      readingStats: null,
       dimensionTasks: [],
       dimensionActivities: [],
       dimensionProducts: [],
@@ -194,6 +203,10 @@ export default {
       wisdomTips: [],
       /** Mock 平均值(6维认知):感知/专注/记忆/逻辑/空间/加工速度 */
       cognitiveAvgScores: [70, 65, 72, 68, 66, 71],
+      contactList: [],
+      healthAlerts: [],
+      milestones: [],
+      danReports: [],
       funcList: [
         { icon: '\u{1F4CA}', label: '认知报告', needLogin: true, page: '/pages/wisdom-detail/cognitive-report' },
         { icon: '\u{270F}\uFE0F', label: '自己出题', needLogin: true, page: '/pages/wisdom-detail/self-quiz' },
@@ -268,7 +281,8 @@ export default {
       this.loadChildren()
       this.loadFamilyMembersVisible()
       this.loadCognitiveReport()
-      this.loadReadingStats()
+      this.loadRelationshipHealth()
+      this.loadDanReports()
     }
     this.loadDimensionActivities()
     this.loadDimensionProducts()
@@ -312,15 +326,14 @@ export default {
                 }
               }
             }
-            if (!self.activeChildId) {
-              self.activeChildId = self.children[0].id
-              self.activeChildName = self.children[0].nickname || self.children[0].name || ''
-              uni.setStorageSync('currentChildId', self.activeChildId)
-            }
+            // FIX: 不再自动选择第一个孩子
+          }
+          // Load cognitive report only if activeChildId is set (user switched)
+          if (self.activeChildId) {
+            self.loadCognitiveReport()
           }
-          // Load cognitive report AFTER activeChildId is confirmed set
-          self.loadCognitiveReport()
         }
+        self.loadContacts()
       }).catch(function(e) {})
     },
     loadFamilyMembersVisible: function() {
@@ -345,24 +358,10 @@ export default {
         }
       }).catch(function(e) {})
     },
-    loadReadingStats: function() {
-      var self = this
-      var childId = this.activeChildId
-      if (!childId) return
-      getReadingStats(childId).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.readingStats = res.data
-        }
-      }).catch(function() {
-        self.readingStats = null
-      })
-    },
-    goReadingDetail: function() {
-      uni.navigateTo({ url: '/pages/mind-detail/article-records' })
-    },
     onChildChanged: function(childId) {
       this.activeChildId = childId
-      uni.setStorageSync('currentChildId', childId)
+      // FIX: 同步提交到 Vuex store
+      this.$store.commit('switchToChild', childId)
       for (var i = 0; i < this.children.length; i++) {
         if (this.children[i].id == childId) {
           this.activeChildName = this.children[i].nickname || this.children[i].name || ''
@@ -512,6 +511,50 @@ export default {
       uni.navigateTo({ url: '/pages/mind-detail/article-detail?id=' + (article.id || article) })
     },
 
+    // ===== 重要关系 =====
+    loadContacts: function() {
+      var self = this
+      getContactList({}).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.contactList = Array.isArray(res.data) ? res.data : []
+        }
+      }).catch(function(e) {
+        console.log('获取联系人失败', e)
+      })
+    },
+    loadRelationshipHealth: function() {
+      var self = this
+      getHealthAlerts({}).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.healthAlerts = Array.isArray(res.data) ? res.data : []
+        }
+      }).catch(function() {
+        self.healthAlerts = []
+      })
+      getMilestones({}, 30).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.milestones = Array.isArray(res.data) ? res.data : []
+        }
+      }).catch(function() {
+        self.milestones = []
+      })
+    },
+    onContactClick: function(item) {
+      uni.navigateTo({ url: '/pages/wisdom-detail/contact-detail?id=' + item.id })
+    },
+    onShowImport: function() {
+      uni.showToast({ title: '导入通讯录', icon: 'none' })
+    },
+    goInteractionLog: function(memberId) {
+      if (memberId) {
+        uni.navigateTo({ url: '/pages/wisdom-detail/interaction-log?memberId=' + memberId })
+      } else {
+        uni.navigateTo({ url: '/pages/wisdom-detail/interaction-log' })
+      }
+    },
+    goRelationshipQuestionnaire: function() {
+      uni.navigateTo({ url: '/pages/wisdom-detail/relationship-questionnaire' })
+    },
     _watchPageReady() {
       var self = this
       var unwatch = this.$watch(function() {
@@ -530,6 +573,29 @@ export default {
         if (unwatch) unwatch()
         self.showTabTransition = false
       }, 5000)
+    },
+
+    // ===== DAN 报告 =====
+    loadDanReports: function() {
+      var self = this
+      var memberId = self.activeChildId
+      if (!memberId) return
+      getDanReportByChild(memberId, 'wisdom').then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.danReports = res.data
+        }
+      }).catch(function() {})
+    },
+    goDanUpload: function() {
+      uni.navigateTo({ url: '/pages/dan-assessment/report-upload' })
+    },
+    viewDanReport: function(report) {
+      uni.navigateTo({ url: '/pages/dan-assessment/report-upload?draftId=' + report.id })
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
     }
   }
 }
@@ -668,21 +734,54 @@ export default {
   font-weight: 500;
 }
 
-/* ===== 本月阅读卡片 ===== */
-.reading-stats-card {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.reading-stats-card:active { opacity: 0.8; }
-.rsc-header { margin-bottom: 12rpx; }
-.rsc-title { font-size: 28rpx; font-weight: 700; color: #333; }
-.rsc-body { display: flex; align-items: center; justify-content: center; padding: 8rpx 0; }
-.rsc-stat-value { font-size: 32rpx; font-weight: 700; color: #6366F1; }
-
 /* ===== 底部占位 ===== */
 .bottom-spacer {
   height: 120rpx;
 }
+
+/* DAN 报告卡片 */
+.dan-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+}
+.dan-card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.dan-report-date {
+  font-size: 26rpx;
+  color: #666;
+}
+.dan-status {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 8rpx;
+}
+.dan-status.confirmed {
+  background: #D1FAE5;
+  color: #065F46;
+}
+.dan-status.pending {
+  background: #FEF3C7;
+  color: #92400E;
+}
+.dan-report-desc {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.dan-card-footer {
+  text-align: right;
+}
+.dan-view-link {
+  font-size: 26rpx;
+  color: #6366F1;
+}
 </style>

+ 40 - 46
cfc-frontend/utils/api.js

@@ -1317,10 +1317,6 @@ export const getEnergyLogs = (childId, dimensionCode, page = 1, size = 10) => {
   return request('/api/energy/logs', 'POST', { childId, dimensionCode, page, size })
 }
 
-export const getWealthDetail = (memberId, memberType) => {
-  return request('/api/energy/wealth-detail', 'POST', { memberId, memberType })
-}
-
 // ===== 鎴愬氨寰界珷 =====
 export const getUserBadges = (childId) => {
   return request('/api/badges/child/list', 'POST', { childId })
@@ -1718,51 +1714,49 @@ export const tianpanRelatedItems = (data) => {
   return request('/api/tianpan/related-items', 'POST', data)
 }
 
-// ========== 电商供应商 ==========
-export const supplierList = (data) => {
-  return request('/api/supplier/list', 'POST', data)
-}
-
-export const supplierDetail = (id) => {
-  return request('/api/supplier/detail', 'POST', { id })
-}
-
-export const supplierProducts = (data) => {
-  return request('/api/supplier/products', 'POST', data)
-}
-
-// ========== 商品规格 ==========
-export const productSpecGroups = (productId) => {
-  return request('/api/product/spec/groups', 'POST', { productId })
-}
-
-// ========== 推荐系统 - 维度商品 ==========
-export const getDimensionProducts = (data) => {
-  return request('/api/recommend/dimension-products', 'POST', data)
-}
+// ===== DAN 测评报告上传 =====
 
-// ========== 推荐系统 - 复购提醒 ==========
-export const getRepurchaseReminders = (params) => {
-  return request('/api/recommend/repurchase-reminders', 'GET', params)
-}
-
-export const clickRepurchaseReminder = (id) => {
-  return request('/api/recommend/repurchase-reminder/' + id + '/click', 'POST', {})
-}
-
-// ===== 圈子/珍珠图 =====
-export const getMyCircles = (params) => {
-  return request('/api/circle/my-circles', 'POST', params)
-}
-
-export const discoverCircles = (params) => {
-  return request('/api/circle/discover', 'POST', params)
+/**
+ * Phase 1: 上传 DAN 报告 PDF → 解析 → 返回预览草稿
+ */
+export const danParsePreview = (filePath, dimension, memberId) => {
+  return new Promise((resolve, reject) => {
+    var token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: BASE_URL + '/api/dan-assessment/report/parse-preview',
+      filePath: filePath,
+      name: 'file',
+      formData: {
+        dimension: dimension,
+        memberId: String(memberId || '')
+      },
+      header: {
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        var data
+        try { data = JSON.parse(res.data) } catch (e) { data = res.data }
+        if (data && data.code === 200) {
+          resolve(data)
+        } else {
+          reject(data || { message: '解析失败' })
+        }
+      },
+      fail: function(err) { reject(err) }
+    })
+  })
 }
 
-export const joinCircle = (params) => {
-  return request('/api/circle/join', 'POST', params)
+/**
+ * Phase 2: 确认并保存 DAN 报告
+ */
+export const danConfirmReport = (data) => {
+  return request('/api/dan-assessment/report/confirm', 'POST', data)
 }
 
-export const leaveCircle = (params) => {
-  return request('/api/circle/leave', 'POST', params)
+/**
+ * 获取某孩子某维度的 DAN 报告列表
+ */
+export const getDanReportByChild = (memberId, dimension) => {
+  return request('/api/dan-assessment/report/child', 'POST', { memberId, dimension })
 }