Browse Source

Merge remote-tracking branch 'origin/cfclub' into cfclub

asus 1 month ago
parent
commit
b71acdf8eb
62 changed files with 5447 additions and 780 deletions
  1. 68 33
      .anchored-summary.md
  2. 120 1
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 71 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ReportParserController.java
  4. 286 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java
  5. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java
  6. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java
  7. 23 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java
  8. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyJoinRequestDTO.java
  9. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java
  10. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyJoinRequest.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationship.java
  12. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportFingerprint.java
  13. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportParserImport.java
  14. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java
  15. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownCluster.java
  16. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownUpload.java
  17. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportFingerprintMapper.java
  18. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportParserImportMapper.java
  19. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportTypeRegistryMapper.java
  20. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportUnknownClusterMapper.java
  21. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportUnknownUploadMapper.java
  22. 10 3
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java
  23. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  24. 71 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java
  25. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java
  26. 188 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java
  27. 265 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportImportService.java
  28. 207 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportParserDispatchService.java
  29. 23 3
      cfc-backend/src/main/java/com/etotem/cfc/service/UnifiedKnowledgeBaseService.java
  30. 3 3
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  31. 77 1
      cfc-backend/src/main/resources/schema.sql
  32. 3 2
      cfc-backend/src/test/java/com/etotem/cfc/config/FamilyAccessInterceptorTest.java
  33. 17 3
      cfc-backend/src/test/java/com/etotem/cfc/service/DimensionScoreServiceTest.java
  34. 26 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
  35. 7 4
      cfc-backend/src/test/java/com/etotem/cfc/service/FoodRecommendServiceTest.java
  36. 1 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FortuneServiceTest.java
  37. 8 4
      cfc-backend/src/test/java/com/etotem/cfc/service/ProductServiceTest.java
  38. 32 0
      cfc-frontend/components/FamilyMemberCard.vue
  39. 326 711
      cfc-frontend/components/FamilyMemberStrip.vue
  40. 10 2
      cfc-frontend/pages/home-pages/member-home-detail.vue
  41. 5 0
      cfc-frontend/utils/api.js
  42. 63 0
      cfc-langgraph/app/agents/report_parse_agent.py
  43. 33 1
      cfc-langgraph/app/api/report_parse.py
  44. 151 0
      cfc-langgraph/tests/test_auto_tag.py
  45. 1 1
      cfc-web/.last_build_commit
  46. 2 2
      cfc-web/package-lock.json
  47. 1 1
      cfc-web/package.json
  48. 125 0
      cfc-web/public/CHANGELOG-v1.0.md
  49. 126 1
      cfc-web/public/CHANGELOG.md
  50. 79 0
      cfc-web/src/api/langgraph.js
  51. 136 0
      cfc-web/src/api/reportParser.js
  52. 32 0
      cfc-web/src/router/index.js
  53. 9 0
      cfc-web/src/views/Layout.vue
  54. 649 0
      cfc-web/src/views/admin/LangGraphAdmin.vue
  55. 114 0
      cfc-web/src/views/admin/ReportAutoLearn.vue
  56. 175 0
      cfc-web/src/views/admin/ReportParserImport.vue
  57. 240 0
      cfc-web/src/views/admin/ReportTypeManagement.vue
  58. 133 0
      cfc-web/src/views/admin/ReportUnknownCluster.vue
  59. 2 1
      docs/superpowers/PROJECT-OVERVIEW.md
  60. 1022 0
      docs/superpowers/plans/2026-08-05-family-relationship-strip.md
  61. 192 0
      docs/superpowers/specs/2026-08-05-family-relationship-strip-design.md
  62. 37 0
      docs/参考资料/extract_full_report_v5.py

+ 68 - 33
.anchored-summary.md

@@ -1,42 +1,77 @@
-# AI Mascot IP Display (浠宝/福宝) — Session Summary
+# CFC 膳食管理模块部署 — Session Summary
 
 ## Goal
-- Complete and verify the mascot IP形象 system (浠宝/福宝) across all touchpoints: database, backend API, login/registration, profile settings, AI chat display, and floating avatar
+- 部署 LangGraph meal 路由到 ai.etotem.com.cn,修复 404 错误
+- 验证食材识别和菜单生成 API 正常工作
 
 ## Progress
 ### Done
-- ✅ Investigated all mascot-related files and verified completion state
-- ✅ Fixed hardcoded typing indicator in `chat.vue` (`🤖` → `{{ mascotIcon || '🤖' }}`)
-- ✅ Added dynamic CSS class `mascot-{code}` to chat nav bar
-- ✅ Added mascot-specific nav gradient: `.mascot-xibao` (warm orange), `.mascot-fubao` (golden)
-- ✅ Added mascot-specific avatar styling (gradient + colored border)
-- ✅ Added welcome icon float animation with per-mascot delays
-- ✅ Added mascot selection to `user-edit.vue` (registration flow):
-  - Mascot card UI (浠宝/福宝 with gradient icons)
-  - `selectMascot(code)` method
-  - `form.mascot` field in data
-  - `mascot` passed in all API calls (saveUserInfo, directRegister, updateUserInfo)
-  - mascot saved to `uni.getStorageSync('userInfo')` on navigation
-  - Card styles with active state and gradient backgrounds
-- ✅ Verified other mascot components:
-  - `MascotEnum.java` ✅ (XIBAO/FUBAO with code, gender, persona)
-  - `User.java:95` ✅ (`private String mascot;` field)
-  - `AIChatController.java` ✅ (injects mascot_name/mascot_gender/mascot_persona into Dify)
-  - `UserMascotController.java` ✅ (POST set/get)
-  - `profile.vue` ✅ (mascot-selector with cards)
-  - `AIFloatingAvatar.vue` ✅ (reads from storage)
-  - `chat.vue` ✅ (reads from userInfo onLoad, mascot-specific styling)
-
-### Remaining
-- 🔄 Verify all diagnostics clean
-- 🔄 (Optional) Test full mascot flow: login → user-edit mascot selection → profile → AI chat
+- ✅ 修复 LangGraph 服务缺少 meal 路由的问题
+- ✅ 将 meal.py 和 models/meal.py 复制到服务器容器
+- ✅ 修改 main.py 添加 `app.include_router(meal.router)`
+- ✅ 重启容器并验证端点正常
+- ✅ 测试食材识别 API:成功识别番茄、鸡蛋、洋葱等食材
+- ✅ 测试菜单生成 API:成功生成一日三餐菜单
+
+### Key Files Updated
+- `/app/cfc/cfc-langgraph/app/api/meal.py` - 食材识别和菜单生成端点
+- `/app/cfc/cfc-langgraph/app/models/meal.py` - Pydantic 模型定义
+- `/app/cfc/cfc-langgraph/app/main.py` - 添加 meal 路由注册
+
+### API Endpoints
+| Method | Path | 说明 |
+|--------|------|------|
+| POST | `/api/v1/food/recognize` | 食材识别 - 上传图片 URL,返回识别食材列表 |
+| POST | `/api/v1/menu/generate` | 菜单生成 - 根据食材生成一日三餐菜单 |
+
+### Test Results
+**食材识别** - 成功识别图片中的食材:
+```json
+{
+  "foods": [
+    {"name": "西红柿", "confidence": 0.96, "category": "蔬菜"},
+    {"name": "鸡蛋", "confidence": 0.93, "category": "蛋奶"},
+    {"name": "洋葱", "confidence": 0.88, "category": "蔬菜"},
+    {"name": "青椒", "confidence": 0.85, "category": "蔬菜"},
+    {"name": "大蒜", "confidence": 0.79, "category": "调味"},
+    {"name": "食用油", "confidence": 0.65, "category": "调味"}
+  ]
+}
+```
+
+**菜单生成** - 成功生成一日三餐菜单(含营养信息)
+
+## Deployment Commands
+```bash
+# 复制文件到容器
+scp meal.py root@111.228.6.214:/tmp/
+scp models_meal.py root@111.228.6.214:/tmp/
+sudo docker cp /tmp/meal.py cfc-langgraph-langgraph-svc-1:/app/app/api/meal.py
+sudo docker cp /tmp/models_meal.py cfc-langgraph-langgraph-svc-1:/app/app/models/meal.py
+
+# 修改 main.py 添加路由
+sudo docker exec cfc-langgraph-langgraph-svc-1 sed -i 's/from app.api import health, recommend, chat, analyze, tongue, adapter/from app.api import health, recommend, chat, analyze, tongue, adapter, meal/' /app/app/main.py
+sudo docker exec cfc-langgraph-langgraph-svc-1 sed -i '/app.include_router(report_parse.router)/a app.include_router(meal.router)' /app/app/main.py
+
+# 重启服务
+sudo docker restart cfc-langgraph-langgraph-svc-1
+```
+
+## Current Status
+- ✅ LangGraph 服务运行正常 (端口 9000)
+- ✅ 后端服务运行正常 (端口 9082)
+- ✅ 前端代码已包含膳食管理模块 (7 个新页面)
+- ✅ API 端点可正常访问
+
+## Next Steps
+- 前端测试膳食管理模块完整流程
+- 验证后端 AiGateway 正确调用 LangGraph 服务
 
 ## Key Decisions
-- Added mascot selection to `user-edit.vue` during registration (not login.vue), since WeChat login is one-tap
-- Used gradient/card UI matching profile.vue style for consistency
-- Saved mascot to `userInfo` storage in `navigateToHome()` to ensure chat.vue reads it immediately
+- 直接在服务器上修改容器内的文件(因为 Docker 镜像重建需要修改 Dockerfile)
+- meal.py 使用 `/api/v1` 前缀与其他路由保持一致
 
 ## Critical Context
-- Pre-existing LSP warnings (unused `e` variables, string concatenation) — NOT introduced by edits
-- 小程序 limits: no optional chaining, no CSS Grid — all CSS uses flexbox
-- chat.vue has 4 pre-existing `var` declarations — kept as-is for legacy compatibility
+- 后端配置 `python.base-url: https://ai.etotem.com.cn`
+- LangGraph 服务通过 Docker 容器运行
+- 前端通过 `config.js` 自动适配环境(develop/trial/release)

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

@@ -7220,7 +7220,8 @@ private void runMigration100() {
 			jdbcTemplate.execute("ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected'");
 			log.info("迁移112: 已添加article_admin_status列到users表");
 		} catch (Exception e) {
-			log.error("迁移112: 添加article_admin_status列失败: {}", e.getMessage());
+			// 列已存在,幂等忽略(用warn而非error避免日志噪声)
+			log.warn("迁移112: 添加article_admin_status列失败(可能已存在): {}", e.getMessage());
 		}
 
 		// 迁移113: users表添加activity_provider_status字段(活动提供商状态)
@@ -8185,5 +8186,123 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("存量挑战补全participant失败(可能已处理): " + e.getMessage());
 		}
+
+		// ===== 报告指纹检测与解析器调度系统 =====
+
+		// 迁移186: 创建 report_type_registry 表(报告类型注册表)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_type_registry (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"type_id VARCHAR(64) NOT NULL COMMENT '类型标识,如 dan_a1/gut_beijing', " +
+				"family VARCHAR(32) DEFAULT NULL COMMENT '报告家族: dan/gut_flora/其他', " +
+				"display_name VARCHAR(100) NOT NULL COMMENT '显示名称', " +
+				"description VARCHAR(500) DEFAULT NULL COMMENT '描述', " +
+				"parser_path VARCHAR(255) DEFAULT NULL COMMENT 'Python解析器脚本路径', " +
+				"min_confidence INT DEFAULT 10 COMMENT '识别阈值', " +
+				"is_active TINYINT(1) DEFAULT 1 COMMENT '启用状态', " +
+				"source VARCHAR(20) DEFAULT 'prebuilt' COMMENT '来源: prebuilt/imported/auto_generated', " +
+				"review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected', " +
+				"version VARCHAR(20) DEFAULT '1.0' COMMENT '版本', " +
+				"created_by BIGINT DEFAULT NULL COMMENT '创建人ID', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_type_id (type_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告类型注册表'");
+			log.info("已创建report_type_registry表");
+		} catch (Exception e) {
+			log.warn("创建 report_type_registry 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移187: 创建 report_fingerprint 表(指纹规则)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_fingerprint (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"type_id VARCHAR(64) NOT NULL COMMENT '关联report_type_registry.type_id', " +
+				"pattern VARCHAR(200) NOT NULL COMMENT '文本特征', " +
+				"weight INT DEFAULT 10 COMMENT '权重(正=加分,负=排除扣分)', " +
+				"is_exclusion TINYINT(1) DEFAULT 0 COMMENT '是否为排除规则', " +
+				"sort_order INT DEFAULT 0 COMMENT '排序', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_fp_type (type_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告指纹规则表'");
+			log.info("已创建report_fingerprint表");
+		} catch (Exception e) {
+			log.warn("创建 report_fingerprint 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移188: 创建 report_unknown_cluster 表(未知报告聚类,自学习用)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_cluster (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"cluster_key VARCHAR(64) NOT NULL COMMENT '聚类哈希键(同类报告分组)', " +
+				"common_signatures TEXT COMMENT '公共文本特征JSON(用于生成指纹)', " +
+				"llm_template TEXT COMMENT 'LLM解析结果模板JSON', " +
+				"report_count INT DEFAULT 0 COMMENT '累计报告数', " +
+				"status VARCHAR(20) DEFAULT 'collecting' COMMENT '状态: collecting/ready/generated/imported', " +
+				"generated_type_id VARCHAR(64) DEFAULT NULL COMMENT '生成的类型ID', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_cluster_key (cluster_key)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告聚类表'");
+			log.info("已创建report_unknown_cluster表");
+		} catch (Exception e) {
+			log.warn("创建 report_unknown_cluster 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移189: 创建 report_unknown_upload 表(未知报告明细)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_upload (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"cluster_id BIGINT DEFAULT NULL COMMENT '关联report_unknown_cluster.id', " +
+				"file_url VARCHAR(500) DEFAULT NULL COMMENT '文件路径', " +
+				"file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件哈希', " +
+				"llm_result TEXT COMMENT 'LLM解析结果JSON', " +
+				"uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_unknown_cluster (cluster_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告上传明细表'");
+			log.info("已创建report_unknown_upload表");
+		} catch (Exception e) {
+			log.warn("创建 report_unknown_upload 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移190: 创建 report_parser_import 表(外部导入记录)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_parser_import (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"package_name VARCHAR(100) NOT NULL COMMENT '导入包名称', " +
+				"package_file_url VARCHAR(500) DEFAULT NULL COMMENT '导入包文件路径', " +
+				"fingerprint_json TEXT COMMENT '指纹规则JSON', " +
+				"parser_script TEXT COMMENT '解析器脚本内容', " +
+				"status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected', " +
+				"imported_by BIGINT DEFAULT NULL COMMENT '导入人ID', " +
+				"reviewed_by BIGINT DEFAULT NULL COMMENT '审核人ID', " +
+				"review_note VARCHAR(500) DEFAULT NULL COMMENT '审核意见', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告解析器导入记录表'");
+			log.info("已创建report_parser_import表");
+		} catch (Exception e) {
+			log.warn("创建 report_parser_import 表失败(可能已存在): " + e.getMessage());
+		}
+
+// 迁移191: foods 添加 report_id/score 列(Food 实体已含字段,生产库缺列导致 Unknown column 'report_id' 错误)
+		ensureColumn("foods", "report_id", "BIGINT DEFAULT NULL COMMENT '关联报告ID(从PDF解析的食物推荐)'");
+		ensureColumn("foods", "score", "INT DEFAULT NULL COMMENT '食物评分'");
+
+		// 迁移192: verification_codes.type 添加默认值(旧版插入未携带 type 字段,DB 列 NOT NULL 无默认值导致报错)
+		try {
+			jdbcTemplate.execute("ALTER TABLE verification_codes MODIFY COLUMN `type` VARCHAR(20) NOT NULL DEFAULT 'login' COMMENT '验证码类型: login/register/reset'");
+			log.info("已为verification_codes.type添加默认值");
+		} catch (Exception e) {
+			log.warn("verification_codes.type 默认值修改失败(可能已修改): " + e.getMessage());
+		}
+
+		// 迁移193: family_relationships表添加关系质量评分三列(关系条展示增强)
+		ensureColumn("family_relationships", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)'");
+		ensureColumn("family_relationships", "intimacy_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)'");
+		ensureColumn("family_relationships", "communication_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)'");
+
+		// 迁移194: family_join_requests 添加 family_role 列(保存申请人角色)
+		ensureColumn("family_join_requests", "family_role", "VARCHAR(20) DEFAULT NULL COMMENT '申请人在家庭中的角色:爸爸/妈妈/爷爷/奶奶等'");
 	}
 }

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportParserController.java

@@ -0,0 +1,71 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ReportFingerprintService;
+import com.etotem.cfc.service.ReportParserDispatchService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/report-parser")
+public class ReportParserController {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParserController.class);
+
+    @Resource
+    private ReportFingerprintService fingerprintService;
+
+    @Resource
+    private ReportParserDispatchService dispatchService;
+
+    @PostMapping("/upload")
+    public Result<Map<String, Object>> uploadAndParse(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam(value = "fileName", required = false) String fileName) {
+        if (file == null || file.isEmpty()) {
+            return Result.error("请选择文件");
+        }
+
+        try {
+            String uploadDir = "/tmp/report_uploads";
+            File dir = new File(uploadDir);
+            if (!dir.exists()) dir.mkdirs();
+
+            String savedName = UUID.randomUUID().toString() + "_" +
+                    (fileName != null ? fileName : file.getOriginalFilename());
+            File savedFile = new File(dir, savedName);
+            file.transferTo(savedFile);
+
+            String pdfPath = savedFile.getAbsolutePath();
+            String fname = file.getOriginalFilename();
+            log.info("报告上传解析: file={}, path={}", fname, pdfPath);
+            return dispatchService.parseReport(pdfPath, fname);
+
+        } catch (Exception e) {
+            log.error("上传解析失败", e);
+            return Result.error("上传解析失败: " + e.getMessage());
+        }
+    }
+
+    @PostMapping("/detect")
+    public Result<Map<String, Object>> detect(@RequestBody Map<String, Object> params) {
+        String filePath = (String) params.get("filePath");
+        String fileName = (String) params.get("fileName");
+        if (filePath == null) {
+            return Result.error("filePath 不能为空");
+        }
+        File file = new File(filePath);
+        if (!file.exists()) {
+            return Result.error("文件不存在: " + filePath);
+        }
+        Map<String, Object> result = fingerprintService.detect(filePath, fileName);
+        return Result.success(result);
+    }
+}

+ 286 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -0,0 +1,286 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.ReportImportService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/admin/report-parser")
+public class ReportParserAdminController {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParserAdminController.class);
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportFingerprintMapper fingerprintMapper;
+
+    @Resource
+    private ReportUnknownClusterMapper unknownClusterMapper;
+
+    @Resource
+    private ReportUnknownUploadMapper unknownUploadMapper;
+
+    @Resource
+    private ReportParserImportMapper importMapper;
+
+    @Resource
+    private ReportImportService reportImportService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // ==================== 报告类型管理 ====================
+
+    @PostMapping("/types")
+    public Result<Page<ReportTypeRegistry>> listTypes(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 999;
+        Page<ReportTypeRegistry> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<ReportTypeRegistry> wrapper = new LambdaQueryWrapper<ReportTypeRegistry>()
+                .orderByDesc(ReportTypeRegistry::getId);
+        return Result.success(typeRegistryMapper.selectPage(pageParam, wrapper));
+    }
+
+    @PostMapping("/types/save")
+    public Result<Void> saveType(@RequestBody ReportTypeRegistry type) {
+        if (type.getTypeId() == null || type.getTypeId().isEmpty()) {
+            return Result.error("typeId 不能为空");
+        }
+        if (type.getDisplayName() == null || type.getDisplayName().isEmpty()) {
+            return Result.error("displayName 不能为空");
+        }
+        if (type.getId() != null) {
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.updateById(type);
+        } else {
+            Long exists = typeRegistryMapper.selectCount(
+                    new LambdaQueryWrapper<ReportTypeRegistry>()
+                            .eq(ReportTypeRegistry::getTypeId, type.getTypeId()));
+            if (exists != null && exists > 0) {
+                return Result.error("typeId 已存在");
+            }
+            type.setIsActive(type.getIsActive() != null ? type.getIsActive() : true);
+            type.setSource(type.getSource() != null ? type.getSource() : "prebuilt");
+            type.setReviewStatus(type.getReviewStatus() != null ? type.getReviewStatus() : "approved");
+            type.setVersion(type.getVersion() != null ? type.getVersion() : "1.0");
+            type.setMinConfidence(type.getMinConfidence() != null ? type.getMinConfidence() : 10);
+            type.setCreatedAt(new Date());
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.insert(type);
+        }
+        return Result.success();
+    }
+
+    @PostMapping("/types/toggle")
+    public Result<Void> toggleType(@RequestBody Map<String, Object> params) {
+        String typeId = (String) params.get("typeId");
+        Boolean isActive = params.get("isActive") != null ? (Boolean) params.get("isActive") : false;
+        if (typeId == null) return Result.error("typeId 不能为空");
+        ReportTypeRegistry type = typeRegistryMapper.selectOne(
+                new LambdaQueryWrapper<ReportTypeRegistry>().eq(ReportTypeRegistry::getTypeId, typeId));
+        if (type == null) return Result.error("类型不存在");
+        type.setIsActive(isActive);
+        type.setUpdatedAt(new Date());
+        typeRegistryMapper.updateById(type);
+        return Result.success();
+    }
+
+    @PostMapping("/types/delete")
+    public Result<Void> deleteType(@RequestBody Map<String, Object> params) {
+        String typeId = (String) params.get("typeId");
+        if (typeId == null) return Result.error("typeId 不能为空");
+        typeRegistryMapper.delete(new LambdaQueryWrapper<ReportTypeRegistry>()
+                .eq(ReportTypeRegistry::getTypeId, typeId));
+        fingerprintMapper.delete(new LambdaQueryWrapper<ReportFingerprint>()
+                .eq(ReportFingerprint::getTypeId, typeId));
+        return Result.success();
+    }
+
+    // ==================== 指纹规则管理 ====================
+
+    @PostMapping("/fingerprints/list")
+    public Result<List<ReportFingerprint>> listFingerprints(@RequestBody Map<String, Object> params) {
+        String typeId = (String) params.get("typeId");
+        if (typeId == null) return Result.error("typeId 不能为空");
+        List<ReportFingerprint> list = fingerprintMapper.selectList(
+                new LambdaQueryWrapper<ReportFingerprint>()
+                        .eq(ReportFingerprint::getTypeId, typeId)
+                        .orderByAsc(ReportFingerprint::getSortOrder));
+        return Result.success(list);
+    }
+
+    @PostMapping("/fingerprints/save")
+    public Result<Void> saveFingerprint(@RequestBody ReportFingerprint fp) {
+        if (fp.getTypeId() == null || fp.getPattern() == null) {
+            return Result.error("typeId 和 pattern 不能为空");
+        }
+        fp.setWeight(fp.getWeight() != null ? fp.getWeight() : 10);
+        fp.setIsExclusion(fp.getIsExclusion() != null ? fp.getIsExclusion() : false);
+        fp.setSortOrder(fp.getSortOrder() != null ? fp.getSortOrder() : 0);
+        fp.setCreatedAt(new Date());
+        fingerprintMapper.insert(fp);
+        return Result.success();
+    }
+
+    @PostMapping("/fingerprints/delete")
+    public Result<Void> deleteFingerprint(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? ((Number) params.get("id")).longValue() : null;
+        if (id == null) return Result.error("id 不能为空");
+        fingerprintMapper.deleteById(id);
+        return Result.success();
+    }
+
+    // ==================== 导入管理 ====================
+
+    @PostMapping("/imports")
+    public Result<Page<ReportParserImport>> listImports(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        Page<ReportParserImport> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<ReportParserImport> wrapper = new LambdaQueryWrapper<ReportParserImport>()
+                .orderByDesc(ReportParserImport::getCreatedAt);
+        return Result.success(importMapper.selectPage(pageParam, wrapper));
+    }
+
+    @PostMapping("/imports/import")
+    public Result<ReportParserImport> uploadImport(@RequestBody Map<String, Object> params) {
+        String jsonContent = (String) params.get("jsonContent");
+        String fileName = (String) params.get("fileName");
+        Long userId = params.get("userId") != null ? ((Number) params.get("userId")).longValue() : null;
+        if (jsonContent == null) return Result.error("jsonContent 不能为空");
+        return reportImportService.importPackage(fileName, jsonContent, userId);
+    }
+
+    @PostMapping("/imports/approve")
+    public Result<Map<String, Object>> approveImport(@RequestBody Map<String, Object> params) {
+        Long importId = params.get("id") != null ? ((Number) params.get("id")).longValue() : null;
+        Long reviewerId = params.get("reviewerId") != null ? ((Number) params.get("reviewerId")).longValue() : null;
+        if (importId == null) return Result.error("id 不能为空");
+        return reportImportService.approveImport(importId, reviewerId);
+    }
+
+    @PostMapping("/imports/reject")
+    public Result<Void> rejectImport(@RequestBody Map<String, Object> params) {
+        Long importId = params.get("id") != null ? ((Number) params.get("id")).longValue() : null;
+        Long reviewerId = params.get("reviewerId") != null ? ((Number) params.get("reviewerId")).longValue() : null;
+        String note = (String) params.get("note");
+        if (importId == null) return Result.error("id 不能为空");
+        return reportImportService.rejectImport(importId, reviewerId, note);
+    }
+
+    // ==================== 未知报告聚类 ====================
+
+    @PostMapping("/unknown-clusters")
+    public Result<Page<ReportUnknownCluster>> listClusters(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        String status = (String) params.get("status");
+        Page<ReportUnknownCluster> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<ReportUnknownCluster> wrapper = new LambdaQueryWrapper<ReportUnknownCluster>()
+                .orderByDesc(ReportUnknownCluster::getReportCount);
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(ReportUnknownCluster::getStatus, status);
+        }
+        return Result.success(unknownClusterMapper.selectPage(pageParam, wrapper));
+    }
+
+    @PostMapping("/unknown-uploads")
+    public Result<List<ReportUnknownUpload>> listUploads(@RequestBody Map<String, Object> params) {
+        Long clusterId = params.get("clusterId") != null ? ((Number) params.get("clusterId")).longValue() : null;
+        if (clusterId == null) return Result.error("clusterId 不能为空");
+        List<ReportUnknownUpload> list = unknownUploadMapper.selectList(
+                new LambdaQueryWrapper<ReportUnknownUpload>()
+                        .eq(ReportUnknownUpload::getClusterId, clusterId)
+                        .orderByDesc(ReportUnknownUpload::getUploadedAt));
+        return Result.success(list);
+    }
+
+    // ==================== 自学习生成 ====================
+
+    @PostMapping("/generate-type")
+    public Result<Map<String, Object>> generateType(@RequestBody Map<String, Object> params) {
+        Long clusterId = params.get("clusterId") != null ? ((Number) params.get("clusterId")).longValue() : null;
+        if (clusterId == null) return Result.error("clusterId 不能为空");
+
+        ReportUnknownCluster cluster = unknownClusterMapper.selectById(clusterId);
+        if (cluster == null) return Result.error("聚类不存在");
+        if (!"ready".equals(cluster.getStatus())) {
+            return Result.error("该聚类不可生成,当前状态: " + cluster.getStatus());
+        }
+
+        try {
+            String clusterKey = cluster.getClusterKey();
+            String typeId = "auto_" + clusterKey.toLowerCase().replaceAll("[^a-z0-9]", "_").replaceAll("_+", "_");
+
+            // 创建报告类型
+            ReportTypeRegistry type = new ReportTypeRegistry();
+            type.setTypeId(typeId);
+            type.setFamily("auto_generated");
+            type.setDisplayName("自动生成 - " + typeId);
+            type.setDescription("由自学习系统自动生成,基于 " + cluster.getReportCount() + " 份同类报告");
+            type.setParserPath(typeId + ".py");
+            type.setMinConfidence(10);
+            type.setIsActive(false);
+            type.setSource(ReportTypeRegistry.SOURCE_AUTO_GENERATED);
+            type.setReviewStatus(ReportTypeRegistry.REVIEW_PENDING);
+            type.setVersion("1.0");
+            type.setCreatedAt(new Date());
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.insert(type);
+
+            // 从公共签名生成指纹规则
+            String signatures = cluster.getCommonSignatures();
+            if (signatures != null && !signatures.isEmpty()) {
+                // 提取可能的文本特征作为指纹
+                String[] candidates = signatures.split("[\n\r]+");
+                Set<String> seen = new HashSet<>();
+                int sort = 0;
+                for (String line : candidates) {
+                    String trimmed = line.trim();
+                    if (trimmed.length() >= 4 && trimmed.length() <= 100 && !seen.contains(trimmed)) {
+                        seen.add(trimmed);
+                        ReportFingerprint fp = new ReportFingerprint();
+                        fp.setTypeId(typeId);
+                        fp.setPattern(trimmed);
+                        fp.setWeight(10);
+                        fp.setIsExclusion(false);
+                        fp.setSortOrder(sort++);
+                        fp.setCreatedAt(new Date());
+                        fingerprintMapper.insert(fp);
+                    }
+                }
+            }
+
+            // 更新聚类
+            cluster.setStatus("generated");
+            cluster.setGeneratedTypeId(typeId);
+            cluster.setUpdatedAt(new Date());
+            unknownClusterMapper.updateById(cluster);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("typeId", typeId);
+            result.put("displayName", type.getDisplayName());
+            result.put("fingerprintCount", type.getMinConfidence());
+            result.put("reviewStatus", "pending");
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("自学习生成类型失败 clusterId={}", clusterId, e);
+            return Result.error("生成失败: " + e.getMessage());
+        }
+    }
+}

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java

@@ -111,6 +111,9 @@ public class FamilyInviteController {
 
         String inviteCode = body.get("inviteCode");
 
+        // 获取申请人角色
+        String familyRole = body.get("familyRole");
+
         // 兼容邀请令牌(token):通过 token 解析到 familyId
         if (inviteCode == null || inviteCode.trim().isEmpty()) {
             String token = body.get("token");
@@ -129,7 +132,7 @@ public class FamilyInviteController {
         }
 
         try {
-            com.etotem.cfc.entity.FamilyJoinRequest request = familyJoinRequestService.createJoinRequest(userId, inviteCode);
+            com.etotem.cfc.entity.FamilyJoinRequest request = familyJoinRequestService.createJoinRequest(userId, inviteCode, familyRole);
             Family family = familyMapper.selectById(request.getTargetFamilyId());
             Map<String, Object> result = new HashMap<>();
             result.put("requestId", request.getId());

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java

@@ -68,7 +68,7 @@ public class FamilyUserController {
         }
 
         try {
-            FamilyJoinRequest request = familyJoinRequestService.createJoinRequest(userId, inviteCode);
+            FamilyJoinRequest request = familyJoinRequestService.createJoinRequest(userId, inviteCode, null);
             Family family = familyMapper.selectById(request.getTargetFamilyId());
             Map<String, Object> result = new HashMap<>();
             result.put("requestId", request.getId());

+ 23 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller.family;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.dto.HealthAlertVO;
 import com.etotem.cfc.dto.MilestoneVO;
 import com.etotem.cfc.dto.RelationshipScoreVO;
@@ -87,7 +88,7 @@ public class RelationshipQualityController {
         return Result.success(milestones);
     }
 
-    /**
+/**
      * 解析请求中的家庭ID:优先请求体中的familyId,其次request attribute,最后回退用户所属家庭
      */
     private Long getFamilyIdFromRequest(Long userId, Map<String, Object> body, Long familyId) {
@@ -105,4 +106,25 @@ public class RelationshipQualityController {
         }
         return null;
     }
+
+    @Operation(summary = "获取以指定成员为中心的所有成员关系(pairwise)")
+    @PostMapping("/pairwise")
+    public Result<List<FamilyMemberVO>> getPairwise(@RequestBody Map<String, Object> body,
+                                                    @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long centerMemberId = body.get("centerMemberId") != null
+                ? Long.valueOf(body.get("centerMemberId").toString()) : null;
+        if (centerMemberId == null) {
+            return Result.error("centerMemberId不能为空");
+        }
+        Long familyId = getFamilyIdFromRequest(userId, body, null);
+        try {
+            List<FamilyMemberVO> members = relationshipQualityService.getPairwiseRelationships(centerMemberId, familyId);
+            return Result.success(members);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyJoinRequestDTO.java

@@ -16,6 +16,7 @@ public class FamilyJoinRequestDTO implements Serializable {
     private String targetFamilyName;
     private String status; // pending / approved / rejected
     private String adminComment;
+    private String familyRole; // 申请人在家庭中的角色
     private String createdAt;
     private String updatedAt;
 }

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

@@ -64,6 +64,9 @@ public class FamilyMemberVO {
     /** 亲密度分数 */
     private java.math.BigDecimal intimacyScore;
 
+    /** 沟通质量分数 */
+    private java.math.BigDecimal communicationScore;
+
     /** 最近互动时间 */
     private java.util.Date lastInteractionAt;
 

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

@@ -38,6 +38,9 @@ public class FamilyJoinRequest implements Serializable {
     /** 审批备注 */
     private String comment;
 
+    /** 申请人在家庭中的角色:爸爸/妈妈/爷爷/奶奶等 */
+    private String familyRole;
+
     /** 创建时间 */
     private Date createdAt;
 

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

@@ -30,6 +30,15 @@ public class FamilyRelationship implements Serializable {
     /** 关系标签key(冗余用于显示) */
     private String relationshipKey;
 
+    /** 信任度(答卷人视角) 0-100 */
+    private java.math.BigDecimal trustScore;
+
+    /** 亲密度(答卷人视角) 0-100 */
+    private java.math.BigDecimal intimacyScore;
+
+    /** 沟通质量(答卷人视角) 0-100 */
+    private java.math.BigDecimal communicationScore;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportFingerprint.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("report_fingerprint")
+public class ReportFingerprint implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String typeId;
+    private String pattern;
+    private Integer weight;
+    private Boolean isExclusion;
+    private Integer sortOrder;
+    private Date createdAt;
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportParserImport.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("report_parser_import")
+public class ReportParserImport implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String packageName;
+    private String packageFileUrl;
+    private String fingerprintJson;
+    private String parserScript;
+    private String status;
+    private Long importedBy;
+    private Long reviewedBy;
+    private String reviewNote;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public static final String STATUS_PENDING = "pending";
+    public static final String STATUS_APPROVED = "approved";
+    public static final String STATUS_REJECTED = "rejected";
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("report_type_registry")
+public class ReportTypeRegistry implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String typeId;
+    private String family;
+    private String displayName;
+    private String description;
+    private String parserPath;
+    private Integer minConfidence;
+    private Boolean isActive;
+    private String source;
+    private String reviewStatus;
+    private String version;
+    private Long createdBy;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public static final String SOURCE_PREBUILT = "prebuilt";
+    public static final String SOURCE_IMPORTED = "imported";
+    public static final String SOURCE_AUTO_GENERATED = "auto_generated";
+
+    public static final String REVIEW_APPROVED = "approved";
+    public static final String REVIEW_PENDING = "pending";
+    public static final String REVIEW_REJECTED = "rejected";
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownCluster.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("report_unknown_cluster")
+public class ReportUnknownCluster implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String clusterKey;
+    private String commonSignatures;
+    private String llmTemplate;
+    private Integer reportCount;
+    private String status;
+    private String generatedTypeId;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public static final String STATUS_COLLECTING = "collecting";
+    public static final String STATUS_READY = "ready";
+    public static final String STATUS_GENERATED = "generated";
+    public static final String STATUS_IMPORTED = "imported";
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownUpload.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("report_unknown_upload")
+public class ReportUnknownUpload implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long clusterId;
+    private String fileUrl;
+    private String fileHash;
+    private String llmResult;
+    private Date uploadedAt;
+}

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

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

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

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

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

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

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

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

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

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

+ 10 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java

@@ -40,7 +40,7 @@ public class FamilyJoinRequestService {
     /**
      * 创建家庭加入请求(待审批)
      */
-    public FamilyJoinRequest createJoinRequest(Long userId, String inviteCode) {
+    public FamilyJoinRequest createJoinRequest(Long userId, String inviteCode, String familyRole) {
         Family family;
 
         // 支持传入 familyId(数字)作为 inviteCode
@@ -106,6 +106,7 @@ public class FamilyJoinRequestService {
         request.setTargetFamilyId(family.getId());
         request.setJoinMethod("by_code");
         request.setInviteRef(inviteCode.trim());
+        request.setFamilyRole(familyRole);
         request.setStatus("pending");
         request.setCreatedAt(new Date());
         request.setUpdatedAt(new Date());
@@ -151,6 +152,7 @@ public class FamilyJoinRequestService {
         } else {
             if (user.getFamilyId() == null || user.getFamilyId() == 0L || !user.getFamilyId().equals(family.getId())) {
                 user.setFamilyId(family.getId());
+                user.setFamilyRole(request.getFamilyRole());
                 user.setUpdatedAt(new Date());
                 userMapper.updateById(user);
             }
@@ -220,19 +222,22 @@ public class FamilyJoinRequestService {
             Family family = familyMapper.selectById(req.getTargetFamilyId());
             dto.setTargetFamilyName(family != null ? family.getName() : "");
             dto.setStatus(req.getStatus());
+            dto.setFamilyRole(req.getFamilyRole());
+            dto.setAdminComment(req.getComment());
             dto.setCreatedAt(req.getCreatedAt() != null ? req.getCreatedAt().toString() : "");
             return dto;
         }).collect(Collectors.toList());
     }
 
     /**
-     * 查询用户自己的待审批请求
+     * 查询用户自己的最近一次加入请求(不限状态,用于轮询审批结果)
      */
     public FamilyJoinRequestDTO getMyPendingRequest(Long userId) {
         FamilyJoinRequest req = familyJoinRequestMapper.selectOne(
                 new LambdaQueryWrapper<FamilyJoinRequest>()
                         .eq(FamilyJoinRequest::getRequesterUserId, userId)
-                        .eq(FamilyJoinRequest::getStatus, "pending")
+                        .orderByDesc(FamilyJoinRequest::getCreatedAt)
+                        .last("LIMIT 1")
         );
         if (req == null) {
             return null;
@@ -244,6 +249,8 @@ public class FamilyJoinRequestService {
         dto.setTargetFamilyName(family != null ? family.getName() : "");
         dto.setStatus(req.getStatus());
         dto.setInviteCode(req.getInviteRef());
+        dto.setFamilyRole(req.getFamilyRole());
+        dto.setAdminComment(req.getComment());
         dto.setCreatedAt(req.getCreatedAt() != null ? req.getCreatedAt().toString() : "");
         return dto;
     }

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

@@ -241,6 +241,8 @@ public class FamilyMemberService {
             vo.setIsSpouse(fm.getIsSpouse());
             vo.setTrustScore(fm.getTrustScore());
             vo.setIntimacyScore(fm.getIntimacyScore());
+            vo.setCommunicationScore(fm.getCommunicationScore());
+            vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
             vo.setLastInteractionAt(fm.getLastInteractionAt());
             vo.setStreakDays(fm.getStreakDays());
             result.add(vo);

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.HealthAlertVO;
 import com.etotem.cfc.dto.MilestoneVO;
 import com.etotem.cfc.dto.RelationshipScoreVO;
+import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyMemberAttributes;
+import com.etotem.cfc.entity.FamilyRelationship;
 import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import org.slf4j.Logger;
@@ -18,7 +20,9 @@ import java.math.RoundingMode;
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @Service
 public class RelationshipQualityService {
@@ -31,6 +35,9 @@ public class RelationshipQualityService {
     @Resource
     private FamilyMemberAttributesMapper familyMemberAttributesMapper;
 
+    @Resource
+    private FamilyRelationshipService familyRelationshipService;
+
     private static final String TYPE_BIRTHDAY = "birthday";
 
     /**
@@ -214,4 +221,68 @@ public class RelationshipQualityService {
         }
         return "parent";
     }
+
+    /**
+     * 获取以指定成员为中心的所有成员关系(关系类型 + 三项评分)
+     */
+    public List<FamilyMemberVO> getPairwiseRelationships(Long centerMemberId, Long familyId) {
+        List<FamilyMemberVO> result = new ArrayList<>();
+        FamilyMember center = familyMemberMapper.selectById(centerMemberId);
+        if (center == null || (familyId != null && !familyId.equals(center.getFamilyId()))) {
+            throw new RuntimeException("成员不存在或不属于该家庭");
+        }
+        if (familyId == null) {
+            familyId = center.getFamilyId();
+        }
+
+        List<FamilyMember> members = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
+
+        // 中心成员 → 各成员的关系行(评分与关系类型来源)
+        List<FamilyRelationship> rels = familyRelationshipService.getRelationsForMember(centerMemberId);
+        Map<Long, FamilyRelationship> relMap = new HashMap<>();
+        for (FamilyRelationship rel : rels) {
+            relMap.put(rel.getToMemberId(), rel);
+        }
+
+        for (FamilyMember fm : members) {
+            FamilyMemberVO vo = new FamilyMemberVO();
+            vo.setId(fm.getId());
+            vo.setSource("family_member");
+            vo.setUserId(fm.getUserId());
+            vo.setNickname(fm.getNickname());
+            vo.setPhone(fm.getPhone());
+            vo.setAvatar(fm.getAvatar());
+            vo.setGender(fm.getGender());
+            vo.setGeneration(fm.getGeneration());
+            vo.setEffectiveRole(computeEffectiveRole(fm));
+            vo.setRoleLabel(computeEffectiveRole(fm));
+            vo.setIsSpouse(fm.getIsSpouse());
+            vo.setIsSelf(centerMemberId.equals(fm.getId()));
+            vo.setAge(fm.getAge());
+            vo.setLastInteractionAt(fm.getLastInteractionAt());
+
+            FamilyRelationship rel = relMap.get(fm.getId());
+            if (rel != null) {
+                vo.setGenerationValue(rel.getGenerationValue());
+                String label = rel.getRelationshipKey() != null && !rel.getRelationshipKey().isEmpty()
+                        ? rel.getRelationshipKey()
+                        : familyRelationshipService.computeRelationshipLabel(rel.getGenerationValue(), fm.getGender());
+                vo.setRelativeLabel(label);
+                vo.setRelationship(label);
+                vo.setTrustScore(rel.getTrustScore());
+                vo.setIntimacyScore(rel.getIntimacyScore());
+                vo.setCommunicationScore(rel.getCommunicationScore());
+            } else if (centerMemberId.equals(fm.getId())) {
+                vo.setRelationship("我");
+                vo.setRelativeLabel("我");
+            } else {
+                vo.setTrustScore(BigDecimal.ZERO);
+                vo.setIntimacyScore(BigDecimal.ZERO);
+                vo.setCommunicationScore(BigDecimal.ZERO);
+            }
+            result.add(vo);
+        }
+        return result;
+    }
 }

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java

@@ -5,9 +5,11 @@ import com.etotem.cfc.dto.GenerateQuestionnaireDTO;
 import com.etotem.cfc.dto.QuestionnaireSnapshotVO;
 import com.etotem.cfc.dto.SubmitQuestionnaireDTO;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FamilyRelationship;
 import com.etotem.cfc.entity.RelationshipQuestionnaireResponse;
 import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FamilyRelationshipMapper;
 import com.etotem.cfc.mapper.RelationshipQuestionnaireResponseMapper;
 import com.etotem.cfc.mapper.RelationshipQuestionnaireSnapshotMapper;
 import org.slf4j.Logger;
@@ -37,6 +39,9 @@ public class RelationshipQuestionnaireService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private FamilyRelationshipMapper familyRelationshipMapper;
+
     @Resource
     private AIService aiService;
 
@@ -135,6 +140,9 @@ public class RelationshipQuestionnaireService {
         // 更新成员的评分
         updateMemberScores(snapshot.getFamilyMemberId(), scores);
 
+        // 同步写入 pairwise 关系评分(答卷人视角 → 被评成员)
+        syncPairwiseScores(dto.getRespondentId(), snapshot.getFamilyMemberId(), scores);
+
         return response;
     }
 
@@ -301,4 +309,57 @@ public class RelationshipQuestionnaireService {
             familyMemberMapper.updateById(member);
         }
     }
+
+    /**
+     * 将问卷评分写入 family_relationships 表(答卷人视角)
+     * 答卷人无 family_member 记录或关系行不存在时,静默跳过(不阻塞问卷保存)
+     */
+    private void syncPairwiseScores(Long respondentUserId, Long targetMemberId, BigDecimal[] scores) {
+        try {
+            if (respondentUserId == null || targetMemberId == null || scores == null) {
+                return;
+            }
+            // 找到答卷人对应的 family_member 记录
+            LambdaQueryWrapper<FamilyMember> respWrapper = new LambdaQueryWrapper<>();
+            respWrapper.eq(FamilyMember::getUserId, respondentUserId);
+            FamilyMember respondent = familyMemberMapper.selectOne(respWrapper);
+            if (respondent == null) {
+                log.warn("答卷人用户{}无family_member记录,跳过pairwise评分同步", respondentUserId);
+                return;
+            }
+            // 查 from→to 关系行(有则更新,无则插入)
+            LambdaQueryWrapper<FamilyRelationship> relWrapper = new LambdaQueryWrapper<>();
+            relWrapper.eq(FamilyRelationship::getFromMemberId, respondent.getId())
+                    .eq(FamilyRelationship::getToMemberId, targetMemberId);
+            FamilyRelationship rel = familyRelationshipMapper.selectOne(relWrapper);
+            Date now = new Date();
+            if (rel != null) {
+                rel.setTrustScore(scores[0]);
+                rel.setIntimacyScore(scores[1]);
+                rel.setCommunicationScore(scores[2]);
+                rel.setUpdatedAt(now);
+                familyRelationshipMapper.updateById(rel);
+            } else {
+                FamilyMember target = familyMemberMapper.selectById(targetMemberId);
+                if (target == null) {
+                    return;
+                }
+                FamilyRelationship newRel = new FamilyRelationship();
+                newRel.setFamilyId(target.getFamilyId());
+                newRel.setFromMemberId(respondent.getId());
+                newRel.setToMemberId(targetMemberId);
+                int respGen = respondent.getGeneration() != null ? respondent.getGeneration() : 0;
+                int targetGen = target.getGeneration() != null ? target.getGeneration() : 0;
+                newRel.setGenerationValue(respGen - targetGen);
+                newRel.setTrustScore(scores[0]);
+                newRel.setIntimacyScore(scores[1]);
+                newRel.setCommunicationScore(scores[2]);
+                newRel.setCreatedAt(now);
+                newRel.setUpdatedAt(now);
+                familyRelationshipMapper.insert(newRel);
+            }
+        } catch (Exception e) {
+            log.warn("pairwise评分同步失败: {}", e.getMessage());
+        }
+    }
 }

+ 188 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java

@@ -0,0 +1,188 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.ReportFingerprint;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.mapper.ReportFingerprintMapper;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 报告指纹检测服务
+ * 从 DB 加载指纹规则,调 Python 引擎进行检测
+ */
+@Service
+public class ReportFingerprintService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportFingerprintService.class);
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportFingerprintMapper fingerprintMapper;
+
+    @Value("${python.executable:python3}")
+    private String pythonExecutable;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 检测 PDF 文件,返回匹配的报告类型
+     */
+    public Map<String, Object> detect(String pdfPath, String fileName) {
+        List<ReportTypeRegistry> activeTypes = typeRegistryMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getIsActive, true));
+
+        // 构建指纹规则 JSON
+        List<Map<String, Object>> rules = new ArrayList<>();
+        for (ReportTypeRegistry type : activeTypes) {
+            List<ReportFingerprint> fingerprints = fingerprintMapper.selectList(
+                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportFingerprint>()
+                            .eq(ReportFingerprint::getTypeId, type.getTypeId())
+                            .orderByAsc(ReportFingerprint::getSortOrder));
+
+            Map<String, Object> rule = new LinkedHashMap<>();
+            rule.put("typeId", type.getTypeId());
+            rule.put("minConfidence", type.getMinConfidence() != null ? type.getMinConfidence() : 10);
+            rule.put("parserPath", type.getParserPath());
+
+            List<Map<String, Object>> fps = new ArrayList<>();
+            for (ReportFingerprint fp : fingerprints) {
+                Map<String, Object> fpm = new LinkedHashMap<>();
+                fpm.put("pattern", fp.getPattern());
+                fpm.put("weight", fp.getWeight() != null ? fp.getWeight() : 10);
+                fpm.put("isExclusion", fp.getIsExclusion() != null && fp.getIsExclusion());
+                fps.add(fpm);
+            }
+            rule.put("fingerprints", fps);
+            rules.add(rule);
+        }
+
+        return callPythonEngine(rules, pdfPath, fileName);
+    }
+
+    /**
+     * 调用 Python 指纹引擎
+     */
+    private Map<String, Object> callPythonEngine(List<Map<String, Object>> rules, String pdfPath, String fileName) {
+        try {
+            // 将规则写入临时文件
+            File rulesFile = File.createTempFile("fp_rules_", ".json");
+            rulesFile.deleteOnExit();
+            objectMapper.writeValue(rulesFile, rules);
+
+            String scriptPath = getScriptPath("fingerprint_engine.py");
+            if (!new File(scriptPath).exists()) {
+                log.warn("指纹引擎脚本不存在: {}", scriptPath);
+                Map<String, Object> fallback = new LinkedHashMap<>();
+                fallback.put("typeId", "unknown");
+                fallback.put("reason", "script_not_found");
+                return fallback;
+            }
+
+            ProcessBuilder pb = new ProcessBuilder(
+                    pythonExecutable, scriptPath,
+                    "--rules", rulesFile.getAbsolutePath(),
+                    "--pdf", pdfPath,
+                    "--filename", fileName != null ? fileName : "");
+
+            pb.redirectErrorStream(true);
+            Process process = pb.start();
+
+            String output = readStream(process.getInputStream());
+            int exitCode = process.waitFor();
+
+            if (exitCode == 0 && !output.trim().isEmpty()) {
+                return objectMapper.readValue(output.trim(),
+                        new TypeReference<Map<String, Object>>() {});
+            } else {
+                log.warn("指纹引擎退出码={}, 输出={}", exitCode, output);
+                Map<String, Object> fallback = new LinkedHashMap<>();
+                fallback.put("typeId", "unknown");
+                fallback.put("reason", "exit_" + exitCode);
+                return fallback;
+            }
+        } catch (Exception e) {
+            log.error("调用指纹引擎失败", e);
+            Map<String, Object> fallback = new LinkedHashMap<>();
+            fallback.put("typeId", "unknown");
+            fallback.put("reason", e.getMessage());
+            return fallback;
+        }
+    }
+
+    /**
+     * 获取脚本路径,支持多种搜索位置
+     */
+    private String getScriptPath(String scriptName) {
+        String[] searchPaths = {
+            "docs/scripts/" + scriptName,
+            "../docs/scripts/" + scriptName,
+            "/app/cfc/docs/scripts/" + scriptName,
+            scriptName,
+        };
+        for (String path : searchPaths) {
+            if (new File(path).exists()) return path;
+        }
+        return scriptName;
+    }
+
+    /**
+     * 调用 Python 解析器脚本提取结构化数据
+     */
+    public Map<String, Object> runParser(String parserPath, String pdfPath) {
+        try {
+            String scriptPath = getScriptPath(parserPath);
+            if (!new File(scriptPath).exists()) {
+                log.warn("解析器脚本不存在: {}", scriptPath);
+                return null;
+            }
+
+            ProcessBuilder pb = new ProcessBuilder(
+                    pythonExecutable, scriptPath, pdfPath);
+            pb.redirectErrorStream(true);
+            Process process = pb.start();
+
+            String output = readStream(process.getInputStream());
+            int exitCode = process.waitFor();
+
+            if (exitCode == 0 && !output.trim().isEmpty()) {
+                // 尝试解析输出为 JSON
+                String trimmed = output.trim();
+                if (trimmed.startsWith("{")) {
+                    return objectMapper.readValue(trimmed,
+                            new TypeReference<Map<String, Object>>() {});
+                }
+            }
+            log.warn("解析器退出码={}, 输出前200字符={}", exitCode,
+                    output.length() > 200 ? output.substring(0, 200) : output);
+            return null;
+        } catch (Exception e) {
+            log.error("调用解析器失败", e);
+            return null;
+        }
+    }
+
+    private String readStream(InputStream is) throws IOException {
+        ByteArrayOutputStream buf = new ByteArrayOutputStream();
+        byte[] temp = new byte[1024];
+        int read;
+        while ((read = is.read(temp)) > 0) {
+            buf.write(temp, 0, read);
+        }
+        return buf.toString("UTF-8");
+    }
+}

+ 265 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportImportService.java

@@ -0,0 +1,265 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportFingerprint;
+import com.etotem.cfc.entity.ReportParserImport;
+import com.etotem.cfc.mapper.ReportFingerprintMapper;
+import com.etotem.cfc.mapper.ReportParserImportMapper;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/**
+ * 外部报告解析器导入服务
+ * 支持导入指纹规则 + 解析脚本,注册为新报告类型
+ */
+@Service
+public class ReportImportService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportImportService.class);
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportFingerprintMapper fingerprintMapper;
+
+    @Resource
+    private ReportParserImportMapper importMapper;
+
+    @Value("${report.parser.scripts.path:docs/scripts/parsers}")
+    private String scriptsPath;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 解析并导入 JSON 格式的导入包
+     * 返回解析后的导入记录(pending 状态,待人工审核)
+     */
+    public Result<ReportParserImport> importPackage(String fileName, String jsonContent, Long userId) {
+        try {
+            Map<String, Object> pkg = objectMapper.readValue(jsonContent,
+                    new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
+            return doImport(fileName, jsonContent, pkg, userId);
+        } catch (Exception e) {
+            log.error("解析导入包JSON失败", e);
+            return Result.error("导入包格式错误: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 解析并导入 JSON 文件
+     */
+    public Result<ReportParserImport> importPackageFile(File jsonFile, Long userId) {
+        try {
+            String jsonContent = new String(Files.readAllBytes(jsonFile.toPath()), StandardCharsets.UTF_8);
+            return importPackage(jsonFile.getName(), jsonContent, userId);
+        } catch (Exception e) {
+            log.error("读取导入包文件失败", e);
+            return Result.error("读取导入包失败: " + e.getMessage());
+        }
+    }
+
+    private Result<ReportParserImport> doImport(String fileName, String jsonContent,
+                                                 Map<String, Object> pkg, Long userId) {
+        // 校验必填字段
+        String packageName = str(pkg.get("packageName"));
+        String typeId = str(pkg.get("typeId"));
+        String displayName = str(pkg.get("displayName"));
+        String parserScript = str(pkg.get("parserScript"));
+
+        if (packageName == null || packageName.isEmpty()) {
+            return Result.error("导入包缺少 packageName");
+        }
+        if (typeId == null || typeId.isEmpty()) {
+            return Result.error("导入包缺少 typeId");
+        }
+        if (displayName == null || displayName.isEmpty()) {
+            return Result.error("导入包缺少 displayName");
+        }
+        if (!isValidTypeId(typeId)) {
+            return Result.error("typeId 格式不正确,只能包含字母、数字、下划线");
+        }
+
+        // 校验 typeId 是否已存在
+        Long exists = typeRegistryMapper.selectCount(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getTypeId, typeId));
+        if (exists != null && exists > 0) {
+            return Result.error("typeId 已存在: " + typeId);
+        }
+
+        // 校验指纹规则
+        Object fpsObj = pkg.get("fingerprints");
+        List<Map<String, Object>> fingerprints = new ArrayList<>();
+        if (fpsObj instanceof List) {
+            for (Object fp : (List<?>) fpsObj) {
+                if (fp instanceof Map) {
+                    fingerprints.add((Map<String, Object>) fp);
+                }
+            }
+        }
+        if (fingerprints.isEmpty()) {
+            return Result.error("导入包缺少 fingerprints 指纹规则");
+        }
+
+        // 校验指纹字段
+        for (Map<String, Object> fp : fingerprints) {
+            String pattern = str(fp.get("pattern"));
+            if (pattern == null || pattern.isEmpty()) {
+                return Result.error("指纹规则缺少 pattern 文本特征");
+            }
+        }
+
+        // 创建导入记录(pending 状态)
+        ReportParserImport record = new ReportParserImport();
+        record.setPackageName(packageName);
+        record.setPackageFileUrl(fileName);
+        record.setFingerprintJson(jsonContent);
+        record.setParserScript(parserScript);
+        record.setStatus(ReportParserImport.STATUS_PENDING);
+        record.setImportedBy(userId);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        importMapper.insert(record);
+
+        log.info("导入包已记录待审核: packageName={}, typeId={}, importId={}",
+                packageName, typeId, record.getId());
+        return Result.success(record);
+    }
+
+    /**
+     * 审核通过导入:注册报告类型 + 指纹规则 + 部署解析脚本
+     */
+    public Result<Map<String, Object>> approveImport(Long importId, Long reviewerId) {
+        ReportParserImport record = importMapper.selectById(importId);
+        if (record == null) {
+            return Result.error("导入记录不存在");
+        }
+        if (!ReportParserImport.STATUS_PENDING.equals(record.getStatus())) {
+            return Result.error("该记录已" + record.getStatus());
+        }
+
+        try {
+            Map<String, Object> pkg = objectMapper.readValue(record.getFingerprintJson(),
+                    new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
+
+            String typeId = str(pkg.get("typeId"));
+            String family = str(pkg.get("family"));
+            String displayName = str(pkg.get("displayName"));
+            String description = str(pkg.get("description"));
+            String parserScript = str(pkg.get("parserScript"));
+
+            // 1. 注册报告类型
+            ReportTypeRegistry type = new ReportTypeRegistry();
+            type.setTypeId(typeId);
+            type.setFamily(family != null ? family : "custom");
+            type.setDisplayName(displayName);
+            type.setDescription(description);
+            type.setParserPath(typeId + ".py");
+            type.setMinConfidence(10);
+            type.setIsActive(true);
+            type.setSource(ReportTypeRegistry.SOURCE_IMPORTED);
+            type.setReviewStatus(ReportTypeRegistry.REVIEW_APPROVED);
+            type.setVersion("1.0");
+            type.setCreatedBy(reviewerId);
+            type.setCreatedAt(new Date());
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.insert(type);
+
+            // 2. 注册指纹规则
+            Object fpsObj = pkg.get("fingerprints");
+            if (fpsObj instanceof List) {
+                int sort = 0;
+                for (Object fp : (List<?>) fpsObj) {
+                    if (fp instanceof Map) {
+                        Map<String, Object> fpm = (Map<String, Object>) fp;
+                        ReportFingerprint fpEnt = new ReportFingerprint();
+                        fpEnt.setTypeId(typeId);
+                        fpEnt.setPattern(str(fpm.get("pattern")));
+                        fpEnt.setWeight(fpm.get("weight") != null
+                                ? ((Number) fpm.get("weight")).intValue() : 10);
+                        fpEnt.setIsExclusion(fpm.get("isExclusion") != null
+                                && Boolean.TRUE.equals(fpm.get("isExclusion")));
+                        fpEnt.setSortOrder(sort++);
+                        fpEnt.setCreatedAt(new Date());
+                        fingerprintMapper.insert(fpEnt);
+                    }
+                }
+            }
+
+            // 3. 部署解析脚本
+            if (parserScript != null && !parserScript.isEmpty()) {
+                deployParserScript(typeId, parserScript);
+            }
+
+            // 4. 更新导入记录
+            record.setStatus(ReportParserImport.STATUS_APPROVED);
+            record.setReviewedBy(reviewerId);
+            record.setUpdatedAt(new Date());
+            importMapper.updateById(record);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("typeId", typeId);
+            result.put("displayName", displayName);
+            result.put("importId", importId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("审核通过导入失败 importId={}", importId, e);
+            return Result.error("导入失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 驳回导入
+     */
+    public Result<Void> rejectImport(Long importId, Long reviewerId, String note) {
+        ReportParserImport record = importMapper.selectById(importId);
+        if (record == null) {
+            return Result.error("导入记录不存在");
+        }
+        if (!ReportParserImport.STATUS_PENDING.equals(record.getStatus())) {
+            return Result.error("该记录已" + record.getStatus());
+        }
+        record.setStatus(ReportParserImport.STATUS_REJECTED);
+        record.setReviewedBy(reviewerId);
+        record.setReviewNote(note);
+        record.setUpdatedAt(new Date());
+        importMapper.updateById(record);
+        return Result.success();
+    }
+
+    private void deployParserScript(String typeId, String scriptContent) {
+        try {
+            File dir = new File(scriptsPath);
+            if (!dir.exists() && !dir.mkdirs()) {
+                log.warn("无法创建解析脚本目录: {}", scriptsPath);
+                return;
+            }
+            File scriptFile = new File(dir, typeId + ".py");
+            Files.write(scriptFile.toPath(), scriptContent.getBytes(StandardCharsets.UTF_8));
+            log.info("解析脚本已部署: {}", scriptFile.getAbsolutePath());
+        } catch (Exception e) {
+            log.error("部署解析脚本失败", e);
+        }
+    }
+
+    private boolean isValidTypeId(String typeId) {
+        return Pattern.matches("^[a-zA-Z0-9_]+$", typeId);
+    }
+
+    private String str(Object o) {
+        return o == null ? null : String.valueOf(o);
+    }
+}

+ 207 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportParserDispatchService.java

@@ -0,0 +1,207 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportUnknownCluster;
+import com.etotem.cfc.entity.ReportUnknownUpload;
+import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
+import com.etotem.cfc.mapper.ReportUnknownUploadMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+
+/**
+ * 报告解析调度服务
+ * 1. 指纹检测 → 2a. 专用解析器 / 2b. LLM 兜底
+ */
+@Service
+public class ReportParserDispatchService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParserDispatchService.class);
+
+    @Resource
+    private ReportFingerprintService fingerprintService;
+
+    @Resource
+    private ReportUnknownClusterMapper unknownClusterMapper;
+
+    @Resource
+    private ReportUnknownUploadMapper unknownUploadMapper;
+
+    @Autowired
+    private RestTemplate restTemplate;
+
+    @Value("${cfc.langgraph.url:http://localhost:9000}")
+    private String langgraphUrl;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    private static final int AUTO_LEARN_THRESHOLD = 3;
+
+    /**
+     * 解析报告:指纹检测 → 专用解析器 或 LLM 兜底
+     */
+    public Result<Map<String, Object>> parseReport(String pdfPath, String fileName) {
+        // 1. 指纹检测
+        Map<String, Object> fpResult = fingerprintService.detect(pdfPath, fileName);
+        String typeId = (String) fpResult.get("typeId");
+        log.info("指纹检测结果: typeId={}, score={}", typeId, fpResult.get("score"));
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("fingerprintResult", fpResult);
+
+        // 2a. 已知类型 → 调解析器
+        if (!"unknown".equals(typeId) && fpResult.get("parserPath") != null) {
+            String parserPath = (String) fpResult.get("parserPath");
+            Map<String, Object> parsed = fingerprintService.runParser(parserPath, pdfPath);
+            result.put("typeId", typeId);
+            result.put("parsedData", parsed);
+            result.put("parseMethod", "dedicated_parser");
+            return Result.success(result);
+        }
+
+        // 2b. Unknown → LLM 兜底
+        try {
+            String llmResult = callLlmFallback(pdfPath);
+            Map<String, Object> llmParsed = objectMapper.readValue(llmResult,
+                    new TypeReference<Map<String, Object>>() {});
+
+            result.put("typeId", "unknown");
+            result.put("parsedData", llmParsed);
+            result.put("parseMethod", "llm_fallback");
+
+            // 记录到聚类表
+            recordUnknown(pdfPath, llmParsed, fileName);
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("LLM 兜底解析失败", e);
+            return Result.error("LLM 解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 调用 cfc-langgraph 的 LLM 解析端点
+     */
+    private String callLlmFallback(String pdfPath) throws Exception {
+        String url = langgraphUrl + "/api/v1/report/parse";
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("file_path", pdfPath);
+        body.put("family_id", null);
+        body.put("user_id", null);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
+
+        ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+
+        if (response.getStatusCode() == HttpStatus.OK) {
+            return response.getBody();
+        }
+        throw new RuntimeException("LLM 返回状态码: " + response.getStatusCode());
+    }
+
+    /**
+     * 记录未知报告到聚类表(自学习用)
+     */
+    private void recordUnknown(String pdfPath, Map<String, Object> llmResult, String fileName) {
+        try {
+            String fileHash = computeFileHash(pdfPath);
+            // 提取文本特征用于聚类
+            String textFeatures = extractTextFeatures(pdfPath);
+            String clusterKey = computeClusterKey(textFeatures);
+
+            // 查找或创建聚类
+            ReportUnknownCluster cluster = unknownClusterMapper.selectOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportUnknownCluster>()
+                            .eq(ReportUnknownCluster::getClusterKey, clusterKey));
+
+            if (cluster == null) {
+                cluster = new ReportUnknownCluster();
+                cluster.setClusterKey(clusterKey);
+                cluster.setCommonSignatures(textFeatures);
+                cluster.setLlmTemplate(objectMapper.writeValueAsString(llmResult));
+                cluster.setReportCount(1);
+                cluster.setStatus(ReportUnknownCluster.STATUS_COLLECTING);
+                cluster.setCreatedAt(new Date());
+                cluster.setUpdatedAt(new Date());
+                unknownClusterMapper.insert(cluster);
+            } else {
+                cluster.setReportCount(cluster.getReportCount() == null ? 1 : cluster.getReportCount() + 1);
+                cluster.setUpdatedAt(new Date());
+                // 满 AUTO_LEARN_THRESHOLD 份标记 ready
+                if (cluster.getReportCount() >= AUTO_LEARN_THRESHOLD
+                        && ReportUnknownCluster.STATUS_COLLECTING.equals(cluster.getStatus())) {
+                    cluster.setStatus(ReportUnknownCluster.STATUS_READY);
+                }
+                unknownClusterMapper.updateById(cluster);
+            }
+
+            // 记录上传明细
+            ReportUnknownUpload upload = new ReportUnknownUpload();
+            upload.setClusterId(cluster.getId());
+            upload.setFileUrl(pdfPath);
+            upload.setFileHash(fileHash);
+            upload.setLlmResult(objectMapper.writeValueAsString(llmResult));
+            upload.setUploadedAt(new Date());
+            unknownUploadMapper.insert(upload);
+
+            log.info("已记录未知报告: clusterKey={}, count={}", clusterKey, cluster.getReportCount());
+        } catch (Exception e) {
+            log.warn("记录未知报告失败", e);
+        }
+    }
+
+    private String computeFileHash(String filePath) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] data = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(filePath));
+            byte[] hash = md.digest(data);
+            StringBuilder sb = new StringBuilder();
+            for (byte b : hash) sb.append(String.format("%02x", b));
+            return sb.toString();
+        } catch (Exception e) {
+            return String.valueOf(System.currentTimeMillis());
+        }
+    }
+
+    private String computeClusterKey(String textFeatures) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] hash = md.digest(textFeatures.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : hash) sb.append(String.format("%02x", b));
+            return sb.substring(0, 16);
+        } catch (Exception e) {
+            return String.valueOf(textFeatures.hashCode());
+        }
+    }
+
+    private String extractTextFeatures(String pdfPath) {
+        try {
+            try (org.apache.pdfbox.pdmodel.PDDocument document =
+                         org.apache.pdfbox.pdmodel.PDDocument.load(new File(pdfPath))) {
+                org.apache.pdfbox.text.PDFTextStripper stripper = new org.apache.pdfbox.text.PDFTextStripper();
+                String text = stripper.getText(document);
+                // 取前 2000 字符作为特征
+                return text != null && text.length() > 2000 ? text.substring(0, 2000) : text;
+            }
+        } catch (Exception e) {
+            log.warn("提取PDF文本特征失败: {}", e.getMessage());
+            return "";
+        }
+    }
+}

+ 23 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/UnifiedKnowledgeBaseService.java

@@ -127,8 +127,18 @@ public class UnifiedKnowledgeBaseService {
                     continue;
                 }
                 try {
-                    Map<String, Object> content = objectMapper.readValue(row.getContent(),
-                            new TypeReference<Map<String, Object>>() {});
+                    Object parsed = objectMapper.readValue(row.getContent(), Object.class);
+                    Map<String, Object> content;
+                    if (parsed instanceof Map) {
+                        content = (Map<String, Object>) parsed;
+                    } else if (parsed instanceof List) {
+                        // 兼容菌属-食材映射等顶层为数组的模块(包装为 {"_list": list})
+                        content = new java.util.LinkedHashMap<>();
+                        content.put("_list", parsed);
+                    } else {
+                        content = new java.util.LinkedHashMap<>();
+                        content.put("_value", parsed);
+                    }
                     sectionMap.put(row.getModuleKey(), content);
                 } catch (Exception e) {
                     log.warn("解析模块[{}]内容失败: {}", row.getModuleKey(), e.getMessage());
@@ -345,9 +355,19 @@ public class UnifiedKnowledgeBaseService {
 
         // --- 菌属-食材映射: [{拉丁名, 中文名, 分组, 功能分类, 功能说明, ...}] ---
         Object mapping = sectionMap.get("菌属-食材映射");
+        List<Object> mappingList = null;
         if (mapping instanceof List) {
+            mappingList = (List<Object>) mapping;
+        } else if (mapping instanceof Map) {
+            // 兼容 DB 顶层为 Map 但实际数据在 _list 键下的情况
+            Object inner = ((Map<String, Object>) mapping).get("_list");
+            if (inner instanceof List) {
+                mappingList = (List<Object>) inner;
+            }
+        }
+        if (mappingList != null) {
             List<Map<String, Object>> list = new ArrayList<>();
-            for (Object item : (List<Object>) mapping) {
+            for (Object item : mappingList) {
                 if (item instanceof Map) {
                     list.add((Map<String, Object>) item);
                 }

+ 3 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -520,7 +520,7 @@ private FamilyInvitationService familyInvitationService;
         // 如果有邀请码,提交加入家庭请求(需管理员审批)
         if (dto.getInviteCode() != null && !dto.getInviteCode().isEmpty()) {
             try {
-                familyJoinRequestService.createJoinRequest(user.getId(), dto.getInviteCode());
+                familyJoinRequestService.createJoinRequest(user.getId(), dto.getInviteCode(), null);
             } catch (Exception e) {
                 // 忽略邀请码错误
             }
@@ -1415,7 +1415,7 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
             user = existingUser;
             ensureUserHasFamily(user);
             try {
-                familyJoinRequestService.createJoinRequest(user.getId(), inviteCode);
+                familyJoinRequestService.createJoinRequest(user.getId(), inviteCode, null);
                 log.info("已为用户 {} 提交加入家庭 {} 的请求", user.getId(), family.getId());
             } catch (Exception e) {
                 log.warn("提交加入家庭请求失败: {}", e.getMessage());
@@ -1433,7 +1433,7 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
             user.setUpdatedAt(new Date());
             createUserAndFamily(user);
             try {
-                familyJoinRequestService.createJoinRequest(user.getId(), inviteCode);
+                familyJoinRequestService.createJoinRequest(user.getId(), inviteCode, null);
                 log.info("已为用户 {} 提交加入家庭 {} 的请求", user.getId(), family.getId());
             } catch (Exception e) {
                 log.warn("提交加入家庭请求失败: {}", e.getMessage());

+ 77 - 1
cfc-backend/src/main/resources/schema.sql

@@ -748,7 +748,7 @@ CREATE TABLE IF NOT EXISTS verification_codes (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
     phone VARCHAR(11) NOT NULL COMMENT '手机号',
     code VARCHAR(6) NOT NULL COMMENT '验证码',
-    type VARCHAR(20) NOT NULL COMMENT '验证码类型',
+    type VARCHAR(20) NOT NULL DEFAULT 'login' COMMENT '验证码类型: login/register/reset',
     expires_in INT DEFAULT 300 COMMENT '过期时间(秒)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     expires_at DATETIME NOT NULL COMMENT '过期时间',
@@ -1990,6 +1990,9 @@ CREATE TABLE IF NOT EXISTS family_relationships (
     to_member_id     BIGINT       NOT NULL COMMENT '关系终点成员ID',
     generation_value INT          NOT NULL COMMENT '辈分值: -1=子女, 0=同辈, 1=父母, 2=祖父母, 3=曾祖父母',
     relationship_key VARCHAR(50)  COMMENT '关系标签key(冗余用于显示, 如father/mother/son/daughter/spouse/sibling)',
+    trust_score        DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)',
+    intimacy_score     DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)',
+    communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)',
     created_at       DATETIME     DEFAULT CURRENT_TIMESTAMP,
     updated_at       DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_from_to (from_member_id, to_member_id),
@@ -4300,3 +4303,76 @@ CREATE TABLE IF NOT EXISTS diet_record_items (
     source VARCHAR(20) DEFAULT NULL COMMENT 'ai_recognized/manual/from_recommendation',
     INDEX idx_record (record_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录食材明细表';
+
+-- 报告指纹检测与解析器调度系统
+
+-- 报告类型注册表
+CREATE TABLE IF NOT EXISTS report_type_registry (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    type_id VARCHAR(64) NOT NULL COMMENT '类型标识,如 dan_a1/gut_beijing',
+    family VARCHAR(32) DEFAULT NULL COMMENT '报告家族: dan/gut_flora/其他',
+    display_name VARCHAR(100) NOT NULL COMMENT '显示名称',
+    description VARCHAR(500) DEFAULT NULL COMMENT '描述',
+    parser_path VARCHAR(255) DEFAULT NULL COMMENT 'Python解析器脚本路径',
+    min_confidence INT DEFAULT 10 COMMENT '识别阈值',
+    is_active TINYINT(1) DEFAULT 1 COMMENT '启用状态',
+    source VARCHAR(20) DEFAULT 'prebuilt' COMMENT '来源: prebuilt/imported/auto_generated',
+    review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected',
+    version VARCHAR(20) DEFAULT '1.0' COMMENT '版本',
+    created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_type_id (type_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告类型注册表';
+
+-- 报告指纹规则表
+CREATE TABLE IF NOT EXISTS report_fingerprint (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    type_id VARCHAR(64) NOT NULL COMMENT '关联report_type_registry.type_id',
+    pattern VARCHAR(200) NOT NULL COMMENT '文本特征',
+    weight INT DEFAULT 10 COMMENT '权重(正=加分,负=排除扣分)',
+    is_exclusion TINYINT(1) DEFAULT 0 COMMENT '是否为排除规则',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_fp_type (type_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告指纹规则表';
+
+-- 未知报告聚类表
+CREATE TABLE IF NOT EXISTS report_unknown_cluster (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    cluster_key VARCHAR(64) NOT NULL COMMENT '聚类哈希键(同类报告分组)',
+    common_signatures TEXT COMMENT '公共文本特征JSON(用于生成指纹)',
+    llm_template TEXT COMMENT 'LLM解析结果模板JSON',
+    report_count INT DEFAULT 0 COMMENT '累计报告数',
+    status VARCHAR(20) DEFAULT 'collecting' COMMENT '状态: collecting/ready/generated/imported',
+    generated_type_id VARCHAR(64) DEFAULT NULL COMMENT '生成的类型ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_cluster_key (cluster_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告聚类表';
+
+-- 未知报告上传明细表
+CREATE TABLE IF NOT EXISTS report_unknown_upload (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    cluster_id BIGINT DEFAULT NULL COMMENT '关联report_unknown_cluster.id',
+    file_url VARCHAR(500) DEFAULT NULL COMMENT '文件路径',
+    file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件哈希',
+    llm_result TEXT COMMENT 'LLM解析结果JSON',
+    uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_unknown_cluster (cluster_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告上传明细表';
+
+-- 报告解析器导入记录表
+CREATE TABLE IF NOT EXISTS report_parser_import (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    package_name VARCHAR(100) NOT NULL COMMENT '导入包名称',
+    package_file_url VARCHAR(500) DEFAULT NULL COMMENT '导入包文件路径',
+    fingerprint_json TEXT COMMENT '指纹规则JSON',
+    parser_script TEXT COMMENT '解析器脚本内容',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected',
+    imported_by BIGINT DEFAULT NULL COMMENT '导入人ID',
+    reviewed_by BIGINT DEFAULT NULL COMMENT '审核人ID',
+    review_note VARCHAR(500) DEFAULT NULL COMMENT '审核意见',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告解析器导入记录表';

+ 3 - 2
cfc-backend/src/test/java/com/etotem/cfc/config/FamilyAccessInterceptorTest.java

@@ -10,6 +10,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.MockitoAnnotations;
 import org.junit.jupiter.api.extension.ExtendWith;
 
+import java.io.PrintWriter;
+import java.io.StringWriter;
 import java.util.Collections;
 import java.util.Set;
 import javax.servlet.http.HttpServletResponse;
@@ -67,6 +69,7 @@ public class FamilyAccessInterceptorTest {
         request.setParameter("familyId", requestedFamilyId.toString());
         request.setAttribute("userId", userId);
 
+        when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
         boolean result = familyAccessInterceptor.preHandle(request, response, null);
         assertFalse(result);
         // 验证发送了 403 响应
@@ -111,8 +114,6 @@ public class FamilyAccessInterceptorTest {
     @Test
     public void testAdminPath() throws Exception {
         Long userId = 1L;
-        User user = createMockUser(userId, 1L, "admin");
-        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(1L));
 
         MockHttpServletRequest request = new MockHttpServletRequest();
         request.setMethod("POST");

+ 17 - 3
cfc-backend/src/test/java/com/etotem/cfc/service/DimensionScoreServiceTest.java

@@ -48,6 +48,15 @@ public class DimensionScoreServiceTest {
     @Mock
     private HealthDataSourceRecordService dataSourceRecordService;
 
+    @Mock
+    private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private com.etotem.cfc.mapper.DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private DimensionEnergySyncService dimensionEnergySyncService;
+
     private DimensionScoreServiceImpl service;
 
     @BeforeEach
@@ -59,6 +68,9 @@ public class DimensionScoreServiceTest {
         setField(service, "normMapper", normMapper);
         setField(service, "healthReportService", healthReportService);
         setField(service, "dataSourceRecordService", dataSourceRecordService);
+        setField(service, "familyMemberMapper", familyMemberMapper);
+        setField(service, "danAssessmentResultMapper", danAssessmentResultMapper);
+        setField(service, "dimensionEnergySyncService", dimensionEnergySyncService);
     }
 
     private void setField(Object target, String fieldName, Object value) {
@@ -80,11 +92,13 @@ public class DimensionScoreServiceTest {
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
         assertEquals(1L, vo.getMemberId());
-        assertNull(vo.getHealthIndex());
+        // 产品设计:无个人数据时用 AVERAGE 默认值 65 填充,健康指数按 7 维平均
+        assertEquals(new BigDecimal("65.00"), vo.getHealthIndex());
         assertEquals(7, vo.getDimensions().size());
         for (HealthDimensionVO.DimensionItem item : vo.getDimensions()) {
-            assertEquals("none", item.getLevel());
-            assertNull(item.getScore());
+            assertEquals(new BigDecimal("65"), item.getScore());
+            assertEquals("AVERAGE", item.getDataSource());
+            assertNotNull(item.getLevel());
         }
     }
 

+ 26 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java

@@ -18,6 +18,7 @@ import org.mockito.MockitoAnnotations;
 import java.util.Date;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -144,6 +145,31 @@ public class FamilyChallengeServiceTest {
         verify(familyChallengeMapper).update(any(), any());
     }
 
+    @Test
+    void getTemplates_shouldReturnAllTemplatesWithParsedRewardPoints() {
+        java.util.List<java.util.Map<String, Object>> templates = service.getTemplates();
+
+        assertEquals(7, templates.size());
+        for (java.util.Map<String, Object> t : templates) {
+            assertTrue(t.get("rewardPoints") instanceof Integer, "rewardPoints 必须可解析为 Integer");
+            assertTrue(t.get("durationDays") instanceof Integer, "durationDays 必须可解析为 Integer");
+            assertTrue(t.get("targetValue") instanceof Integer, "targetValue 必须可解析为 Integer");
+            assertTrue(t.get("targetMode") instanceof String, "targetMode 必须存在");
+        }
+        java.util.Map<String, Object> first = templates.get(0);
+        assertEquals("full_checkin", first.get("type"));
+        assertEquals("全员打卡挑战", first.get("title"));
+        assertEquals(Integer.valueOf(3), first.get("durationDays"));
+        assertEquals(Integer.valueOf(3), first.get("targetValue"));
+        assertEquals(Integer.valueOf(50), first.get("rewardPoints"));
+        assertEquals("all_members", first.get("targetMode"));
+        java.util.Map<String, Object> sports = templates.get(1);
+        assertEquals("sports", sports.get("type"));
+        assertEquals("aggregate", sports.get("targetMode"));
+        assertEquals(Integer.valueOf(300), sports.get("targetValue"));
+        assertEquals(Integer.valueOf(100), sports.get("rewardPoints"));
+    }
+
     @Test
     void respondChallenge_agree_shouldTransitionToActiveWhenAllAgreed() {
         FamilyChallenge challenge = new FamilyChallenge();

+ 7 - 4
cfc-backend/src/test/java/com/etotem/cfc/service/FoodRecommendServiceTest.java

@@ -9,6 +9,7 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.io.Serializable;
 import java.util.*;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -147,13 +148,15 @@ public class FoodRecommendServiceTest {
         FoodRecommendIndex existing = mockIndex(1L, 100L, "食材A", 80);
         FoodRecommendIndex existing2 = mockIndex(1L, 200L, "食材B", 75);
         when(healthReportService.getLatestReport(1L)).thenReturn(report);
-        when(reportFoodSuitabilityMapper.selectList(any())).thenReturn(Collections.emptyList()); // no suit data
+        // 新报告只含食材C,existing A/B 被移除
+        ReportFoodSuitability newSuit = mockSuit(1L, 300L, "食材C", 70);
+        when(reportFoodSuitabilityMapper.selectList(any())).thenReturn(Collections.singletonList(newSuit));
         when(foodRecommendIndexMapper.selectList(any())).thenReturn(Arrays.asList(existing, existing2));
 
         service.recalculateForUser(1L);
 
         // Both should be deleted and logged
-        verify(foodRecommendIndexMapper, times(2)).deleteById(any());
+        verify(foodRecommendIndexMapper, times(2)).deleteById(any(Serializable.class));
         verify(foodRecommendIdxLogMapper, times(2)).insert(any());
     }
 
@@ -196,8 +199,8 @@ public class FoodRecommendServiceTest {
 
     @Test
     public void getByFamily_returnsAllMemberIndices() {
-        FamilyMember m1 = mockMember(1L, 1L); // memberId=1, userId=10
-        FamilyMember m2 = mockMember(2L, 2L); // memberId=2, userId=20
+        FamilyMember m1 = mockMember(1L, 10L);
+        FamilyMember m2 = mockMember(2L, 20L);
         FoodRecommendIndex idx1 = mockIndex(10L, 100L, "食材A", 80);
         FoodRecommendIndex idx2 = mockIndex(20L, 200L, "食材B", 75);
         when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(m1, m2));

+ 1 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FortuneServiceTest.java

@@ -79,6 +79,7 @@ class FortuneServiceTest {
     void testGetAndSaveFamilyFortune() {
         LocalDate testDate = LocalDate.of(2026, 7, 15);
         FamilyFortune testFortune = new FamilyFortune();
+        testFortune.setId(1L);
         testFortune.setFamilyId(1L);
         testFortune.setFortuneDate(testDate);
         testFortune.setDominantElement("木");

+ 8 - 4
cfc-backend/src/test/java/com/etotem/cfc/service/ProductServiceTest.java

@@ -31,7 +31,10 @@ public class ProductServiceTest {
     private UserMapper userMapper;
 
     @Mock
-    private DanshopSyncService danshopSyncService;
+    private ProductGiftRuleService productGiftRuleService;
+
+    @Mock
+    private ProductDeliveryConfigService productDeliveryConfigService;
 
     private ProductService service;
 
@@ -41,7 +44,8 @@ public class ProductServiceTest {
         service = new ProductService();
         setField(service, "productMapper", productMapper);
         setField(service, "userMapper", userMapper);
-        setField(service, "danshopSyncService", danshopSyncService);
+        setField(service, "productGiftRuleService", productGiftRuleService);
+        setField(service, "productDeliveryConfigService", productDeliveryConfigService);
     }
 
     private static void setField(Object target, String fieldName, Object value) throws Exception {
@@ -76,7 +80,7 @@ public class ProductServiceTest {
 
     @Test
     public void shelve_offShelf_success() {
-        Product product = mockProduct(1L, 100L, "approved");
+        Product product = mockProduct(1L, 100L, "on_shelf");
         when(productMapper.selectById(1L)).thenReturn(product);
 
         Result<String> result = service.shelve(1L, 100L, "unshelve");
@@ -117,7 +121,7 @@ public class ProductServiceTest {
         Result<String> result = service.shelve(1L, 100L, "shelve");
 
         assertEquals(500, result.getCode());
-        assertEquals("商品未通过审核,无法上架", result.getMessage());
+        assertEquals("商品未通过审核,无法上架", result.getMessage());
         verify(productMapper, never()).updateById(any());
     }
 

+ 32 - 0
cfc-frontend/components/FamilyMemberCard.vue

@@ -13,6 +13,11 @@
       </view>
     </view>
 
+    <!-- 关系现状三色点:信任/亲密度/沟通 -->
+    <view class="card-relation-dots">
+      <view class="relation-dot" v-for="(color, idx) in relationDotColors" :key="idx" :style="{ background: color }"></view>
+    </view>
+
     <!-- 昵称 -->
     <text class="card-nickname">{{ member.nickname }}</text>
 
@@ -63,6 +68,20 @@ export default {
       if (role === 'child') return '#0EA5E9'
       if (role === 'teacher') return '#8B5CF6'
       return '#9CA3AF'
+    },
+    // 关系现状三色点颜色:信任/亲密度/沟通(绿≥70 / 黄40-69 / 红<40 / 灰空)
+    relationDotColors: function() {
+      var m = this.member
+      var empty = '#E5E7EB'
+      if (!m) return [empty, empty, empty]
+      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
+      return keys.map(function(k) {
+        var v = Number(m[k]) || 0
+        if (v <= 0) return empty
+        if (v >= 70) return '#10B981'
+        if (v >= 40) return '#F59E0B'
+        return '#EF4444'
+      })
     }
   },
   methods: {
@@ -139,6 +158,19 @@ export default {
 .spouse-icon {
   font-size: 18rpx;
 }
+.card-relation-dots {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  margin-top: 4rpx;
+  height: 12rpx;
+}
+.relation-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  margin: 0 4rpx;
+}
 .card-nickname {
   font-size: 22rpx;
   color: #374151;

+ 326 - 711
cfc-frontend/components/FamilyMemberStrip.vue

@@ -1,226 +1,226 @@
 <template>
-  <view class="member-strip">
-    <!-- 浮标:家庭图标(默认态)→ 当前成员头像(展开态) -->
-    <view class="fab-wrap">
-      <view class="fab-avatar" :class="{ active: fanOpen }" @tap="toggleFan">
-        <!-- 默认态:家庭图标 -->
-        <view class="fab-icon-default" v-if="!fanOpen">
-          <text class="fab-icon-emoji">👨‍👩‍👧‍👦</text>
-        </view>
-        <!-- 展开态:当前成员头像 -->
-        <image class="fab-img" :class="{ 'fab-img-show': fanOpen }" :src="currentMember.avatar || defaultAvatar" mode="aspectFill" />
-        <view class="fab-ring" :class="{ 'ring-active': fanOpen }"></view>
-        <view class="fab-badge" v-if="pendingCount > 0 && isAdmin" @tap.stop="openRequests">
-          <text class="badge-count">{{ pendingCount }}</text>
+  <view class="family-member-strip">
+    <!-- 有成员:滚动条 / 居中模式 + 长按菜单 -->
+    <view v-if="renderedMembers && renderedMembers.length > 0">
+      <scroll-view
+        class="strip-scroll"
+        scroll-x
+        :scroll-with-animation="true"
+        :enhanced="true"
+        show-scrollbar="false"
+        v-if="renderedMembers.length >= 4"
+      >
+        <view class="strip-inner scroll-mode">
+          <FamilyMemberCard
+            v-for="member in renderedMembers"
+            :key="member.id"
+            :member="member"
+            :isSelected="member.id === selectedMemberId"
+            :energyScore="getMemberEnergy(member)"
+            @select="onSelect"
+            @longpress="onLongPress"
+          />
         </view>
+      </scroll-view>
+      <view class="strip-inner center-mode" v-else>
+        <FamilyMemberCard
+          v-for="member in renderedMembers"
+          :key="member.id"
+          :member="member"
+          :isSelected="member.id === selectedMemberId"
+          :energyScore="getMemberEnergy(member)"
+          @select="onSelect"
+          @longpress="onLongPress"
+        />
       </view>
-      <view class="fab-pulse" v-if="!fanOpen"></view>
-    </view>
 
-    <!-- 扇形展开成员 -->
-    <view v-if="fanOpen" class="fan-wrap show">
-      <view class="fan-overlay" @tap="closeFan"></view>
-      <view class="fan-container">
-        <view
-          class="fan-card"
-          v-for="(member, index) in members"
-          :key="member.id"
-          :style="getFanPosition(member, index)"
-          :class="{ selected: member.id === selectedMemberId }"
-          @tap="selectMember(member)"
-          @longpress="onFanLongPress(member)"
-        >
-          <view class="fan-avatar-wrap">
-            <image class="fan-avatar" :src="member.avatar || defaultAvatar" mode="aspectFill" />
-            <view class="fan-relation-badge" :style="{ background: getRelationColor(member) }">
-              <text class="fan-relation-text">{{ member.relationship || member.relativeLabel || member.roleLabel || '成员' }}</text>
-            </view>
-            <view class="fan-spouse-mark" v-if="member.isSpouse">
-              <text class="spouse-icon">💍</text>
+      <!-- 长按操作菜单 -->
+      <view class="action-overlay" v-if="showActionMenu" @click="closeMenu"></view>
+      <view class="action-sheet" v-if="showActionMenu">
+        <view class="action-header">
+          <image class="action-avatar" :src="actionMember ? (actionMember.avatar || '/static/default-avatar.png') : ''" mode="aspectFill" />
+          <view class="action-header-info">
+            <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
+            <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || actionMember.roleLabel || '') : '' }}</text>
+          </view>
+        </view>
+        <!-- 关系现状区块 -->
+        <view class="action-scores" v-if="actionMember">
+          <view class="score-row">
+            <text class="score-label">信任度</text>
+            <view class="score-bar-track">
+              <view class="score-bar-fill trust" :style="{ width: scoreWidths[0] }"></view>
             </view>
+            <text class="score-value">{{ scoreValues[0] }}</text>
           </view>
-          <text class="fan-nickname">{{ member.nickname }}</text>
-          <view class="fan-score" v-if="getMemberEnergy(member) !== null">
-            <text class="score-value">{{ getMemberEnergy(member) }}</text>
-            <text class="score-unit">分</text>
+          <view class="score-row">
+            <text class="score-label">亲密度</text>
+            <view class="score-bar-track">
+              <view class="score-bar-fill intimacy" :style="{ width: scoreWidths[1] }"></view>
+            </view>
+            <text class="score-value">{{ scoreValues[1] }}</text>
           </view>
-          <view class="fan-selected-mark" v-if="member.id === selectedMemberId">
-            <text class="mark-icon">✓</text>
+          <view class="score-row">
+            <text class="score-label">沟通质量</text>
+            <view class="score-bar-track">
+              <view class="score-bar-fill communication" :style="{ width: scoreWidths[2] }"></view>
+            </view>
+            <text class="score-value">{{ scoreValues[2] }}</text>
           </view>
         </view>
-      </view>
-    </view>
-
-    <!-- 长按/点击浮标弹出的个人菜单 -->
-    <view class="action-overlay" v-if="showActionMenu" @tap="closeMenu"></view>
-    <view class="action-sheet" v-if="showActionMenu">
-      <view class="action-header">
-        <view class="action-avatar-wrap">
-          <image class="action-avatar" :src="actionMember ? (actionMember.avatar || defaultAvatar) : (currentMember.avatar || defaultAvatar)" mode="aspectFill" />
+        <view class="action-divider"></view>
+        <view class="action-item" @click="addMember" v-if="actionMember && actionMember.isSelf">
+          <text class="action-item-icon">➕</text>
+          <text class="action-item-text">添加成员</text>
         </view>
-        <view class="action-info">
-          <text class="action-title">{{ actionMember ? actionMember.nickname : (currentMember.nickname || '我') }}</text>
-          <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || '') : '' }}</text>
+        <view class="action-item" @click="inviteMember" v-if="actionMember && actionMember.isSelf">
+          <text class="action-item-icon">📨</text>
+          <text class="action-item-text">邀请成员</text>
+        </view>
+        <view class="action-item" @click="switchToMember" v-if="canSwitch">
+          <text class="action-item-icon">🔄</text>
+          <text class="action-item-text">切换到该成员视角</text>
+        </view>
+        <view class="action-item" @click="editMember" v-if="canEdit">
+          <text class="action-item-icon">✏️</text>
+          <text class="action-item-text">编辑资料</text>
+        </view>
+        <view class="action-item" @click="questionnaireMember">
+          <text class="action-item-icon">📋</text>
+          <text class="action-item-text">关系问卷</text>
+          <text class="action-time" v-if="lastQuestionnaireText">  ·  上次填写 {{ lastQuestionnaireText }}</text>
+        </view>
+        <view class="action-item" @click="interactionMember">
+          <text class="action-item-icon">💬</text>
+          <text class="action-item-text">记录互动</text>
+        </view>
+        <view class="action-item" @click="callMember" v-if="actionMember && actionMember.phone">
+          <text class="action-item-icon">📞</text>
+          <text class="action-item-text">拨打电话</text>
+        </view>
+        <view class="action-item action-danger" @click="removeMember" v-if="canRemove">
+          <text class="action-item-icon">🗑️</text>
+          <text class="action-item-text">移除成员</text>
+        </view>
+        <view class="action-item action-cancel" @click="closeMenu">
+          <text class="action-item-text">取消</text>
         </view>
-      </view>
-      <view class="action-item" @tap="switchToMember" v-if="canSwitch">
-        <text class="action-item-icon">🔄</text>
-        <text class="action-item-text">切换到该成员视角</text>
-      </view>
-      <view class="action-item" @tap="editMember" v-if="canEdit">
-        <text class="action-item-icon">✏️</text>
-        <text class="action-item-text">编辑资料</text>
-      </view>
-      <view class="action-item" @tap="questionnaireMember">
-        <text class="action-item-icon">📋</text>
-        <text class="action-item-text">关系问卷</text>
-      </view>
-      <view class="action-item" @tap="interactionMember">
-        <text class="action-item-icon">💬</text>
-        <text class="action-item-text">记录互动</text>
-      </view>
-      <view class="action-item" @tap="callMember" v-if="actionMember && actionMember.phone">
-        <text class="action-item-icon">📞</text>
-        <text class="action-item-text">拨打电话</text>
-      </view>
-      <view class="action-item action-danger" @tap="removeMember" v-if="canRemove">
-        <text class="action-item-icon">🗑️</text>
-        <text class="action-item-text">移除成员</text>
-      </view>
-      <view class="action-item action-cancel" @tap="closeMenu">
-        <text class="action-item-text">取消</text>
       </view>
     </view>
 
-    <!-- 待审批请求弹窗 -->
-    <view class="request-overlay" v-if="showRequests" @tap="closeRequests"></view>
-    <view class="request-sheet" v-if="showRequests">
-      <view class="sheet-header">
-        <text class="sheet-title">待处理加入申请({{ pendingRequests.length }})</text>
-        <text class="sheet-close" @tap="closeRequests">×</text>
-      </view>
-      <view v-if="pendingRequests.length === 0" class="empty-requests">
-        <text>暂无待处理的请求</text>
-      </view>
-      <view class="request-item" v-for="req in pendingRequests" :key="req.id">
-        <image class="req-avatar" src="/static/default-avatar.png" mode="aspectFill"></image>
-        <view class="req-info">
-          <text class="req-name">{{ req.requesterNickname }}</text>
-          <text class="req-time">{{ req.createdAt }}</text>
-        </view>
-      <view class="req-actions">
-        <button class="req-btn btn-approve" @tap="approveRequest(req.id)">通过</button>
-          <button class="req-btn btn-reject" @tap="rejectRequest(req.id)">拒绝</button>
-        </view>
+    <!-- 未创建家庭引导 -->
+    <view class="family-strip-empty" v-else>
+      <view class="empty-content">
+        <text class="empty-icon">🏠</text>
+        <text class="empty-title">还未创建家庭</text>
+        <text class="empty-desc">创建家庭,开启全家幸福之旅</text>
+        <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
       </view>
     </view>
+
+    <!-- 邀请成员分享海报弹窗 -->
+    <SharePoster
+      :show="showPoster"
+      :referralCode="inviteCode"
+      :qrCodeBase64="inviteQrCode"
+      :nickname="posterNickname"
+      :userAvatar="posterAvatar"
+      @close="showPoster = false" />
   </view>
 </template>
 
 <script>
-import { kickFamilyMemberById, getPendingFamilyRequests, approveFamilyJoinRequest, rejectFamilyJoinRequest } from '@/utils/api'
+import FamilyMemberCard from '@/components/FamilyMemberCard'
+import SharePoster from '@/components/SharePoster.vue'
+import { kickFamilyMemberById, createFamily, generateFamilyInviteQrCode, getPairwiseRelationships } from '@/utils/api'
+import { parseDate } from '@/utils/format.js'
 
 export default {
+  components: { FamilyMemberCard, SharePoster },
   props: {
     members: { type: Array, default: function() { return [] } },
     selectedMemberId: { type: Number, default: null },
     energyMap: { type: Object, default: function() { return {} } },
     isParent: { type: Boolean, default: false },
-    isAdmin: { type: Boolean, default: false }
+    centerMemberId: { type: Number, default: null }
   },
   data: function() {
     return {
-      fanOpen: false,
       showActionMenu: false,
       actionMember: null,
-      pendingCount: 0,
-      pendingRequests: [],
-      showRequests: false
+      renderedMembers: [],
+      creatingFamily: false,
+      showPoster: false,
+      inviteCode: '',
+      inviteQrCode: '',
+      posterNickname: '',
+      posterAvatar: ''
     }
   },
-  mounted: function() {
-    this.loadPendingRequests()
-  },
-   computed: {
-    defaultAvatar: function() {
-      return '/static/default-avatar.png'
-    },
-    currentMember: function() {
-      if (!this.members || this.members.length === 0) return {}
-      for (var i = 0; i < this.members.length; i++) {
-        if (this.members[i].id === this.selectedMemberId) return this.members[i]
-      }
-      return this.members[0]
+  watch: {
+    members: {
+      handler: function(val) {
+        this.renderedMembers = val || []
+      },
+      immediate: true
     },
+    centerMemberId: {
+      handler: function(val) {
+        if (val) {
+          this.loadPairwise(val)
+        }
+      },
+      immediate: true
+    }
+  },
+  computed: {
     canSwitch: function() {
-      var target = this.actionMember || this.currentMember
-      return target && target.userId && target.source === 'family_member'
+      // 仅家庭成员记录(source=family_member)且已关联账号时才能切换视角;
+      // 家长卡片(source=parent)是 users 表记录,switch 接口只查 family_members 表,切换会失败
+      return this.actionMember && this.actionMember.userId && this.actionMember.source === 'family_member'
     },
     canEdit: function() {
-      var target = this.actionMember || this.currentMember
-      return this.isParent && target && !target.userId && target.source === 'family_member'
+      return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
     },
     canRemove: function() {
-      var target = this.actionMember || this.currentMember
-      return this.isParent && target && !target.isSelf && target.source === 'family_member'
-    }
-  },
-  methods: {
-    // ===== 扇形展开 =====
-    toggleFan: function(e) {
-      if (this.fanOpen) {
-        this.closeFan()
-      } else {
-        this.fanOpen = true
-      }
+      // 仅家庭成员记录可移除(kick 接口只查 family_members 表),且不能移除自己
+      return this.isParent && this.actionMember && !this.actionMember.isSelf && this.actionMember.source === 'family_member'
     },
-    closeFan: function() {
-      this.fanOpen = false
-    },
-    // 计算扇形位置: 头像在左边,成员向右扇形排列
-    // 用固定的偏移量数组,避免用 transform-origin 的兼容问题
-    getFanPosition: function(member, index) {
-      if (!this.fanOpen) return 'transform: translate(0rpx, 0rpx) rotate(0deg);'
-      var total = this.members.length
-      var angleRange = 50
-      var steps = Math.max(total - 1, 1)
-      var angle = -angleRange + (index / steps) * angleRange * 2
-      var rad = angle * Math.PI / 180
-      var radius = 220
-      var dx = Math.cos(rad) * radius
-      var dy = Math.sin(rad) * radius
-      var rotation = angle * 0.3
-      return 'transform: translate(' + dx + 'rpx,' + dy + 'rpx) rotate(' + rotation + 'deg);'
+    // 关系现状三项宽度/数值(下标 0=信任度 1=亲密度 2=沟通质量)
+    actionScores: function() {
+      var m = this.actionMember
+      if (!m) return []
+      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
+      return keys.map(function(k) {
+        var v = Number(m[k]) || 0
+        return { width: Math.max(0, Math.min(100, v)) + '%', value: Math.round(v) }
+      })
     },
-    getMemberEnergy: function(member) {
-      if (!member || !this.energyMap) return null
-      return this.energyMap[member.id] || this.energyMap[member.memberId] || null
+    scoreWidths: function() {
+      return this.actionScores.map(function(s) { return s.width })
     },
-    getRelationColor: function(member) {
-      var rel = member && member.relationship
-      if (rel === '爸爸' || rel === '妈妈' || rel === '父亲' || rel === '母亲') return '#F97316'
-      if (rel === '儿子' || rel === '女儿') return '#0EA5E9'
-      if (rel === '爷爷' || rel === '奶奶' || rel === '外公' || rel === '外婆') return '#6B7280'
-      if (rel === '哥哥' || rel === '姐姐' || rel === '弟弟' || rel === '妹妹') return '#10B981'
-      if (member && member.isSpouse) return '#EC4899'
-      var role = member && member.effectiveRole
-      if (role === 'parent') return '#F97316'
-      if (role === 'child') return '#0EA5E9'
-      if (role === 'teacher') return '#8B5CF6'
-      return '#9CA3AF'
+    scoreValues: function() {
+      return this.actionScores.map(function(s) { return s.value })
     },
-
-    // ===== 成员选择 =====
-    selectMember: function(member) {
+    // 上次填写时间(问卷提交时 updateMemberScores 已更新 lastInteractionAt)
+    lastQuestionnaireText: function() {
+      var m = this.actionMember
+      if (!m || !m.lastInteractionAt) return ''
+      var d = parseDate(m.lastInteractionAt)
+      if (!d) return ''
+      var now = new Date()
+      var diffMs = now.getTime() - d.getTime()
+      var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
+      if (diffDays === 0) return '今天'
+      if (diffDays === 1) return '昨天'
+      if (diffDays < 7) return diffDays + '天前'
+      if (diffDays < 30) return Math.floor(diffDays / 7) + '周前'
+      return Math.floor(diffDays / 30) + '个月前'
+    }
+  },
+  methods: {
+    onSelect: function(member) {
       this.$emit('select', member)
-      // 关闭扇形
-      this.fanOpen = false
-    },
-
-    // ===== 个人菜单 =====
-    onFanLongPress: function(member) {
-      this.actionMember = member
-      this.showActionMenu = true
     },
     onLongPress: function(member) {
       this.actionMember = member
@@ -231,39 +231,35 @@ export default {
       this.actionMember = null
     },
     switchToMember: function() {
-      var member = this.actionMember || this.currentMember
+      var member = this.actionMember
       this.closeMenu()
       if (member && member.userId) {
         this.$emit('switch', member)
       }
     },
     editMember: function() {
-      var member = this.actionMember || this.currentMember
+      var member = this.actionMember
       this.closeMenu()
       if (member) {
-        var params = 'memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '')
-        if (member.gender) params += '&gender=' + member.gender
-        if (member.phone) params += '&phone=' + member.phone
-        if (member.birthday) params += '&birthday=' + member.birthday
-        uni.navigateTo({ url: '/pages/family/add-member?' + params })
+        uni.navigateTo({ url: '/pages/family/add-member?memberId=' + member.id })
       }
     },
     questionnaireMember: function() {
-      var member = this.actionMember || this.currentMember
+      var member = this.actionMember
       this.closeMenu()
       if (member) {
         uni.navigateTo({ url: '/pages/action-detail/relationship-questionnaire?memberId=' + member.id })
       }
     },
     interactionMember: function() {
-      var member = this.actionMember || this.currentMember
+      var member = this.actionMember
       this.closeMenu()
       if (member) {
         uni.navigateTo({ url: '/pages/action-detail/interaction-log?memberId=' + member.id })
       }
     },
     callMember: function() {
-      var member = this.actionMember || this.currentMember
+      var member = this.actionMember
       this.closeMenu()
       if (member && member.phone) {
         uni.makePhoneCall({ phoneNumber: member.phone })
@@ -271,7 +267,7 @@ export default {
     },
     removeMember: function() {
       var self = this
-      var member = this.actionMember || this.currentMember
+      var member = this.actionMember
       this.closeMenu()
       if (member) {
         uni.showModal({
@@ -284,6 +280,7 @@ export default {
                 .then(function() {
                   uni.hideLoading()
                   uni.showToast({ title: '已移除', icon: 'success' })
+                  // 通知父组件刷新成员列表(替代无效的 globalData 标志)
                   self.$emit('removed', member)
                 })
                 .catch(function(err) {
@@ -295,350 +292,108 @@ export default {
         })
       }
     },
-
-    // ===== 待审批请求 =====
-    loadPendingRequests: function() {
+    getMemberEnergy: function(member) {
+      if (!member || !this.energyMap) return null
+      return this.energyMap[member.id] || this.energyMap[member.memberId] || null
+    },
+    onCreateFamily: function() {
       var self = this
-      if (!this.isAdmin) return
-      getPendingFamilyRequests()
+      if (this.creatingFamily) return
+      this.creatingFamily = true
+      // 家庭名称使用当前用户昵称 + '的家庭'(与 user-edit.vue createFamilyAction 一致)
+      var userInfo = uni.getStorageSync('userInfo')
+      var nickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || '我的'
+      uni.showLoading({ title: '创建中...' })
+      createFamily(nickname + '的家庭')
         .then(function(res) {
+          uni.hideLoading()
+          self.creatingFamily = false
           if (res.code === 200) {
-            var data = res.data || []
-            self.pendingCount = data.length
-            self.pendingRequests = data
+            uni.showToast({ title: '家庭创建成功', icon: 'success' })
+            self.$emit('created')
+          } else {
+            uni.showToast({ title: res.message || '创建失败', icon: 'none' })
           }
         })
-        .catch(function() {
-          // 静默失败
+        .catch(function(err) {
+          uni.hideLoading()
+          self.creatingFamily = false
+          uni.showToast({ title: (err && err.message) || '创建失败', icon: 'none' })
         })
     },
-    openRequests: function() {
-      this.showRequests = true
-    },
-    closeRequests: function() {
-      this.showRequests = false
+    addMember: function() {
+      this.closeMenu()
+      uni.navigateTo({ url: '/pages/family/add-member' })
     },
-    approveRequest: function(requestId) {
+    // 邀请成员:弹窗展示分享海报(与 family-members.vue onInvite 一致的模式)
+    inviteMember: function() {
       var self = this
-      uni.showModal({
-        title: '确认通过',
-        content: '确认通过该成员的加入申请?',
-        success: function(modalRes) {
-          if (modalRes.confirm) {
-            approveFamilyJoinRequest(requestId)
-              .then(function() {
-                uni.showToast({ title: '已通过', icon: 'success' })
-                self.loadPendingRequests()
-                self.closeRequests()
-              })
-              .catch(function(err) {
-                uni.showToast({ title: err.message || '操作失败', icon: 'none' })
-              })
+      this.closeMenu()
+      var userInfo = uni.getStorageSync('userInfo')
+      this.posterNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
+      this.posterAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
+      this.inviteCode = ''
+      this.inviteQrCode = ''
+      uni.showLoading({ title: '生成邀请...' })
+      generateFamilyInviteQrCode()
+        .then(function(res) {
+          uni.hideLoading()
+          if (res.code === 200 && res.data) {
+            self.inviteCode = res.data.inviteCode
+            self.inviteQrCode = res.data.qrCodeBase64
+            self.showPoster = true
+          } else {
+            uni.showToast({ title: res.message || '生成失败', icon: 'none' })
           }
-        }
-      })
+        })
+        .catch(function() {
+          uni.hideLoading()
+          uni.showToast({ title: '生成失败', icon: 'none' })
+        })
     },
-    rejectRequest: function(requestId) {
+    loadPairwise: function(centerMemberId) {
       var self = this
-      uni.showModal({
-        title: '确认拒绝',
-        content: '确认拒绝该成员的加入申请?',
-        success: function(modalRes) {
-          if (modalRes.confirm) {
-            rejectFamilyJoinRequest(requestId)
-              .then(function() {
-                uni.showToast({ title: '已拒绝', icon: 'success' })
-                self.loadPendingRequests()
-                self.closeRequests()
-              })
-              .catch(function(err) {
-                uni.showToast({ title: err.message || '操作失败', icon: 'none' })
-              })
-          }
+      getPairwiseRelationships(centerMemberId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          var arr = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
+          self.renderedMembers = arr
         }
-      })
+      }).catch(function() {})
     }
   }
 }
 </script>
 
 <style scoped>
-.member-strip {
-  /* FAB 悬浮在左侧边缘中部偏下;禁用 transform(transform 会创建 containing block,
-     使后续 .fan-overlay (position: fixed) 退化为相对本容器定位,全屏遮罩失效) */
-  position: fixed;
-  bottom: 45%;
-  left: 16rpx;
-  z-index: 100;
+.family-member-strip {
+  margin: 16rpx 0;
+  padding: 0 30rpx;
 }
-
-/* ===== 浮标 ===== */
-.fab-wrap {
-  position: relative;
-  display: inline-block;
-  margin: 4rpx 0;
-  z-index: 1001;
-}
-.fab-avatar {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 50%;
-  background: linear-gradient(135deg, #F97316, #FB923C);
-  box-shadow: 0 6rpx 24rpx rgba(249, 115, 22, 0.35), 0 0 0 4rpx rgba(249, 115, 22, 0.12);
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease;
-}
-.fab-avatar:active {
-  transform: scale(0.9);
-}
-.fab-avatar.active {
-  transform: scale(1.05);
-  box-shadow: 0 8rpx 32rpx rgba(249, 115, 22, 0.45), 0 0 0 6rpx rgba(249, 115, 22, 0.15);
-}
-
-/* 默认态:家庭图标 */
-.fab-icon-default {
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-.fab-icon-emoji {
-  font-size: 36rpx;
-  line-height: 1;
-  filter: drop-shadow(0 1rpx 2rpx rgba(0,0,0,0.1));
-}
-
-/* 展开态:当前成员头像(渐变出现) */
-.fab-img {
-  position: absolute;
-  top: 0;
-  left: 0;
+.strip-scroll {
   width: 100%;
-  height: 100%;
-  border-radius: 50%;
-  opacity: 0;
-  transform: scale(0.8);
-  transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
-}
-.fab-img-show {
-  opacity: 1;
-  transform: scale(1);
-}
-
-/* 光环 */
-.fab-ring {
-  position: absolute;
-  top: -4rpx;
-  left: -4rpx;
-  right: -4rpx;
-  bottom: -4rpx;
-  border-radius: 50%;
-  border: 3rpx solid rgba(255, 255, 255, 0.6);
-  opacity: 0;
-  transition: opacity 0.3s ease;
-}
-.fab-ring.ring-active {
-  opacity: 1;
-}
-.fab-badge {
-  position: absolute;
-  top: -4rpx;
-  right: -8rpx;
-  width: 32rpx;
-  height: 32rpx;
-  border-radius: 50%;
-  background: #EF4444;
-  border: 2rpx solid #FFFFFF;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-.badge-count {
-  font-size: 16rpx;
-  color: #FFFFFF;
-  font-weight: bold;
-}
-.fab-pulse {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  width: 72rpx;
-  height: 72rpx;
-  margin-top: -36rpx;
-  margin-left: -36rpx;
-  border-radius: 50%;
-  background: rgba(249, 115, 22, 0.25);
-  animation: fab-pulse-anim 2.5s ease-out infinite;
-  /* 呼吸动画层不拦截点击,否则常驻覆盖 FAB 导致无法展开成员扇形 */
-  pointer-events: none;
-}
-@keyframes fab-pulse-anim {
-  0% { transform: scale(1); opacity: 0.6; }
-  100% { transform: scale(2); opacity: 0; }
-}
-
-/* ===== 扇形展开 ===== */
-/* fan-wrap 用 fixed 覆盖整个视口,避免 absolute 套小容器导致位置错乱 */
-.fan-wrap {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  z-index: 999;
-  pointer-events: none;
-}
-.fan-wrap.show {
-  pointer-events: auto;
-}
-.fan-overlay {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background: rgba(0, 0, 0, 0.25);
-  opacity: 0;
-  transition: opacity 0.3s ease;
-  z-index: 999;
-}
-.fan-wrap.show .fan-overlay {
-  opacity: 1;
-}
-/* fan-container 锚定到 FAB 坐标(FAB 在 left:16rpx, bottom:33.33%),零尺寸做原点 */
-.fan-container {
-  position: absolute;
-  bottom: 33.33%;
-  left: 16rpx;
-  top: auto;
-  right: auto;
-  width: 0;
-  height: 0;
-  padding: 0;
-  overflow: visible;
-}
-
-.fan-card {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 110rpx;
-  pointer-events: auto;
-  transform: translate(0, 0) rotate(0deg);
-  transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
-  opacity: 0;
-  z-index: 1000;
-}
-.fan-wrap.show .fan-card {
-  opacity: 1;
-}
-.fan-card.selected {
-  filter: brightness(0.95);
-}
-.fan-avatar-wrap {
-  position: relative;
-  width: 64rpx;
-  height: 64rpx;
-  margin: 0 auto;
-}
-.fan-avatar {
-  width: 64rpx;
-  height: 64rpx;
-  border-radius: 50%;
-  background: #FFFFFF;
-  border: 2rpx solid #FFFFFF;
-  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
-}
-.fan-relation-badge {
-  position: absolute;
-  bottom: -4rpx;
-  left: 50%;
-  transform: translateX(-50%);
-  padding: 1rpx 8rpx;
-  border-radius: 10rpx;
   white-space: nowrap;
-  max-width: 76rpx;
 }
-.fan-relation-text {
-  font-size: 15rpx;
-  color: #FFFFFF;
-  font-weight: 500;
-  line-height: 1.4;
-}
-.fan-spouse-mark {
-  position: absolute;
-  top: -2rpx;
-  right: -2rpx;
-  width: 22rpx;
-  height: 22rpx;
+.strip-inner {
   display: flex;
-  align-items: center;
-  justify-content: center;
+  flex-direction: row;
 }
-.spouse-icon {
-  font-size: 16rpx;
-}
-.fan-nickname {
-  display: block;
-  margin-top: 4rpx;
-  text-align: center;
-  font-size: 20rpx;
-  color: #FFFFFF;
-  font-weight: 600;
-  text-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.4);
-  line-height: 1.2;
-  max-width: 90rpx;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.fan-score {
-  display: flex;
-  align-items: baseline;
-  justify-content: center;
-  margin-top: 2rpx;
+.strip-inner.scroll-mode {
+  gap: 16rpx;
 }
-.score-value {
-  font-size: 18rpx;
-  color: #FBBF24;
-  font-weight: bold;
-  text-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.4);
-}
-.score-unit {
-  font-size: 15rpx;
-  color: rgba(255, 255, 255, 0.8);
-  margin-left: 2rpx;
-  text-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.4);
-}
-.fan-selected-mark {
-  position: absolute;
-  top: 0;
-  right: 0;
-  width: 22rpx;
-  height: 22rpx;
-  border-radius: 50%;
-  background: #F97316;
-  display: flex;
-  align-items: center;
+.strip-inner.center-mode {
   justify-content: center;
+  gap: 24rpx;
+  flex-wrap: wrap;
 }
-.mark-icon {
-  font-size: 14rpx;
-  color: #FFFFFF;
-  font-weight: bold;
+.strip-scroll .strip-inner.scroll-mode {
+  display: inline-flex;
 }
-
-/* ===== 操作菜单 ===== */
+/* 操作菜单 */
 .action-overlay {
   position: fixed;
   top: 0; left: 0; right: 0; bottom: 0;
   background: rgba(0,0,0,0.4);
-  z-index: 1001;
+  z-index: 999;
 }
 .action-sheet {
   position: fixed;
@@ -647,63 +402,35 @@ export default {
   right: 0;
   background: #FFFFFF;
   border-radius: 24rpx 24rpx 0 0;
-  z-index: 1002;
+  z-index: 1000;
   padding: 20rpx 0;
   padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
-  animation: sheet-up 0.3s ease;
-}
-@keyframes sheet-up {
-  from { transform: translateY(100%); }
-  to { transform: translateY(0); }
-}
-.action-header {
-  display: flex;
-  align-items: center;
-  padding: 16rpx 30rpx 20rpx;
-  border-bottom: 1rpx solid #F3F4F6;
-  gap: 16rpx;
-}
-.action-avatar-wrap {
-  width: 56rpx;
-  height: 56rpx;
-  border-radius: 50%;
-  overflow: hidden;
-  flex-shrink: 0;
-}
-.action-avatar {
-  width: 56rpx;
-  height: 56rpx;
-  border-radius: 50%;
-}
-.action-info {
-  display: flex;
-  flex-direction: column;
-  gap: 2rpx;
 }
 .action-title {
-  font-size: 28rpx;
+  font-size: 30rpx;
   font-weight: 600;
   color: #1F2937;
 }
 .action-relation {
-  font-size: 22rpx;
+  font-size: 24rpx;
   color: #9CA3AF;
+  margin-top: 4rpx;
 }
 .action-item {
   display: flex;
   align-items: center;
-  padding: 22rpx 30rpx;
+  padding: 24rpx 30rpx;
   border-bottom: 1rpx solid #F9FAFB;
 }
 .action-item:active {
   background: #F3F4F6;
 }
 .action-item-icon {
-  font-size: 30rpx;
+  font-size: 32rpx;
   margin-right: 16rpx;
 }
 .action-item-text {
-  font-size: 26rpx;
+  font-size: 28rpx;
   color: #374151;
 }
 .action-danger .action-item-text {
@@ -711,212 +438,100 @@ export default {
 }
 .action-cancel {
   justify-content: center;
-  margin-top: 8rpx;
+  margin-top: 10rpx;
   border-bottom: none;
 }
 .action-cancel .action-item-text {
   color: #6B7280;
 }
-
-/* ===== 待审批请求 ===== */
-.request-overlay {
-  position: fixed;
-  top: 0; left: 0; right: 0; bottom: 0;
-  background: rgba(0,0,0,0.4);
-  z-index: 1003;
+.action-time {
+  font-size: 22rpx;
+  color: #9CA3AF;
+  margin-left: 4rpx;
 }
-.request-sheet {
-  position: fixed;
-  bottom: 0;
-  left: 0;
-  right: 0;
+/* 空状态 */
+.family-strip-empty {
+  margin: 16rpx 0;
+  padding: 40rpx 30rpx;
   background: #FFFFFF;
-  border-radius: 24rpx 24rpx 0 0;
-  z-index: 1004;
-  padding: 24rpx 0;
-  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
-  animation: sheet-up 0.3s ease;
-  max-height: 60vh;
-  overflow-y: auto;
-}
-.sheet-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 0 30rpx 16rpx;
-  border-bottom: 1rpx solid #F3F4F6;
-}
-.sheet-title {
-  font-size: 28rpx;
-  font-weight: 600;
-  color: #1F2937;
-}
-.sheet-close {
-  font-size: 32rpx;
-  color: #999;
-}
-.empty-requests {
-  padding: 60rpx 0;
-  text-align: center;
-  font-size: 24rpx;
-  color: #999;
-}
-.request-item {
+  border-radius: 24rpx;
   display: flex;
-  align-items: center;
-  padding: 20rpx 30rpx;
-  gap: 16rpx;
-}
-.req-avatar {
-  width: 56rpx;
-  height: 56rpx;
-  border-radius: 50%;
-  flex-shrink: 0;
+  justify-content: center;
 }
-.req-info {
-  flex: 1;
+.empty-content {
   display: flex;
   flex-direction: column;
-  gap: 2rpx;
-}
-.req-name {
-  font-size: 26rpx;
-  color: #333;
-}
-.req-time {
-  font-size: 20rpx;
-  color: #999;
-}
-.req-actions {
-  display: flex;
-  gap: 12rpx;
-}
-.req-btn {
-  padding: 8rpx 20rpx;
-  border-radius: 20rpx;
-  font-size: 22rpx;
-  font-weight: 500;
+  align-items: center;
 }
-.btn-approve {
-  background: #10B981;
+.empty-icon { font-size: 56rpx; }
+.empty-title { font-size: 30rpx; font-weight: 600; color: #1F2937; margin-top: 12rpx; }
+.empty-desc { font-size: 24rpx; color: #9CA3AF; margin-top: 8rpx; }
+.empty-btn {
+  margin-top: 24rpx;
+  padding: 16rpx 48rpx;
+  background: #F97316;
   color: #FFFFFF;
+  font-size: 28rpx;
+  border-radius: 40rpx;
 }
-.btn-reject {
-  background: #F3F4F6;
-  color: #666;
-}
-</style>
-
-.badge-count {
-  font-size: 26rpx;
-  font-weight: 700;
-  color: #EF4444;
-}
-
-/* 待审批请求弹窗 */
-.request-overlay {
-  position: fixed;
-  top: 0; left: 0; right: 0; bottom: 0;
-  background: rgba(0,0,0,0.4);
-  z-index: 1500;
-}
-
-.request-sheet {
-  position: fixed;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  background: #FFFFFF;
-  border-radius: 24rpx 24rpx 0 0;
-  z-index: 1501;
-  max-height: 70vh;
-  overflow-y: auto;
-  padding: 20rpx 0;
-  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
-}
-
-.sheet-header {
+/* Action Sheet 头部(覆盖原有 column 布局) */
+.action-header {
   display: flex;
-  justify-content: space-between;
+  flex-direction: row;
   align-items: center;
-  padding: 16rpx 30rpx 16rpx;
+  padding: 24rpx 30rpx;
   border-bottom: 1rpx solid #F3F4F6;
 }
-
-.sheet-title {
-  font-size: 32rpx;
-  font-weight: 700;
-  color: #1F2937;
+.action-avatar {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  margin-right: 20rpx;
+  background: #F3F4F6;
 }
-
-.sheet-close {
-  font-size: 40rpx;
-  color: #9CA3AF;
-  padding: 8rpx;
-  line-height: 1;
+.action-header-info {
+  display: flex;
+  flex-direction: column;
 }
-
-.empty-requests {
-  text-align: center;
-  padding: 60rpx 0;
-  font-size: 28rpx;
-  color: #9CA3AF;
+/* 关系现状 */
+.action-scores {
+  padding: 20rpx 30rpx;
 }
-
-.request-item {
+.score-row {
   display: flex;
+  flex-direction: row;
   align-items: center;
-  padding: 20rpx 30rpx;
-  border-bottom: 1rpx solid #F9FAFB;
+  margin-bottom: 12rpx;
 }
-
-.req-avatar {
-  width: 80rpx;
-  height: 80rpx;
-  border-radius: 50%;
-  margin-right: 16rpx;
-  flex-shrink: 0;
+.score-label {
+  width: 110rpx;
+  font-size: 24rpx;
+  color: #6B7280;
 }
-
-.req-info {
+.score-bar-track {
   flex: 1;
-  min-width: 0;
-}
-
-.req-name {
-  font-size: 30rpx;
-  font-weight: 600;
-  color: #1F2937;
-  display: block;
-}
-
-.req-time {
-  font-size: 24rpx;
-  color: #9CA3AF;
-  display: block;
-  margin-top: 4rpx;
+  height: 14rpx;
+  background: #F3F4F6;
+  border-radius: 7rpx;
+  overflow: hidden;
 }
-
-.req-actions {
-  display: flex;
-  gap: 12rpx;
+.score-bar-fill {
+  height: 100%;
+  border-radius: 7rpx;
 }
-
-.req-btn {
-  padding: 12rpx 24rpx;
-  border-radius: 8rpx;
+.score-bar-fill.trust { background: #10B981; }
+.score-bar-fill.intimacy { background: #3B82F6; }
+.score-bar-fill.communication { background: #F59E0B; }
+.score-value {
+  width: 70rpx;
+  text-align: right;
   font-size: 24rpx;
+  color: #1F2937;
   font-weight: 600;
-  border: none;
-}
-
-.btn-approve {
-  background: #16A34A;
-  color: #FFFFFF;
 }
-
-.btn-reject {
-  background: #EF4444;
-  color: #FFFFFF;
+.action-divider {
+  height: 1rpx;
+  background: #F3F4F6;
+  margin: 0 30rpx;
 }
 </style>

+ 10 - 2
cfc-frontend/pages/home-pages/member-home-detail.vue

@@ -27,6 +27,13 @@
         </view>
       </view>
 
+      <!-- 以该成员为中心的家庭成员关系条 -->
+      <FamilyMemberStrip
+        v-if="memberId"
+        :center-member-id="memberId"
+        :selected-member-id="memberId"
+        :is-parent="true" />
+
       <!-- 家庭成员关系图谱(只读) -->
       <FamilyRelationGraph
         dimensionCode="home"
@@ -64,10 +71,11 @@
 <script>
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
+import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import { getFamilyEnergySandbox, getChildren, getTodayTasksByCategory } from '../../utils/api.js'
 
 export default {
-  components: { FamilyRelationGraph, DimensionTasks },
+  components: { FamilyRelationGraph, DimensionTasks, FamilyMemberStrip },
   data() {
     return {
       memberId: null,
@@ -137,7 +145,7 @@ export default {
     }
   },
   onLoad: function(options) {
-    this.memberId = options.memberId || null
+    this.memberId = Number(options.memberId) || null
     this.memberType = options.memberType || 'child'
   },
   onShow: function() {

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

@@ -315,6 +315,11 @@ export const getVisibleFamilyMembers = () => {
 	return request('/api/family/user/members/visible', 'POST')
 }
 
+// 获取以指定成员为中心的所有成员关系(关系类型+信任/亲密度/沟通评分)
+export const getPairwiseRelationships = (centerMemberId) => {
+	return request('/api/family/relationship/pairwise', 'POST', { centerMemberId })
+}
+
 // 更新成员可见性
 export const updateMemberVisibility = (memberId, memberType, showToFamily) => {
 	return request('/api/family/user/members/update-visibility', 'POST', { memberId, memberType, showToFamily })

+ 63 - 0
cfc-langgraph/app/agents/report_parse_agent.py

@@ -70,6 +70,69 @@ class ReportParseAgent:
         result.pop('_parse_incomplete', None)
         return result
 
+    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None) -> dict:
+        """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
+        try:
+            from PyPDF2 import PdfReader
+            reader = PdfReader(file_path)
+            text = '\n'.join(page.extract_text() or '' for page in reader.pages)
+
+            ctx_str = ""
+            if extra_context:
+                ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
+
+            prompt = f"""你是一个通用报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
+
+报告文本内容:
+{text[:12000]}{ctx_str}
+
+请分析这份报告,推断它的类型和内容,然后按以下JSON Schema返回:
+{{
+    "reportType": "推断的报告类型名称",
+    "reportTypeFamily": "报告家族分类(如: dan/cognitive/gut_flora/health_check/other)",
+    "confidence": "high/medium/low",
+    "summary": {{
+        "personName": "姓名",
+        "reportDate": "报告日期",
+        "reportNumber": "报告编号",
+        "overallScore": "总分(如果有)",
+        "interpretation": "报告整体解读摘要"
+    }},
+    "indicators": [
+        {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态"}}
+    ],
+    "sections": [
+        {{"title": "段落标题", "content": "段落内容摘要", "items": [{{"name": "...", "value": "..."}}]}}
+    ],
+    "textFeatures": ["文本特征1", "文本特征2", ...]
+}}
+
+只返回JSON,不要其他文字。"""
+
+            if self.llm_api_key:
+                import httpx
+                async with httpx.AsyncClient(timeout=120) as client:
+                    resp = await client.post(
+                        f"{settings.llm_base_url}/chat/completions",
+                        json={
+                            "model": settings.llm_model or "gpt-4o",
+                            "messages": [{"role": "user", "content": prompt}],
+                            "temperature": 0.1,
+                        },
+                        headers={"Authorization": f"Bearer {self.llm_api_key}"},
+                    )
+                    resp.raise_for_status()
+                    data = resp.json()
+                    content = data['choices'][0]['message']['content']
+                    content = content.replace('```json', '').replace('```', '').strip()
+                    return json.loads(content)
+            else:
+                logger.warning("LLM 未配置,返回空")
+                return {"reportType": "unknown", "summary": {}, "indicators": [], "sections": []}
+        except Exception as e:
+            logger.error("通用LLM解析失败: %s", e)
+            return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
+
     async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
         """LLM 兜底解析"""
         try:

+ 33 - 1
cfc-langgraph/app/api/report_parse.py

@@ -1,9 +1,10 @@
 from fastapi import APIRouter, HTTPException
 from pydantic import BaseModel
-from typing import Optional
+from typing import Optional, Any
 from app.agents.report_parse_agent import ReportParseAgent
 import logging
 import os
+import json
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/api/v1", tags=["report_parse"])
@@ -43,3 +44,34 @@ async def parse_report(req: ParseRequest):
     except Exception as e:
         logger.error("报告解析失败: %s", e, exc_info=True)
         return ParseResponse(code=500, message=f"解析失败: {str(e)}", data={})
+
+
+class GenericParseRequest(BaseModel):
+    file_path: str
+    extra_context: Optional[dict] = None
+
+
+class GenericParseResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+@router.post("/report/parse-generic", response_model=GenericParseResponse)
+async def parse_report_generic(req: GenericParseRequest):
+    """通用报告 LLM 兜底解析。
+
+    适用于指纹检测未匹配的未知类型报告。
+    直接交给 LLM 提取结构化数据,不经过算法预解析。
+    """
+    if not os.path.exists(req.file_path):
+        raise HTTPException(status_code=400, detail=f"文件不存在: {req.file_path}")
+
+    logger.info("report_parse_generic: file_path=%s", req.file_path)
+    agent = get_agent()
+    try:
+        result = await agent.parse_generic(req.file_path, req.extra_context)
+        return GenericParseResponse(data=result)
+    except Exception as e:
+        logger.error("通用报告解析失败: %s", e, exc_info=True)
+        return GenericParseResponse(code=500, message=f"解析失败: {str(e)}", data={})

+ 151 - 0
cfc-langgraph/tests/test_auto_tag.py

@@ -0,0 +1,151 @@
+"""
+LangGraph 自动打标签 (auto-tag) 功能测试
+=============================================
+
+背景:
+    Java 后端在知识中心发布/更新文章时, 会调用 LangGraph 的 POST /api/v1/chat
+    端点, 传入"提取文章标签"的 prompt, 让 LLM 返回 3-5 个中文关键词标签。
+
+    Java 端构造的 prompt (见 ArticleService.autoGenerateTags()):
+        "你是一个家庭教育内容分析助手。请根据以下文章内容,提取 3 到 5 个
+         最能代表文章主题的中文关键词作为标签。
+         要求:1) 关键词为中文短语或名词,2-6 个字;2) 用英文逗号分隔;
+         3) 不要包含编号、引号或解释文字;4) 只返回标签列表本身。
+         文章内容:{plainText}"
+
+    Java 端解析逻辑 (与下方 parse_tags 保持一致):
+        - 用英文逗号 split
+        - 去掉行首的数字/点/横线/空白/顿号句号逗号
+        - 去掉行尾空白/顿号句号逗号
+        - 标签非空、长度 <= 20、最多取 5 个
+
+本测试: 复用 ASGI 内嵌调用, 验证 LangGraph 返回的标签质量与格式,
+        并验证其能被 Java 端的解析逻辑正确解析。
+"""
+
+import re
+
+import pytest
+from httpx import AsyncClient, ASGITransport
+
+from app.main import app
+
+
+# ============================================================
+# 测试数据
+# ============================================================
+
+# 真实文章内容 (来自 articles/D14*, 肠道菌群/过敏主题)
+ARTICLE_GUT = (
+    "儿童过敏反复不好,根源可能在肠道。人体约70%的免疫细胞分布在肠道,"
+    "肠道是人体最大的免疫器官。当肠道菌群处于健康平衡状态时,免疫系统能够准确区分"
+    "朋友和敌人。但当菌群失衡——有益菌减少、有害菌过度繁殖——肠道屏障功能就会受损。"
+    "科学调理四步走:第一步消除破坏因素,减少精制糖和加工食品;第二步重建菌群平衡,"
+    "增加膳食纤维摄入;第三步修复肠道屏障,补充谷氨酰胺、锌、维生素D;第四步维持健康"
+    "生活方式。每个孩子的菌群状况都是独特的,这正是菌群检测的核心价值。"
+)
+
+# 短内容
+ARTICLE_SHORT = "孩子睡眠质量差,作息不规律怎么办?规律作息对成长很重要。"
+
+# ============================================================
+# 工具函数
+# ============================================================
+
+# 与 Java 端 ArticleService.autoGenerateTags() 的解析逻辑保持一致
+TAG_CLEAN_START = re.compile(r"^[\d\.\-\s、。,]+")
+TAG_CLEAN_END = re.compile(r"[\s、。,]+$")
+
+
+def parse_tags(ai_response: str) -> list:
+    """按 Java 端解析规则解析 LLM 返回的标签列表"""
+    if not ai_response:
+        return []
+    tags = []
+    for raw in ai_response.split(","):
+        tag = TAG_CLEAN_START.sub("", raw.strip().strip())
+        tag = TAG_CLEAN_END.sub("", tag).strip()
+        if tag and len(tag) <= 20 and len(tags) < 5:
+            tags.append(tag)
+    return tags
+
+
+def build_tag_prompt(plain_text: str) -> str:
+    """构造与 Java 端完全一致的标签提取 prompt"""
+    return (
+        "你是一个家庭教育内容分析助手。请根据以下文章内容,"
+        "提取 3 到 5 个最能代表文章主题的中文关键词作为标签。"
+        "要求:1) 关键词为中文短语或名词,2-6 个字;2) 用英文逗号分隔;"
+        "3) 不要包含编号、引号或解释文字;4) 只返回标签列表本身。"
+        f"\n\n文章内容:{plain_text}"
+    )
+
+
+async def call_chat(query: str, user_id: int = 0) -> dict:
+    """调用 LangGraph /api/v1/chat 端点"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/chat", json={
+            "query": query,
+            "user_id": user_id,
+            "conversation_id": "",
+        })
+        assert resp.status_code == 200, f"chat 端点返回 {resp.status_code}"
+        return resp.json()
+
+
+# ============================================================
+# 测试用例
+# ============================================================
+
+@pytest.mark.asyncio
+async def test_auto_tag_endpoint_responds():
+    """发送标签 prompt, 端点应返回 200, 且含 answer 与 tasks 数组"""
+    data = await call_chat(build_tag_prompt(ARTICLE_GUT))
+    assert "answer" in data, "响应缺少 answer 字段"
+    assert isinstance(data.get("answer"), str)
+    assert isinstance(data.get("tasks"), list)
+
+
+@pytest.mark.asyncio
+async def test_auto_tag_valid_format():
+    """返回的标签应能被 Java 解析逻辑解析为 3-5 个中文标签"""
+    data = await call_chat(build_tag_prompt(ARTICLE_GUT))
+    tags = parse_tags(data.get("answer", ""))
+    assert 3 <= len(tags) <= 5, f"标签数量应为 3-5 个, 实际 {len(tags)}: {tags}"
+    for tag in tags:
+        # 2-6 个中文字
+        chinese_chars = re.findall(r"[\u4e00-\u9fff]", tag)
+        assert 2 <= len(chinese_chars) <= 6, f"标签应为 2-6 个中文字: '{tag}'"
+        # 不应包含编号/引号/英文逗号
+        assert not re.search(r"[\d\"',。]", tag), f"标签不应包含数字/引号/标点: '{tag}'"
+
+
+@pytest.mark.asyncio
+async def test_auto_tag_content_relevance():
+    """标签应反映文章核心主题 (肠道/过敏/免疫 等)"""
+    data = await call_chat(build_tag_prompt(ARTICLE_GUT))
+    tags = parse_tags(data.get("answer", ""))
+    assert tags, "未解析出任何标签"
+    # 核心主题词中至少命中一个
+    core_keywords = ["肠胃", "过敏", "免疫", "肠道", "菌群", "营养", "健康"]
+    hit = [kw for kw in core_keywords if any(kw in t for t in tags)]
+    assert hit, f"标签未命中核心主题词, 实际标签: {tags}"
+
+
+@pytest.mark.asyncio
+async def test_auto_tag_short_content():
+    """短内容也应正常返回标签, 不崩溃"""
+    data = await call_chat(build_tag_prompt(ARTICLE_SHORT))
+    tags = parse_tags(data.get("answer", ""))
+    assert tags, "短内容也应提取出标签"
+    assert len(tags) <= 5
+
+
+@pytest.mark.asyncio
+async def test_auto_tag_empty_content():
+    """空内容应安全返回空标签, 不崩溃"""
+    data = await call_chat(build_tag_prompt(""))
+    # 空内容不应抛异常, answer 可为空字符串
+    assert "answer" in data
+    assert isinstance(data.get("answer"), str)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-84c2a6757d32d526b96946b032d59668a2af65ea
+cb55c00ccecf4225e53fbe8b56b80c9a1b34c80a

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.860",
+  "version": "1.0.870",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.860",
+      "version": "1.0.870",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.861",
+  "version": "1.0.871",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 125 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,131 @@
 
 ---
 
+## v1.0.871 (2026-08-08)
+
+### Bug 修复
+- 修复 UserService 中 createJoinRequest 调用
+- 修复 createJoinRequest 调用参数
+- 修复邀请流程 - 保存familyRole、修复轮询逻辑
+
+
+## v1.0.870 (2026-08-08)
+
+### Bug 修复
+- 修复 RequestBodyCachingFilter 编译问题
+- FamilyMemberStrip centerMemberId watcher 加 immediate + 清理重复 .action-header 样式
+- pairwise VO 补齐 isSpouse 与 lastInteractionAt(修复成员详情页 action-sheet 关系标签和上次填写时间显示)
+- 修正 parseDate import 语法(named export)
+
+### 新功能
+- 成员详情页接入以成员为中心的关系条
+- pairwise接口API + 成员条centerMemberId视角模式
+- 成员条空状态引导 + 长按菜单升级关系现状Action Sheet
+- 家庭成员卡片增加关系现状三色点
+- 关系pairwise接口(以成员为中心) + VO补齐沟通评分+isSelf
+- 问卷提交时同步pairwise关系评分到family_relationships
+- family_relationships表增加关系质量评分三列
+
+### 文档
+- 家庭成员关系条增强实现计划(FamilyMemberStrip)
+- 家庭成员关系条关系展示增强设计文档
+
+
+## v1.0.869 (2026-08-08)
+
+### Bug 修复
+- 移除多余的 String 构造函数调用
+- 修复 Java 8 兼容性问题 - readAllBytes 改用 readStream
+
+
+## v1.0.868 (2026-08-08)
+
+### Bug 修复
+- 将 Java 11 HttpClient 改为 RestTemplate 兼容 Java 8
+
+
+## v1.0.867 (2026-08-08)
+
+### Bug 修复
+- LangGraph 管理页面使用原生 fetch 避免 axios 拦截器问题
+- 迁移192 verification_codes.type 添加默认值
+
+### 其他
+- - 旧版 INSERT 未携带 type 字段,DB 列 NOT NULL 无默认值导致插入失败
+- - 使用 MODIFY COLUMN 添加 DEFAULT 'login'(向后兼容,无需改代码)
+- - 直连生产 DB ALTER 已执行(下次服务重启 DBI 迁移幂等跳过)
+- 
+
+
+## v1.0.866 (2026-08-08)
+
+### Bug 修复
+- LangGraph 管理页面使用独立 axios 实例避免 CORS 和响应格式问题
+
+
+## v1.0.865 (2026-08-08)
+
+### 新功能
+- 健康饮食菜单添加 LangGraph 管理入口
+
+
+## v1.0.864 (2026-08-08)
+
+### Bug 修复
+- 处理生产日志错误(foods.report_id/score + 知识库JSON + DBI迁移112)
+
+### 其他
+-   - 迁移191: foods 添加 report_id/score 列
+-   - 直连生产 DB ALTER 已执行
+- - 修复 菌属-食材映射 JSON 解析失败:
+-   - DB 顶层为 JSON Array 但代码期望 Map,加载时包装为 {_list: [...]}
+-   - 消费端兼容两种形态
+- - 修复 迁移112 日志等级:
+-   - Duplicate column 已存在是幂等预期,log.error → log.warn
+-   - 避免误报
+- 
+
+
+## v1.0.863 (2026-08-08)
+
+### Bug 修复
+- 修复测试编译与 getTemplates 断言;补充肠道菌群解析参考脚本
+
+### 新功能
+- 报告解析管理端页面 - 类型管理/指纹自学习/解析器导入/未知聚类
+- 报告解析 agent 接入指纹识别与自动标注
+- 报告指纹解析系统 - 类型注册/指纹识别/未知聚类/解析器导入
+
+### 其他
+- - report_parse API 对接报告类型注册表
+- - 新增 test_auto_tag 自动标注测试
+- 
+-   report_unknown_cluster/report_unknown_upload/report_parser_import 五表
+- - 新增 ReportParserController + ReportParserAdminController
+- - 新增 5 entity + 5 mapper + 3 service(ReportFingerprintService/
+-   ReportImportService/ReportParserDispatchService)
+- - schema.sql 同步 5 张新表定义
+- 
+
+
+## v1.0.862 (2026-08-08)
+
+### Bug 修复
+- 修复 diet/index.vue 运行时错误
+
+### 其他
+- - addIngredient/removeIngredient/addFood 方法缺少 var self = this
+- 
+
+### 新功能
+- 管理端挑战评分配置页+AdminChallengeScoreConfigController
+- respond端点+创建去门控+list返回myStatus
+- create改pending+respondChallenge+打卡结算口径收紧
+
+### 测试
+- 参与机制单元测试(respond/评分矩阵)
+
+
 ## v1.0.861 (2026-08-08)
 
 ### Bug 修复

+ 126 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.861
+> 当前版本: v1.0.871
 
 ## 历史版本
 
@@ -8,6 +8,131 @@
 
 ---
 
+## v1.0.871 (2026-08-08)
+
+### Bug 修复
+- 修复 UserService 中 createJoinRequest 调用
+- 修复 createJoinRequest 调用参数
+- 修复邀请流程 - 保存familyRole、修复轮询逻辑
+
+
+## v1.0.870 (2026-08-08)
+
+### Bug 修复
+- 修复 RequestBodyCachingFilter 编译问题
+- FamilyMemberStrip centerMemberId watcher 加 immediate + 清理重复 .action-header 样式
+- pairwise VO 补齐 isSpouse 与 lastInteractionAt(修复成员详情页 action-sheet 关系标签和上次填写时间显示)
+- 修正 parseDate import 语法(named export)
+
+### 新功能
+- 成员详情页接入以成员为中心的关系条
+- pairwise接口API + 成员条centerMemberId视角模式
+- 成员条空状态引导 + 长按菜单升级关系现状Action Sheet
+- 家庭成员卡片增加关系现状三色点
+- 关系pairwise接口(以成员为中心) + VO补齐沟通评分+isSelf
+- 问卷提交时同步pairwise关系评分到family_relationships
+- family_relationships表增加关系质量评分三列
+
+### 文档
+- 家庭成员关系条增强实现计划(FamilyMemberStrip)
+- 家庭成员关系条关系展示增强设计文档
+
+
+## v1.0.869 (2026-08-08)
+
+### Bug 修复
+- 移除多余的 String 构造函数调用
+- 修复 Java 8 兼容性问题 - readAllBytes 改用 readStream
+
+
+## v1.0.868 (2026-08-08)
+
+### Bug 修复
+- 将 Java 11 HttpClient 改为 RestTemplate 兼容 Java 8
+
+
+## v1.0.867 (2026-08-08)
+
+### Bug 修复
+- LangGraph 管理页面使用原生 fetch 避免 axios 拦截器问题
+- 迁移192 verification_codes.type 添加默认值
+
+### 其他
+- - 旧版 INSERT 未携带 type 字段,DB 列 NOT NULL 无默认值导致插入失败
+- - 使用 MODIFY COLUMN 添加 DEFAULT 'login'(向后兼容,无需改代码)
+- - 直连生产 DB ALTER 已执行(下次服务重启 DBI 迁移幂等跳过)
+- 
+
+
+## v1.0.866 (2026-08-08)
+
+### Bug 修复
+- LangGraph 管理页面使用独立 axios 实例避免 CORS 和响应格式问题
+
+
+## v1.0.865 (2026-08-08)
+
+### 新功能
+- 健康饮食菜单添加 LangGraph 管理入口
+
+
+## v1.0.864 (2026-08-08)
+
+### Bug 修复
+- 处理生产日志错误(foods.report_id/score + 知识库JSON + DBI迁移112)
+
+### 其他
+-   - 迁移191: foods 添加 report_id/score 列
+-   - 直连生产 DB ALTER 已执行
+- - 修复 菌属-食材映射 JSON 解析失败:
+-   - DB 顶层为 JSON Array 但代码期望 Map,加载时包装为 {_list: [...]}
+-   - 消费端兼容两种形态
+- - 修复 迁移112 日志等级:
+-   - Duplicate column 已存在是幂等预期,log.error → log.warn
+-   - 避免误报
+- 
+
+
+## v1.0.863 (2026-08-08)
+
+### Bug 修复
+- 修复测试编译与 getTemplates 断言;补充肠道菌群解析参考脚本
+
+### 新功能
+- 报告解析管理端页面 - 类型管理/指纹自学习/解析器导入/未知聚类
+- 报告解析 agent 接入指纹识别与自动标注
+- 报告指纹解析系统 - 类型注册/指纹识别/未知聚类/解析器导入
+
+### 其他
+- - report_parse API 对接报告类型注册表
+- - 新增 test_auto_tag 自动标注测试
+- 
+-   report_unknown_cluster/report_unknown_upload/report_parser_import 五表
+- - 新增 ReportParserController + ReportParserAdminController
+- - 新增 5 entity + 5 mapper + 3 service(ReportFingerprintService/
+-   ReportImportService/ReportParserDispatchService)
+- - schema.sql 同步 5 张新表定义
+- 
+
+
+## v1.0.862 (2026-08-08)
+
+### Bug 修复
+- 修复 diet/index.vue 运行时错误
+
+### 其他
+- - addIngredient/removeIngredient/addFood 方法缺少 var self = this
+- 
+
+### 新功能
+- 管理端挑战评分配置页+AdminChallengeScoreConfigController
+- respond端点+创建去门控+list返回myStatus
+- create改pending+respondChallenge+打卡结算口径收紧
+
+### 测试
+- 参与机制单元测试(respond/评分矩阵)
+
+
 ## v1.0.861 (2026-08-08)
 
 ### Bug 修复

+ 79 - 0
cfc-web/src/api/langgraph.js

@@ -0,0 +1,79 @@
+import axios from 'axios'
+
+// LangGraph 服务地址
+const LANGGRAPH_BASE = 'https://ai.etotem.com.cn'
+
+// 直接创建 axios 实例,不走通用拦截器(LangGraph 返回原始 JSON,不是 {code, data} 格式)
+const lgAxios = axios.create({
+  baseURL: LANGGRAPH_BASE,
+  timeout: 10000,
+  withCredentials: true
+})
+
+/**
+ * 获取 LangGraph 健康状态
+ */
+export function getLangGraphHealth() {
+  return new Promise((resolve) => {
+    const img = new Image()
+    img.onload = () => resolve({ status: 'healthy' })
+    img.onerror = () => resolve({ status: 'unreachable' })
+    img.src = LANGGRAPH_BASE + '/health?t=' + Date.now()
+  })
+}
+
+/**
+ * 获取 LangGraph OpenAPI 规范
+ */
+export function getLangGraphSpec() {
+  return lgAxios.get('/openapi.json')
+}
+
+/**
+ * 获取详细健康检查
+ */
+export function getLangGraphDetailedHealth() {
+  return lgAxios.get('/api/v1/health')
+}
+
+/**
+ * 获取 Prometheus metrics
+ */
+export function getLangGraphMetrics() {
+  return lgAxios.get('/metrics')
+}
+
+/**
+ * 测试食材识别
+ */
+export function testFoodRecognize(data) {
+  return lgAxios.post('/api/v1/food/recognize', data, { timeout: 60000 })
+}
+
+/**
+ * 测试菜单生成
+ */
+export function testMenuGenerate(data) {
+  return lgAxios.post('/api/v1/menu/generate', data, { timeout: 60000 })
+}
+
+/**
+ * 测试对话
+ */
+export function testChat(data) {
+  return lgAxios.post('/api/v1/chat', data, { timeout: 60000 })
+}
+
+/**
+ * 测试营养推荐
+ */
+export function testRecommend(data) {
+  return lgAxios.post('/api/v1/recommend', data, { timeout: 60000 })
+}
+
+/**
+ * 测试问卷生成
+ */
+export function testQuestionnaireGenerate(data) {
+  return lgAxios.post('/api/v1/questionnaire/generate', data, { timeout: 60000 })
+}

+ 136 - 0
cfc-web/src/api/reportParser.js

@@ -0,0 +1,136 @@
+import request from '@/utils/request'
+
+// ========== 报告类型管理 ==========
+export function getReportTypes(params) {
+  return request({
+    url: '/api/admin/report-parser/types',
+    method: 'post',
+    data: params
+  })
+}
+
+export function saveReportType(data) {
+  return request({
+    url: '/api/admin/report-parser/types/save',
+    method: 'post',
+    data
+  })
+}
+
+export function toggleReportType(data) {
+  return request({
+    url: '/api/admin/report-parser/types/toggle',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteReportType(data) {
+  return request({
+    url: '/api/admin/report-parser/types/delete',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 指纹规则管理 ==========
+export function getFingerprints(params) {
+  return request({
+    url: '/api/admin/report-parser/fingerprints/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function saveFingerprint(data) {
+  return request({
+    url: '/api/admin/report-parser/fingerprints/save',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteFingerprint(data) {
+  return request({
+    url: '/api/admin/report-parser/fingerprints/delete',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 导入管理 ==========
+export function getParserImports(params) {
+  return request({
+    url: '/api/admin/report-parser/imports',
+    method: 'post',
+    data: params
+  })
+}
+
+export function approveParserImport(data) {
+  return request({
+    url: '/api/admin/report-parser/imports/approve',
+    method: 'post',
+    data
+  })
+}
+
+export function rejectParserImport(data) {
+  return request({
+    url: '/api/admin/report-parser/imports/reject',
+    method: 'post',
+    data
+  })
+}
+
+export function importParserPackage(data) {
+  return request({
+    url: '/api/admin/report-parser/imports/import',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 未知报告聚类 ==========
+export function getUnknownClusters(params) {
+  return request({
+    url: '/api/admin/report-parser/unknown-clusters',
+    method: 'post',
+    data: params
+  })
+}
+
+export function getClusterUploads(params) {
+  return request({
+    url: '/api/admin/report-parser/unknown-uploads',
+    method: 'post',
+    data: params
+  })
+}
+
+// ========== 自学习生成 ==========
+export function generateTypeFromCluster(data) {
+  return request({
+    url: '/api/admin/report-parser/generate-type',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 上传/检测 ==========
+export function uploadAndParseReport(formData) {
+  return request({
+    url: '/api/report-parser/upload',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+export function detectReportType(data) {
+  return request({
+    url: '/api/report-parser/detect',
+    method: 'post',
+    data
+  })
+}

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

@@ -667,6 +667,38 @@ const routes = [
         name: 'BadgeManage',
         component: () => import('@/views/admin/BadgeManage'),
         meta: { title: '勋章管理', perm: 'system:config' }
+      },
+      // ========== 报告指纹解析系统 ==========
+      {
+        path: 'report-types',
+        name: 'ReportTypeManagement',
+        component: () => import('@/views/admin/ReportTypeManagement.vue'),
+        meta: { title: '报告类型管理', perm: 'system:config' }
+      },
+      {
+        path: 'report-parser-import',
+        name: 'ReportParserImport',
+        component: () => import('@/views/admin/ReportParserImport.vue'),
+        meta: { title: '解析器导入管理', perm: 'system:config' }
+      },
+      {
+        path: 'report-unknown-clusters',
+        name: 'ReportUnknownCluster',
+        component: () => import('@/views/admin/ReportUnknownCluster.vue'),
+        meta: { title: '未知报告审核', perm: 'system:config' }
+      },
+      {
+        path: 'report-auto-learn',
+        name: 'ReportAutoLearn',
+        component: () => import('@/views/admin/ReportAutoLearn.vue'),
+        meta: { title: '报告自学习', perm: 'system:config' }
+      },
+      // ========== LangGraph AI 服务管理 ==========
+      {
+        path: 'langgraph-admin',
+        name: 'LangGraphAdmin',
+        component: () => import('@/views/admin/LangGraphAdmin.vue'),
+        meta: { title: 'LangGraph 管理', perm: 'system:config' }
       }
     ]
   }

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

@@ -243,8 +243,17 @@ export default {
             { path: '/foods', label: '食材管理', icon: 'el-icon-apple', perm: 'diet:foods' },
             { path: '/recipes', label: '食谱管理', icon: 'el-icon-dish', perm: 'diet:recipes' },
             { path: '/seasonal-foods', label: '应季食材', icon: 'el-icon-sunny', perm: 'diet:seasonal' },
+            { path: '/langgraph-admin', label: 'LangGraph 管理', icon: 'el-icon-cpu', perm: 'system:config' },
           ]},
 
+        // ===== 报告解析系统 (admin) =====
+        { title: '报告解析系统', icon: 'el-icon-cpu', perm: 'system:config',
+          children: [
+            { path: '/report-types', label: '报告类型管理', icon: 'el-icon-collection', perm: 'system:config' },
+            { path: '/report-parser-import', label: '解析器导入', icon: 'el-icon-upload2', perm: 'system:config' },
+            { path: '/report-unknown-clusters', label: '未知报告审核', icon: 'el-icon-question', perm: 'system:config' },
+            { path: '/report-auto-learn', label: '报告自学习', icon: 'el-icon-magic-stick', perm: 'system:config' },
+          ]},
 
         { title: '家庭服务', icon: 'el-icon-s-custom', perm: 'service:*',
           children: [

+ 649 - 0
cfc-web/src/views/admin/LangGraphAdmin.vue

@@ -0,0 +1,649 @@
+<template>
+  <div class="langgraph-admin page-container">
+    <!-- 顶部状态栏 -->
+    <el-row :gutter="16" class="status-row">
+      <el-col :span="6">
+        <el-card :class="['status-card', statusClass]" shadow="hover">
+          <div class="status-icon">{{ statusIcon }}</div>
+          <div class="status-info">
+            <div class="status-label">服务状态</div>
+            <div class="status-value">{{ statusText }}</div>
+            <div class="status-sub">{{ statusTime }}</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="status-icon" style="background:linear-gradient(135deg,#667eea,#764ba2)">🔗</div>
+          <div class="status-info">
+            <div class="status-label">服务地址</div>
+            <div class="status-value" style="font-size:13px">{{ langgraphUrl }}</div>
+            <div class="status-sub">端口 9000 · v{{ serviceVersion }}</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="status-icon" style="background:linear-gradient(135deg,#11998e,#38ef7d)">📊</div>
+          <div class="status-info">
+            <div class="status-label">API 端点数</div>
+            <div class="status-value">{{ apiCount }}</div>
+            <div class="status-sub">个可用接口</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="status-icon" style="background:linear-gradient(135deg,#fc4a1a,#f7b733)">⚡</div>
+          <div class="status-info">
+            <div class="status-label">刷新操作</div>
+            <div style="margin-top:6px">
+              <el-button size="mini" type="primary" @click="refreshAll" :loading="refreshing">刷新</el-button>
+              <el-button size="mini" @click="testAllHealth">全部检测</el-button>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 组件健康状态 -->
+    <el-card class="component-card" shadow="never">
+      <div slot="header" class="card-header">
+        <span>🧩 组件健康状态</span>
+        <el-button size="mini" @click="fetchDetailedHealth">重新检测</el-button>
+      </div>
+      <el-table :data="componentList" border stripe size="small">
+        <el-table-column prop="name" label="组件" width="160" />
+        <el-table-column prop="status" label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.status === 'ok' ? 'success' : row.status === 'error' ? 'danger' : 'info'" size="small">
+              {{ row.status === 'ok' ? '正常' : row.status === 'error' ? '异常' : '未知' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="detail" label="详情" />
+      </el-table>
+    </el-card>
+
+    <!-- API 端点列表 -->
+    <el-card class="api-card" shadow="never">
+      <div slot="header" class="card-header">
+        <span>📡 API 端点列表 ({{ apiEndpoints.length }})</span>
+        <el-input v-model="apiFilter" placeholder="筛选端点..." size="mini" prefix-icon="el-icon-search" style="width:200px" clearable />
+      </div>
+      <el-table :data="filteredApiEndpoints" border stripe size="small" max-height="500">
+        <el-table-column prop="method" label="方法" width="80">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.method === 'GET' ? 'info' : 'primary'" size="mini">{{ row.method }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="path" label="路径" min-width="280" show-overflow-tooltip />
+        <el-table-column prop="summary" label="说明" min-width="200" show-overflow-tooltip />
+        <el-table-column label="操作" width="100" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button v-if="row.method === 'POST'" type="text" size="small" @click="openTestDialog(row)">测试</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 快速测试面板 -->
+    <el-card class="test-card" shadow="never">
+      <div slot="header" class="card-header">
+        <span>🧪 功能测试</span>
+        <el-button size="mini" type="success" @click="runQuickTests" :loading="quickTesting">一键测试</el-button>
+      </div>
+      <el-row :gutter="12">
+        <el-col :span="6" v-for="t in quickTests" :key="t.key">
+          <el-card shadow="hover" class="test-item" :class="'test-' + t.result">
+            <div class="test-item-header">
+              <span class="test-name">{{ t.name }}</span>
+              <el-tag :type="t.result === 'success' ? 'success' : t.result === 'error' ? 'danger' : 'info'" size="mini">
+                {{ t.result === 'success' ? '✓' : t.result === 'error' ? '✗' : '—' }}
+              </el-tag>
+            </div>
+            <div class="test-item-desc">{{ t.desc }}</div>
+            <div v-if="t.result === 'error'" class="test-item-error">{{ t.errorMsg }}</div>
+            <div v-if="t.result === 'success'" class="test-item-time">{{ t.time }}ms</div>
+          </el-card>
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <!-- 配置面板 -->
+    <el-card class="config-card" shadow="never">
+      <div slot="header" class="card-header">
+        <span>⚙️ 服务配置</span>
+        <el-button size="mini" type="primary" @click="fetchConfig">刷新配置</el-button>
+      </div>
+      <el-descriptions :column="2" border size="small">
+        <el-descriptions-item label="LLM 模型" :span="1">
+          <el-tag size="mini">{{ config.LLM_MODEL || '-' }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="LLM 基础 URL" :span="1">
+          {{ config.LLM_BASE_URL || '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="Embedding 模型" :span="1">
+          <el-tag size="mini">{{ config.EMBEDDING_MODEL || '-' }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="Embedding URL" :span="1">
+          {{ config.EMBEDDING_BASE_URL || '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="Java 后端地址" :span="1">
+          {{ config.JAVA_BASE_URL || '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="Chroma DB 路径" :span="1">
+          {{ config.CHROMA_DB_PATH || '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="LangSmith 追踪" :span="1">
+          <el-tag :type="config.LANGCHAIN_TRACING_V2 === 'true' ? 'success' : 'info'" size="mini">
+            {{ config.LANGCHAIN_TRACING_V2 === 'true' ? '已启用' : '未启用' }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="日志级别" :span="1">
+          <el-tag size="mini">{{ config.LOG_LEVEL || 'info' }}</el-tag>
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-card>
+
+    <!-- API 测试对话框 -->
+    <el-dialog :visible.sync="testDialogVisible" :title="'测试: ' + testingEndpoint.path" width="700px" :close-on-click-modal="false">
+      <el-form size="small">
+        <el-form-item label="请求参数">
+          <el-input
+            v-model="testParams"
+            type="textarea"
+            :rows="8"
+            placeholder='请输入 JSON 参数,例如: {"user_id": 1, "query": "你好"}'
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="runTest" :loading="testing">发送请求</el-button>
+          <el-button @click="testDialogVisible = false">关闭</el-button>
+        </el-form-item>
+      </el-form>
+      <el-divider>响应结果</el-divider>
+      <pre class="response-pre" v-if="testResponse">{{ testResponse }}</pre>
+      <el-alert v-else type="info" :closable="false" description="点击"发送请求"查看响应结果" />
+    </el-dialog>
+
+    <!-- 日志面板 -->
+    <el-card class="log-card" shadow="never">
+      <div slot="header" class="card-header">
+        <span>📋 最近日志</span>
+        <div>
+          <el-button size="mini" @click="fetchLogs" :loading="logLoading">刷新</el-button>
+          <el-button size="mini" type="danger" @click="clearLogs">清空</el-button>
+        </div>
+      </div>
+      <div class="log-container" ref="logContainer">
+        <div v-for="(log, i) in logList" :key="i" class="log-line" :class="'log-' + getLogLevel(log)">
+          <span class="log-time">{{ log.time }}</span>
+          <span class="log-level">{{ log.level }}</span>
+          <span class="log-msg">{{ log.msg }}</span>
+        </div>
+        <div v-if="logList.length === 0" class="log-empty">暂无日志</div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+const BASE = 'https://ai.etotem.com.cn'
+
+function fetchJSON(url, options = {}) {
+  return fetch(url, {
+    ...options,
+    headers: {
+      'Content-Type': 'application/json',
+      ...options.headers
+    }
+  }).then(function(res) {
+    if (!res.ok) throw new Error('HTTP ' + res.status)
+    return res.json()
+  })
+}
+
+function fetchText(url, options = {}) {
+  return fetch(url, {
+    ...options,
+    headers: {
+      'Content-Type': 'application/json',
+      ...options.headers
+    }
+  }).then(function(res) {
+    if (!res.ok) throw new Error('HTTP ' + res.status)
+    return res.text()
+  })
+}
+
+export default {
+  name: 'LangGraphAdmin',
+  data() {
+    return {
+      langgraphUrl: 'https://ai.etotem.com.cn',
+      serviceVersion: '0.3.0',
+      statusText: '检测中...',
+      statusClass: 'status-checking',
+      statusIcon: '🔄',
+      statusTime: '',
+      apiCount: 0,
+      apiEndpoints: [],
+      apiFilter: '',
+      componentList: [],
+      refreshing: false,
+      quickTesting: false,
+      config: {
+        LLM_MODEL: 'deepseek',
+        LLM_BASE_URL: 'http://axonhub.iwintrue.com/v1',
+        EMBEDDING_MODEL: 'BAAI/bge-large-zh-v1.5',
+        EMBEDDING_BASE_URL: 'https://api.siliconflow.cn/v1',
+        JAVA_BASE_URL: 'http://111.227.6.214:9082',
+        CHROMA_DB_PATH: '/data/chroma_db',
+        LANGCHAIN_TRACING_V2: 'true',
+        LOG_LEVEL: 'info'
+      },
+      testDialogVisible: false,
+      testingEndpoint: {},
+      testParams: '{}',
+      testing: false,
+      testResponse: '',
+      logList: [],
+      logLoading: false,
+      _interval: null
+    }
+  },
+  computed: {
+    filteredApiEndpoints: function() {
+      var self = this
+      if (!self.apiFilter) return self.apiEndpoints
+      var kw = self.apiFilter.toLowerCase()
+      return self.apiEndpoints.filter(function(e) {
+        return e.path.toLowerCase().indexOf(kw) >= 0 || (e.summary && e.summary.toLowerCase().indexOf(kw) >= 0)
+      })
+    },
+    quickTests: function() {
+      return [
+        { key: 'food', name: '食材识别', desc: 'POST /api/v1/food/recognize', result: 'pending', time: 0, errorMsg: '' },
+        { key: 'menu', name: '菜单生成', desc: 'POST /api/v1/menu/generate', result: 'pending', time: 0, errorMsg: '' },
+        { key: 'chat', name: 'AI 对话', desc: 'POST /api/v1/chat', result: 'pending', time: 0, errorMsg: '' },
+        { key: 'questionnaire', name: '问卷生成', desc: 'POST /api/v1/questionnaire/generate', result: 'pending', time: 0, errorMsg: '' }
+      ]
+    }
+  },
+  created: function() {
+    this.refreshAll()
+    this.fetchLogs()
+    this._interval = setInterval(this.refreshAll, 30000)
+  },
+  beforeDestroy: function() {
+    if (this._interval) clearInterval(this._interval)
+  },
+  methods: {
+    async refreshAll() {
+      this.refreshing = true
+      try {
+        await Promise.all([this.fetchHealth(), this.fetchSpec()])
+      } finally {
+        this.refreshing = false
+      }
+    },
+    async fetchHealth() {
+      try {
+        var img = new Image()
+        var healthy = await new Promise(function(resolve) {
+          img.onload = function() { resolve(true) }
+          img.onerror = function() { resolve(false) }
+          img.src = BASE + '/health?t=' + Date.now()
+        })
+        if (healthy) {
+          this.statusText = '运行中'
+          this.statusClass = 'status-healthy'
+          this.statusIcon = '✅'
+        } else {
+          this.statusText = '未响应'
+          this.statusClass = 'status-unhealthy'
+          this.statusIcon = '❌'
+        }
+      } catch (e) {
+        this.statusText = '异常'
+        this.statusClass = 'status-unhealthy'
+        this.statusIcon = '❌'
+      }
+      this.statusTime = new Date().toLocaleTimeString()
+    },
+    async fetchSpec() {
+      try {
+        var res = await fetchJSON(BASE + '/openapi.json')
+        var paths = res.paths || {}
+        var endpoints = []
+        var self = this
+        Object.keys(paths).forEach(function(path) {
+          var methods = paths[path]
+          Object.keys(methods).forEach(function(method) {
+            var info = methods[method]
+            endpoints.push({
+              method: method.toUpperCase(),
+              path: path,
+              summary: info.summary || info.description || ''
+            })
+          })
+        })
+        endpoints.sort(function(a, b) {
+          if (a.method !== b.method) return a.method.localeCompare(b.method)
+          return a.path.localeCompare(b.path)
+        })
+        this.apiEndpoints = endpoints
+        this.apiCount = endpoints.length
+        this.fetchDetailedHealth()
+      } catch (e) {
+        console.error('获取 API 规范失败', e)
+      }
+    },
+    async fetchDetailedHealth() {
+      try {
+        var res = await fetchJSON(BASE + '/api/v1/health')
+        var data = res || {}
+        var comps = []
+        var c = data.components || {}
+        if (c.chromadb) {
+          comps.push({ name: 'ChromaDB', status: c.chromadb.status || 'unknown', detail: c.chromadb.path || '' })
+        }
+        if (c.llm) {
+          comps.push({ name: 'LLM', status: c.llm.status || 'unknown', detail: c.llm.message || '' })
+        }
+        if (c.java_backend) {
+          comps.push({ name: 'Java 后端', status: c.java_backend.status || 'unknown', detail: c.java_backend.code ? 'HTTP ' + c.java_backend.code : '' })
+        }
+        this.componentList = comps
+        var allOk = comps.every(function(x) { return x.status === 'ok' })
+        var hasError = comps.some(function(x) { return x.status === 'error' })
+        if (allOk) {
+          this.statusText = '运行中'
+          this.statusClass = 'status-healthy'
+          this.statusIcon = '✅'
+        } else if (hasError) {
+          this.statusText = '部分异常'
+          this.statusClass = 'status-degraded'
+          this.statusIcon = '⚠️'
+        } else {
+          this.statusText = '检测中'
+          this.statusClass = 'status-checking'
+          this.statusIcon = '🔄'
+        }
+        this.statusTime = new Date().toLocaleTimeString()
+      } catch (e) {
+        this.componentList = [{ name: '健康检查', status: 'error', detail: e.message }]
+      }
+    },
+    fetchConfig: function() {
+      this.config = {
+        LLM_MODEL: 'deepseek',
+        LLM_BASE_URL: 'http://axonhub.iwintrue.com/v1',
+        EMBEDDING_MODEL: 'BAAI/bge-large-zh-v1.5',
+        EMBEDDING_BASE_URL: 'https://api.siliconflow.cn/v1',
+        JAVA_BASE_URL: 'http://111.227.6.214:9082',
+        CHROMA_DB_PATH: '/data/chroma_db',
+        LANGCHAIN_TRACING_V2: 'true',
+        LOG_LEVEL: 'info'
+      }
+    },
+    openTestDialog: function(endpoint) {
+      this.testingEndpoint = endpoint
+      var defaults = {
+        '/api/v1/food/recognize': JSON.stringify({ image_url: 'https://example.com/food.jpg' }, null, 2),
+        '/api/v1/menu/generate': JSON.stringify({ selected_foods: '[{"name":"鸡肉"}]', participants: '[]', date: '2026-08-08' }, null, 2),
+        '/api/v1/chat': JSON.stringify({ query: '你好', user_id: 1 }, null, 2),
+        '/api/v1/recommend': JSON.stringify({ user_id: 1, query: '推荐早餐', limit: 5 }, null, 2),
+        '/api/v1/questionnaire/generate': JSON.stringify({ member_name: '小明', relationship_type: 'parent' }, null, 2)
+      }
+      this.testParams = defaults[endpoint.path] || '{}'
+      this.testResponse = ''
+      this.testDialogVisible = true
+    },
+    async runTest() {
+      var self = this
+      self.testing = true
+      self.testResponse = ''
+      try {
+        var payload = {}
+        if (self.testParams && self.testParams !== '请上传舌苔图片 (MIME 类型表单)') {
+          payload = JSON.parse(self.testParams)
+        }
+        var path = self.testingEndpoint.path
+        var res = await fetch(BASE + path, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify(payload),
+          signal: AbortSignal.timeout(60000)
+        })
+        var data = await res.json()
+        self.testResponse = JSON.stringify(data, null, 2)
+      } catch (e) {
+        self.testResponse = '错误: ' + e.message
+      } finally {
+        self.testing = false
+      }
+    },
+    async runQuickTests() {
+      var self = this
+      self.quickTesting = true
+      var tests = self.quickTests.slice()
+      var results = { food: tests[0], menu: tests[1], chat: tests[2], questionnaire: tests[3] }
+
+      var t0 = Date.now()
+      try {
+        var r1 = await fetch(BASE + '/api/v1/food/recognize', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ image_url: 'https://example.com/food.jpg' })
+        })
+        await r1.json()
+        results.food.result = 'success'
+        results.food.time = Date.now() - t0
+      } catch (e) {
+        results.food.result = 'error'
+        results.food.errorMsg = e.message
+      }
+
+      t0 = Date.now()
+      try {
+        var r2 = await fetch(BASE + '/api/v1/menu/generate', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ selected_foods: '[{"name":"鸡肉"}]', participants: '[]', date: '2026-08-08' })
+        })
+        await r2.json()
+        results.menu.result = 'success'
+        results.menu.time = Date.now() - t0
+      } catch (e) {
+        results.menu.result = 'error'
+        results.menu.errorMsg = e.message
+      }
+
+      t0 = Date.now()
+      try {
+        var r3 = await fetch(BASE + '/api/v1/chat', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ query: '你好', user_id: 1 })
+        })
+        await r3.json()
+        results.chat.result = 'success'
+        results.chat.time = Date.now() - t0
+      } catch (e) {
+        results.chat.result = 'error'
+        results.chat.errorMsg = e.message
+      }
+
+      t0 = Date.now()
+      try {
+        var r4 = await fetch(BASE + '/api/v1/questionnaire/generate', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ member_name: '小明', relationship_type: 'parent' })
+        })
+        await r4.json()
+        results.questionnaire.result = 'success'
+        results.questionnaire.time = Date.now() - t0
+      } catch (e) {
+        results.questionnaire.result = 'error'
+        results.questionnaire.errorMsg = e.message
+      }
+
+      tests[0] = results.food
+      tests[1] = results.menu
+      tests[2] = results.chat
+      tests[3] = results.questionnaire
+      self.quickTesting = false
+    },
+    async fetchLogs() {
+      this.logLoading = true
+      try {
+        var res = await fetch(BASE + '/openapi.json')
+        var data = await res.json()
+        this.logList = [{
+          time: new Date().toLocaleTimeString(),
+          level: 'INFO',
+          msg: '服务连接正常,API 端点数: ' + Object.keys(data.paths || {}).length
+        }]
+      } catch (e) {
+        this.logList = [{
+          time: new Date().toLocaleTimeString(),
+          level: 'ERROR',
+          msg: '无法连接到 LangGraph 服务: ' + e.message
+        }]
+      } finally {
+        this.logLoading = false
+      }
+    },
+    clearLogs: function() {
+      this.logList = []
+    },
+    testAllHealth: function() {
+      this.fetchHealth()
+      this.fetchDetailedHealth()
+    },
+    getLogLevel: function(log) {
+      if (log.level === 'ERROR' || log.level === 'error') return 'error'
+      if (log.level === 'WARNING' || log.level === 'WARN') return 'warning'
+      return 'info'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container {
+  padding: 16px;
+}
+.status-row {
+  margin-bottom: 16px;
+}
+.status-card {
+  display: flex;
+  align-items: center;
+  padding: 12px 16px;
+  cursor: default;
+  transition: all 0.3s;
+}
+.status-card:hover {
+  transform: translateY(-2px);
+}
+.status-healthy { border-left: 4px solid #67c23a; }
+.status-unhealthy { border-left: 4px solid #f56c6c; }
+.status-degraded { border-left: 4px solid #e6a23c; }
+.status-checking { border-left: 4px solid #409eff; }
+.status-icon {
+  font-size: 32px;
+  margin-right: 16px;
+}
+.status-info {
+  flex: 1;
+}
+.status-label {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 4px;
+}
+.status-value {
+  font-size: 18px;
+  font-weight: bold;
+  color: #303133;
+}
+.status-sub {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.test-item {
+  cursor: default;
+  transition: all 0.3s;
+}
+.test-item:hover {
+  transform: translateY(-2px);
+}
+.test-item-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.test-name {
+  font-weight: 600;
+  font-size: 14px;
+  color: #303133;
+}
+.test-item-desc {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 6px;
+}
+.test-item-error {
+  font-size: 11px;
+  color: #f56c6c;
+  margin-top: 4px;
+}
+.test-item-time {
+  font-size: 11px;
+  color: #67c23a;
+  margin-top: 4px;
+}
+.response-pre {
+  background: #f5f7fa;
+  padding: 12px;
+  border-radius: 4px;
+  font-size: 12px;
+  max-height: 400px;
+  overflow-y: auto;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+.log-container {
+  max-height: 250px;
+  overflow-y: auto;
+  background: #1e1e1e;
+  border-radius: 4px;
+  padding: 12px;
+  font-family: 'Courier New', monospace;
+  font-size: 12px;
+}
+.log-line {
+  display: flex;
+  gap: 12px;
+  padding: 2px 0;
+  line-height: 1.6;
+}
+.log-time { color: #888; min-width: 80px; }
+.log-level { min-width: 60px; font-weight: bold; }
+.log-level.ERROR { color: #f56c6c; }
+.log-level.WARNING { color: #e6a23c; }
+.log-level.INFO { color: #67c23a; }
+.log-msg { color: #d4d4d4; flex: 1; }
+.log-empty { color: #666; text-align: center; padding: 20px; }
+</style>

+ 114 - 0
cfc-web/src/views/admin/ReportAutoLearn.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="report-autolearn admin-page">
+    <el-card>
+      <div slot="header">
+        <span>报告自学习</span>
+        <div style="float: right;">
+          <el-button size="small" @click="fetchClusters" icon="el-icon-refresh">刷新</el-button>
+        </div>
+      </div>
+
+      <el-alert title="自学习机制:当同一类型的未知报告积累到3份后,系统自动标记为"可生成"状态,管理员可在此生成报告类型和指纹规则。" type="info" :closable="false" show-icon style="margin-bottom: 16px;" />
+
+      <el-table :data="clusters" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="clusterKey" label="聚类键" width="140" />
+        <el-table-column prop="reportCount" label="报告数" width="80" />
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.status === 'ready' ? 'warning' : 'success'" size="small">
+              {{ row.status === 'ready' ? '可生成' : '已生成' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="generatedTypeId" label="类型ID" width="140" />
+        <el-table-column prop="updatedAt" label="更新时间" width="180" />
+        <el-table-column label="操作" width="180" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="handleDetail(row)">详情</el-button>
+            <el-button v-if="row.status === 'ready'" size="mini" type="success" @click="handleGenerate(row)">生成类型</el-button>
+            <el-button v-if="row.status === 'generated'" size="mini" type="primary" @click="handleViewType(row)" plain>查看类型</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :visible.sync="detailVisible" title="聚类详情" width="750px">
+      <template v-if="currentCluster">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="聚类键">{{ currentCluster.clusterKey }}</el-descriptions-item>
+          <el-descriptions-item label="报告数">{{ currentCluster.reportCount }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ currentCluster.status === 'ready' ? '可生成' : '已生成' }}</el-descriptions-item>
+          <el-descriptions-item label="类型ID">{{ currentCluster.generatedTypeId || '—' }}</el-descriptions-item>
+        </el-descriptions>
+        <h4>公共文本特征</h4>
+        <pre class="detail-pre">{{ currentCluster.commonSignatures }}</pre>
+        <h4>LLM 解析模板</h4>
+        <pre class="detail-pre">{{ formatJson(currentCluster.llmTemplate) }}</pre>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getUnknownClusters, generateTypeFromCluster } from '@/api/reportParser'
+
+export default {
+  name: 'ReportAutoLearn',
+  data() {
+    return {
+      loading: false,
+      clusters: [],
+      detailVisible: false,
+      currentCluster: null,
+    }
+  },
+  mounted() { this.fetchClusters() },
+  methods: {
+    async fetchClusters() {
+      this.loading = true
+      try {
+        const res = await getUnknownClusters({ page: 1, size: 50 })
+        this.clusters = (res.data?.records || res.data || []).filter(
+          c => c.status === 'ready' || c.status === 'generated')
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    formatJson(str) {
+      if (!str) return ''
+      try { return JSON.stringify(JSON.parse(str), null, 2) } catch (e) { return str }
+    },
+    handleDetail(row) {
+      this.currentCluster = row
+      this.detailVisible = true
+    },
+    handleGenerate(row) {
+      this.$confirm('确认为此聚类生成报告类型和指纹规则?生成后需在"报告类型管理"中审核启用。', '提示', { type: 'warning' })
+        .then(async () => {
+          try {
+            const res = await generateTypeFromCluster({ clusterId: row.id })
+            this.$message.success('已生成类型: ' + (res.data?.typeId || ''))
+            this.fetchClusters()
+          } catch (e) { this.$message.error('生成失败') }
+        }).catch(() => {})
+    },
+    handleViewType(row) {
+      this.$router.push('/report-types')
+    },
+  }
+}
+</script>
+
+<style scoped>
+.detail-pre {
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  padding: 10px;
+  max-height: 300px;
+  overflow: auto;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 175 - 0
cfc-web/src/views/admin/ReportParserImport.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="report-import admin-page">
+    <el-card>
+      <div slot="header">
+        <span>解析器导入管理</span>
+        <div style="float: right; display: flex; gap: 10px;">
+          <el-button type="primary" size="small" icon="el-icon-upload2" @click="uploadDialogVisible = true">上传导入包</el-button>
+        </div>
+      </div>
+
+      <el-table :data="imports" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="packageName" label="导入包名称" min-width="200" />
+        <el-table-column prop="packageFileUrl" label="文件" width="150" />
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="导入时间" width="180" />
+        <el-table-column label="操作" width="160" fixed="right">
+          <template slot-scope="{ row }">
+            <template v-if="row.status === 'pending'">
+              <el-button size="mini" type="success" @click="handleApprove(row)">批准</el-button>
+              <el-button size="mini" type="danger" @click="handleReject(row)">驳回</el-button>
+            </template>
+            <el-button size="mini" type="primary" @click="handlePreview(row)">预览</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 上传导入包对话框 -->
+    <el-dialog :visible.sync="uploadDialogVisible" title="上传导入包" width="600px" :close-on-click-modal="false">
+      <el-upload
+        drag
+        :auto-upload="false"
+        :limit="1"
+        accept=".json"
+        :on-change="handleFileChange">
+        <i class="el-icon-upload"></i>
+        <div class="el-upload__text">将 json 文件拖到此处,或<em>点击上传</em></div>
+        <div class="el-upload__tip" slot="tip">导入包 JSON 格式:{ packageName, typeId, displayName, family, description, parserScript, fingerprints: [{pattern, weight, isExclusion}] }</div>
+      </el-upload>
+      <div slot="footer">
+        <el-button @click="uploadDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleImport" :loading="submitting">确认导入</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 预览导入包对话框 -->
+    <el-dialog :visible.sync="previewDialogVisible" title="导入包预览" width="700px">
+      <div v-if="previewData">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="名称">{{ previewData.packageName }}</el-descriptions-item>
+          <el-descriptions-item label="类型ID">{{ previewData.typeId }}</el-descriptions-item>
+          <el-descriptions-item label="显示名称">{{ previewData.displayName }}</el-descriptions-item>
+          <el-descriptions-item label="家族">{{ previewData.family }}</el-descriptions-item>
+          <el-descriptions-item label="描述" :span="2">{{ previewData.description }}</el-descriptions-item>
+        </el-descriptions>
+        <h4>指纹规则</h4>
+        <el-table :data="previewData.fingerprints || []" border size="small">
+          <el-table-column prop="pattern" label="文本特征" min-width="200" />
+          <el-table-column prop="weight" label="权重" width="70" />
+          <el-table-column label="类型" width="80">
+            <template slot-scope="{ row }">
+              <el-tag :type="row.isExclusion ? 'danger' : 'success'" size="mini">{{ row.isExclusion ? '排除' : '正向' }}</el-tag>
+            </template>
+          </el-table-column>
+        </el-table>
+        <h4>解析脚本</h4>
+        <pre class="script-preview">{{ previewData.parserScript }}</pre>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getParserImports, approveParserImport, rejectParserImport, importParserPackage } from '@/api/reportParser'
+
+export default {
+  name: 'ReportParserImport',
+  data() {
+    return {
+      loading: false, submitting: false,
+      imports: [],
+      uploadDialogVisible: false,
+      previewDialogVisible: false,
+      selectedFile: null,
+      previewData: null,
+    }
+  },
+  mounted() { this.fetchImports() },
+  methods: {
+    async fetchImports() {
+      this.loading = true
+      try {
+        const res = await getParserImports({ page: 1, size: 50 })
+        this.imports = res.data?.records || res.data || []
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    statusType(status) {
+      return { pending: 'warning', approved: 'success', rejected: 'danger' }[status] || 'info'
+    },
+    statusText(status) {
+      return { pending: '待审核', approved: '已批准', rejected: '已驳回' }[status] || status
+    },
+    handleFileChange(file) {
+      this.selectedFile = file.raw
+    },
+    async handleImport() {
+      if (!this.selectedFile) {
+        this.$message.warning('请先选择文件')
+        return
+      }
+      this.submitting = true
+      const reader = new FileReader()
+      reader.onload = async (e) => {
+        try {
+          const jsonContent = e.target.result
+          const res = await importParserPackage({ jsonContent, fileName: this.selectedFile.name })
+          this.$message.success('导入成功,待审核')
+          this.uploadDialogVisible = false
+          this.selectedFile = null
+          this.fetchImports()
+        } catch (err) { this.$message.error('导入失败') }
+        this.submitting = false
+      }
+      reader.readAsText(this.selectedFile)
+    },
+    handlePreview(row) {
+      try {
+        const jsonContent = row.fingerprintJson
+        this.previewData = JSON.parse(jsonContent)
+        this.previewDialogVisible = true
+      } catch (e) { this.$message.error('预览失败:JSON 解析错误') }
+    },
+    handleApprove(row) {
+      this.$confirm('确认批准此导入?将注册报告类型和指纹规则。', '提示', { type: 'warning' })
+        .then(async () => {
+          try {
+            await approveParserImport({ id: row.id })
+            this.$message.success('已批准')
+            this.fetchImports()
+          } catch (e) { this.$message.error('操作失败') }
+        }).catch(() => {})
+    },
+    handleReject(row) {
+      this.$prompt('请输入驳回原因', '驳回导入', { inputPlaceholder: '驳回原因', inputValidator: (v) => !!v || '请输入原因' })
+        .then(async ({ value }) => {
+          try {
+            await rejectParserImport({ id: row.id, note: value })
+            this.$message.success('已驳回')
+            this.fetchImports()
+          } catch (e) { this.$message.error('操作失败') }
+        }).catch(() => {})
+    },
+  }
+}
+</script>
+
+<style scoped>
+.script-preview {
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  padding: 10px;
+  max-height: 300px;
+  overflow: auto;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 240 - 0
cfc-web/src/views/admin/ReportTypeManagement.vue

@@ -0,0 +1,240 @@
+<template>
+  <div class="report-types admin-page">
+    <el-card>
+      <div slot="header">
+        <span>报告类型管理</span>
+        <div style="float: right; display: flex; gap: 10px;">
+          <el-input v-model="keyword" placeholder="搜索类型..." prefix-icon="el-icon-search" size="small" clearable style="width: 200px;" />
+          <el-button type="primary" size="small" @click="handleCreate">+ 新增类型</el-button>
+        </div>
+      </div>
+
+      <el-table :data="filteredTypes" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="typeId" label="类型标识" width="140" />
+        <el-table-column prop="displayName" label="显示名称" min-width="160" />
+        <el-table-column prop="family" label="家族" width="100" />
+        <el-table-column prop="parserPath" label="解析器路径" width="200" />
+        <el-table-column prop="minConfidence" label="阈值" width="70" />
+        <el-table-column label="状态" width="80">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.isActive ? 'success' : 'info'" size="small">
+              {{ row.isActive ? '启用' : '停用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="source" label="来源" width="100" />
+        <el-table-column label="操作" width="220" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+            <el-button size="mini" :type="row.isActive ? 'warning' : 'success'" @click="handleToggle(row)">
+              {{ row.isActive ? '停用' : '启用' }}
+            </el-button>
+            <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
+              <el-button size="mini">
+                更多<i class="el-icon-arrow-down el-icon--right"></i>
+              </el-button>
+              <el-dropdown-menu slot="dropdown">
+                <el-dropdown-item command="fingerprints" icon="el-icon-s-operation">指纹规则</el-dropdown-item>
+                <el-dropdown-item command="delete" icon="el-icon-delete" divided>删除</el-dropdown-item>
+              </el-dropdown-menu>
+            </el-dropdown>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 编辑/新增对话框 -->
+    <el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑报告类型' : '新增报告类型'" width="550px">
+      <el-form :model="form" label-width="100px" size="small">
+        <el-form-item label="类型标识" required>
+          <el-input v-model="form.typeId" placeholder="如 dan_a1 / gut_beijing" :disabled="isEdit" />
+          <div class="el-form-item-msg">唯一标识,创建后不可修改</div>
+        </el-form-item>
+        <el-form-item label="显示名称" required>
+          <el-input v-model="form.displayName" placeholder="如 A1 儿童核心认知发展" />
+        </el-form-item>
+        <el-form-item label="所属家族">
+          <el-input v-model="form.family" placeholder="如 dan / gut_flora / custom" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="解析器路径">
+          <el-input v-model="form.parserPath" placeholder="如 dan_reports/extract_all_types.py" />
+        </el-form-item>
+        <el-form-item label="识别阈值">
+          <el-input-number v-model="form.minConfidence" :min="1" :max="100" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 指纹规则管理对话框 -->
+    <el-dialog :visible.sync="fpDialogVisible" title="指纹规则管理" width="700px" :close-on-click-modal="false">
+      <div slot="header">
+        <span>指纹规则 - {{ currentType?.typeId }}</span>
+        <el-button type="primary" size="small" style="float:right" @click="handleAddFp">+ 新增指纹</el-button>
+      </div>
+      <el-table :data="fingerprints" v-loading="fpLoading" border stripe size="small">
+        <el-table-column prop="pattern" label="文本特征" min-width="250" />
+        <el-table-column prop="weight" label="权重" width="80" />
+        <el-table-column label="类型" width="80">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.isExclusion ? 'danger' : 'success'" size="mini">
+              {{ row.isExclusion ? '排除' : '正向' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sortOrder" label="排序" width="60" />
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="danger" @click="handleDeleteFp(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-dialog :visible.sync="fpFormVisible" title="新增指纹规则" width="450px" :append-to-body="true">
+        <el-form :model="fpForm" label-width="80px" size="small">
+          <el-form-item label="文本特征" required>
+            <el-input v-model="fpForm.pattern" placeholder="如 GROWTH MINDSET" />
+          </el-form-item>
+          <el-form-item label="权重">
+            <el-input-number v-model="fpForm.weight" :min="-100" :max="100" />
+            <div class="el-form-item-msg">正=正向匹配加分,负=排除扣分</div>
+          </el-form-item>
+          <el-form-item label="排除规则">
+            <el-switch v-model="fpForm.isExclusion" />
+          </el-form-item>
+          <el-form-item label="排序">
+            <el-input-number v-model="fpForm.sortOrder" :min="0" :max="999" />
+          </el-form-item>
+        </el-form>
+        <div slot="footer">
+          <el-button @click="fpFormVisible = false">取消</el-button>
+          <el-button type="primary" @click="handleSubmitFp" :loading="fpSubmitting">保存</el-button>
+        </div>
+      </el-dialog>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getReportTypes, saveReportType, toggleReportType, deleteReportType,
+         getFingerprints, saveFingerprint, deleteFingerprint } from '@/api/reportParser'
+
+export default {
+  name: 'ReportTypeManagement',
+  data() {
+    return {
+      loading: false, submitting: false, fpLoading: false, fpSubmitting: false,
+      types: [], keyword: '',
+      dialogVisible: false, isEdit: false,
+      form: { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 },
+      fpDialogVisible: false, currentType: null,
+      fingerprints: [], fpFormVisible: false,
+      fpForm: { pattern: '', weight: 10, isExclusion: false, sortOrder: 0 },
+    }
+  },
+  computed: {
+    filteredTypes() {
+      if (!this.keyword) return this.types
+      const kw = this.keyword.toLowerCase()
+      return this.types.filter(t =>
+        (t.typeId || '').toLowerCase().includes(kw) ||
+        (t.displayName || '').toLowerCase().includes(kw) ||
+        (t.family || '').toLowerCase().includes(kw))
+    }
+  },
+  mounted() { this.fetchTypes() },
+  methods: {
+    async fetchTypes() {
+      this.loading = true
+      try {
+        const res = await getReportTypes({ page: 1, size: 999 })
+        this.types = (res.data?.records || res.data || [])
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    handleCreate() {
+      this.isEdit = false
+      this.form = { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 }
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = { ...row }
+      this.dialogVisible = true
+    },
+    async handleSubmit() {
+      this.submitting = true
+      try {
+        await saveReportType(this.form)
+        this.$message.success('保存成功')
+        this.dialogVisible = false
+        this.fetchTypes()
+      } catch (e) { this.$message.error('保存失败') }
+      this.submitting = false
+    },
+    async handleToggle(row) {
+      try {
+        await toggleReportType({ typeId: row.typeId, isActive: !row.isActive })
+        this.$message.success('已更新')
+        this.fetchTypes()
+      } catch (e) { this.$message.error('操作失败') }
+    },
+    handleActionCmd(row, cmd) {
+      if (cmd === 'fingerprints') this.handleFingerprints(row)
+      if (cmd === 'delete') this.handleDelete(row)
+    },
+    async handleDelete(row) {
+      await this.$confirm('确定删除此类型及其指纹规则?', '提示', { type: 'warning' })
+      try {
+        await deleteReportType({ typeId: row.typeId })
+        this.$message.success('已删除')
+        this.fetchTypes()
+      } catch (e) { this.$message.error('删除失败') }
+    },
+    async handleFingerprints(row) {
+      this.currentType = row
+      this.fpDialogVisible = true
+      this.fingerprints = []
+      this.fpLoading = true
+      try {
+        const res = await getFingerprints({ typeId: row.typeId })
+        this.fingerprints = res.data || []
+      } catch (e) { this.$message.error('加载指纹失败') }
+      this.fpLoading = false
+    },
+    handleAddFp() {
+      this.fpForm = { pattern: '', weight: 10, isExclusion: false, sortOrder: 0 }
+      this.fpFormVisible = true
+    },
+    async handleSubmitFp() {
+      this.fpSubmitting = true
+      try {
+        await saveFingerprint({ ...this.fpForm, typeId: this.currentType.typeId })
+        this.$message.success('保存成功')
+        this.fpFormVisible = false
+        this.handleFingerprints(this.currentType)
+      } catch (e) { this.$message.error('保存失败') }
+      this.fpSubmitting = false
+    },
+    async handleDeleteFp(row) {
+      await this.$confirm('确定删除此指纹规则?', '提示', { type: 'warning' })
+      try {
+        await deleteFingerprint({ id: row.id })
+        this.$message.success('已删除')
+        this.handleFingerprints(this.currentType)
+      } catch (e) { this.$message.error('删除失败') }
+    },
+  }
+}
+</script>
+
+<style scoped>
+.el-form-item-msg { font-size: 12px; color: #999; margin-top: 2px; }
+</style>

+ 133 - 0
cfc-web/src/views/admin/ReportUnknownCluster.vue

@@ -0,0 +1,133 @@
+<template>
+  <div class="report-unknown admin-page">
+    <el-card>
+      <div slot="header">
+        <span>未知报告审核</span>
+        <div style="float: right; display: flex; gap: 10px;">
+          <el-select v-model="statusFilter" size="small" style="width: 140px;" @change="fetchClusters">
+            <el-option label="全部" value="" />
+            <el-option label="收集" value="collecting" />
+            <el-option label="可生成" value="ready" />
+            <el-option label="已生成" value="generated" />
+          </el-select>
+        </div>
+      </div>
+
+      <el-table :data="clusters" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="clusterKey" label="聚类键" width="140" />
+        <el-table-column prop="reportCount" label="报告数" width="80" />
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="generatedTypeId" label="生成类型" width="140" />
+        <el-table-column prop="updatedAt" label="更新时间" width="180" />
+        <el-table-column label="操作" width="160" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="handleDetail(row)">详情</el-button>
+            <el-button v-if="row.status === 'ready'" size="mini" type="success" @click="handleGenerate(row)">生成类型</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 详情对话框 -->
+    <el-dialog :visible.sync="detailVisible" title="聚类详情" width="750px" :close-on-click-modal="false">
+      <template v-if="currentCluster">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="聚类键">{{ currentCluster.clusterKey }}</el-descriptions-item>
+          <el-descriptions-item label="报告数">{{ currentCluster.reportCount }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ statusText(currentCluster.status) }}</el-descriptions-item>
+          <el-descriptions-item label="生成类型">{{ currentCluster.generatedTypeId || '—' }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4>公共文本特征</h4>
+        <pre class="detail-pre">{{ currentCluster.commonSignatures }}</pre>
+
+        <h4>LLM 解析模板</h4>
+        <pre class="detail-pre">{{ formatJson(currentCluster.llmTemplate) }}</pre>
+
+        <h4>聚类内报告</h4>
+        <el-table :data="uploads" v-loading="uploadLoading" border size="small">
+          <el-table-column prop="id" label="ID" width="60" />
+          <el-table-column prop="fileUrl" label="文件" min-width="200" />
+          <el-table-column prop="uploadedAt" label="上传时间" width="180" />
+        </el-table>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getUnknownClusters, getClusterUploads, generateTypeFromCluster } from '@/api/reportParser'
+
+export default {
+  name: 'ReportUnknownCluster',
+  data() {
+    return {
+      loading: false, uploadLoading: false,
+      clusters: [], statusFilter: '',
+      detailVisible: false, currentCluster: null,
+      uploads: [],
+    }
+  },
+  mounted() { this.fetchClusters() },
+  methods: {
+    async fetchClusters() {
+      this.loading = true
+      try {
+        const res = await getUnknownClusters({ page: 1, size: 50, status: this.statusFilter })
+        this.clusters = res.data?.records || res.data || []
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    statusType(status) {
+      return { collecting: 'info', ready: 'warning', generated: 'success', imported: 'success' }[status] || 'info'
+    },
+    statusText(status) {
+      return { collecting: '收集', ready: '可生成', generated: '已生成', imported: '已导入' }[status] || status
+    },
+    formatJson(str) {
+      if (!str) return ''
+      try { return JSON.stringify(JSON.parse(str), null, 2) } catch (e) { return str }
+    },
+    async handleDetail(row) {
+      this.currentCluster = row
+      this.detailVisible = true
+      this.uploads = []
+      this.uploadLoading = true
+      try {
+        const res = await getClusterUploads({ clusterId: row.id })
+        this.uploads = res.data || []
+      } catch (e) { this.$message.error('加载报告失败') }
+      this.uploadLoading = false
+    },
+    handleGenerate(row) {
+      this.$confirm('确认为此聚类生成报告类型?将创建待审核的报告类型和指纹规则。', '提示', { type: 'warning' })
+        .then(async () => {
+          try {
+            const res = await generateTypeFromCluster({ clusterId: row.id })
+            this.$message.success('已生成类型: ' + (res.data?.typeId || ''))
+            this.fetchClusters()
+          } catch (e) { this.$message.error('生成失败') }
+        }).catch(() => {})
+    },
+  }
+}
+</script>
+
+<style scoped>
+.detail-pre {
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  padding: 10px;
+  max-height: 300px;
+  overflow: auto;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 2 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -2,7 +2,7 @@
 
 **文档版本:** v2.5
 **日期:** 2026-08-04
-**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)
+**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)
 **维护:** 所有需求变更需更新本文档
 
 ---
@@ -68,6 +68,7 @@
 | 家庭成员卡片(跨页展示+可见性) | ✅ 已上线 | `specs/2026-06-15-family-member-cards-design.md` + `plans/2026-06-15-family-member-cards.md` |
 | 成员详情页维度专属内容区 | ✅ 已实施(member-detail.vue 505行 + 4维度section组件 879行) | `specs/2026-07-14-member-detail-dimension-sections.md` |
 | 关系问卷与互动记录功能补全 | ✅ 已实施(3表迁移154-156+评分算法+LangGraph接入+跨家庭校验+编辑bug修复) | `specs/2026-08-04-relationship-feedback-fix-design.md` + `plans/2026-08-04-relationship-feedback-fix.md` |
+| 家庭成员关系条(FamilyMemberStrip)关系展示增强 | ✅ 已实施(后端:FamilyRelationship 三列 + 迁移180 + pairwise 接口 + VO 补齐;前端:FamilyMemberCard 三色点 + FamilyMemberStrip 空状态引导 + Action Sheet 升级 + pairwise API + centerMemberId 视角模式 + member-home-detail.vue 接入) | `specs/2026-08-05-family-relationship-strip-design.md` + `plans/2026-08-05-family-relationship-strip.md` |
 
 ---
 

+ 1022 - 0
docs/superpowers/plans/2026-08-05-family-relationship-strip.md

@@ -0,0 +1,1022 @@
+# FamilyMemberStrip 关系展示增强 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 在 FamilyMemberStrip/FamilyMemberCard 上直接呈现关系类型与关系现状(信任度/亲密度/沟通质量),长按菜单升级为带关系现状区块的 Action Sheet,支持未创建家庭引导 + 当前用户添加/邀请成员入口,并新增 pairwise 接口支撑成员详情页以任意成员为中心的视图。
+
+**架构:** 后端在 `family_relationships` 表增加评分三列并新增 pairwise 接口(问卷提交时同步写入);前端在 FamilyMemberCard 徽章下增加三个迷你色点,FamilyMemberStrip 增加空状态引导与 `centerMemberId` prop,长按菜单升级为分区 Action Sheet。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus(后端)/ uni-app Vue 2 Options API(前端)
+
+**关联设计文档:** `docs/superpowers/specs/2026-08-05-family-relationship-strip-design.md`
+
+---
+
+## 文件结构
+
+**后端(cfc-backend/src/main/java/com/etotem/cfc/):**
+- 修改 `entity/FamilyRelationship.java` — 增加 trustScore/intimacyScore/communicationScore 三字段
+- 修改 `config/DatabaseInitializer.java` — 迁移154:family_relationships 表加三列
+- 修改 `resources/schema.sql` — family_relationships CREATE TABLE 补三列
+- 修改 `service/RelationshipQuestionnaireService.java` — submitQuestionnaire 增加 pairwise 落库
+- 修改 `dto/FamilyMemberVO.java` — 补齐 communicationScore 字段
+- 修改 `service/FamilyMemberService.java` — listMembers 补齐 communicationScore
+- 修改 `service/RelationshipQualityService.java` — 新增 getPairwiseRelationships()
+- 修改 `controller/family/RelationshipQualityController.java` — 新增 POST /pairwise
+
+**前端(cfc-frontend/):**
+- 修改 `components/FamilyMemberCard.vue` — 徽章下加三色点
+- 修改 `components/FamilyMemberStrip.vue` — 空状态引导 + centerMemberId prop + Action Sheet 升级
+- 修改 `utils/api.js` — 新增 getPairwiseRelationships()
+- 修改 `pages/home-pages/member-home-detail.vue` — 接入 centerMemberId 模式
+
+**测试:**
+- 无现成后端测试框架(mvn test 只有 1 个测试类),以 `mvn clean compile` 验证
+- 前端以 `npm run build:mp-weixin` 验证
+
+---
+
+### 任务 1:后端实体 + 数据库迁移
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationship.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+
+- [ ] **步骤 1:实体加字段**
+
+在 `FamilyRelationship.java` 的 `relationshipKey` 字段后追加:
+
+```java
+    /** 信任度(答卷人视角) 0-100 */
+    private BigDecimal trustScore;
+
+    /** 亲密度(答卷人视角) 0-100 */
+    private BigDecimal intimacyScore;
+
+    /** 沟通质量(答卷人视角) 0-100 */
+    private BigDecimal communicationScore;
+```
+
+并补充 import(若未引入 `java.math.BigDecimal`,加在 `import java.io.Serializable;` 之后)。
+
+- [ ] **步骤 2:DatabaseInitializer 加迁移**
+
+找到 `// 迁移153:` 注释块,在其后追加(用 `ensureColumn` 三次调用):
+
+```java
+        // 迁移154: family_relationships表添加关系质量评分三列(关系条展示增强)
+        ensureColumn("family_relationships", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)'");
+        ensureColumn("family_relationships", "intimacy_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)'");
+        ensureColumn("family_relationships", "communication_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)'");
+```
+
+- [ ] **步骤 3:同步 schema.sql**
+
+`schema.sql` 的 `family_relationships` CREATE TABLE(约 1818 行)在 `relationship_key` 行后加三列:
+
+```sql
+    trust_score        DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)',
+    intimacy_score     DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)',
+    communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)',
+```
+
+- [ ] **步骤 4:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationship.java cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(backend): family_relationships表增加关系质量评分三列"
+```
+
+---
+
+### 任务 2:问卷提交同步写入 pairwise 关系评分
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java`
+
+- [ ] **步骤 1:注入 FamilyRelationshipMapper 并补 import**
+
+该文件已有 `@Resource private FamilyMemberMapper familyMemberMapper;`(line 38)与全部所需 import(LambdaQueryWrapper/FamilyMember/BigDecimal/Date/ArrayList)。只需新增 mapper 注入:
+
+```java
+    @Resource
+    private com.etotem.cfc.mapper.FamilyRelationshipMapper familyRelationshipMapper;
+```
+
+并在 import 区(`import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot;` 之后)补:
+
+```java
+import com.etotem.cfc.entity.FamilyRelationship;
+import com.etotem.cfc.mapper.FamilyRelationshipMapper;
+```
+
+- [ ] **步骤 2:修改 submitQuestionnaire,提交后调用 pairwise 落库**
+
+在 `submitQuestionnaire` 中 `updateMemberScores(snapshot.getFamilyMemberId(), scores);` 之后追加:
+
+```java
+        // 同步写入 pairwise 关系评分(答卷人视角 → 被评成员)
+        syncPairwiseScores(snapshot.getRespondentId(), snapshot.getFamilyMemberId(), scores);
+```
+
+- [ ] **步骤 3:新增 syncPairwiseScores 方法**
+
+在 `updateMemberScores` 方法后新增:
+
+```java
+    /**
+     * 将问卷评分写入 family_relationships 表(答卷人视角)
+     * 答卷人无 family_member 记录或关系行不存在时,静默跳过(不阻塞问卷保存)
+     */
+    private void syncPairwiseScores(Long respondentUserId, Long targetMemberId, BigDecimal[] scores) {
+        try {
+            if (respondentUserId == null || targetMemberId == null || scores == null) return;
+            // 找到答卷人对应的 family_member 记录
+            LambdaQueryWrapper<FamilyMember> respWrapper = new LambdaQueryWrapper<>();
+            respWrapper.eq(FamilyMember::getUserId, respondentUserId);
+            FamilyMember respondent = familyMemberMapper.selectOne(respWrapper);
+            if (respondent == null) {
+                log.warn("答卷人用户{}无family_member记录,跳过pairwise评分同步", respondentUserId);
+                return;
+            }
+            // 查 from→to 关系行(有则更新,无则插入)
+            LambdaQueryWrapper<FamilyRelationship> relWrapper = new LambdaQueryWrapper<>();
+            relWrapper.eq(FamilyRelationship::getFromMemberId, respondent.getId())
+                    .eq(FamilyRelationship::getToMemberId, targetMemberId);
+            FamilyRelationship rel = familyRelationshipMapper.selectOne(relWrapper);
+            Date now = new Date();
+            if (rel != null) {
+                rel.setTrustScore(scores[0]);
+                rel.setIntimacyScore(scores[1]);
+                rel.setCommunicationScore(scores[2]);
+                rel.setUpdatedAt(now);
+                familyRelationshipMapper.updateById(rel);
+            } else {
+                FamilyMember target = familyMemberMapper.selectById(targetMemberId);
+                if (target == null) return;
+                FamilyRelationship newRel = new FamilyRelationship();
+                newRel.setFamilyId(target.getFamilyId());
+                newRel.setFromMemberId(respondent.getId());
+                newRel.setToMemberId(targetMemberId);
+                int respGen = respondent.getGeneration() != null ? respondent.getGeneration() : 0;
+                int targetGen = target.getGeneration() != null ? target.getGeneration() : 0;
+                newRel.setGenerationValue(respGen - targetGen);
+                newRel.setTrustScore(scores[0]);
+                newRel.setIntimacyScore(scores[1]);
+                newRel.setCommunicationScore(scores[2]);
+                newRel.setCreatedAt(now);
+                newRel.setUpdatedAt(now);
+                familyRelationshipMapper.insert(newRel);
+            }
+        } catch (Exception e) {
+            log.warn("pairwise评分同步失败: {}", e.getMessage());
+        }
+    }
+```
+
+需要新增 import:`com.etotem.cfc.entity.FamilyRelationship`(文件顶部 `import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot;` 之后)。
+
+- [ ] **步骤 4:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java
+git commit -m "feat(backend): 问卷提交时同步pairwise关系评分到family_relationships"
+```
+
+---
+
+### 任务 3:后端 VO 补齐 communicationScore + pairwise 接口
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java`
+
+- [ ] **步骤 1:FamilyMemberVO 增加 communicationScore 字段**
+
+`FamilyMemberVO.java` 现有 `intimacyScore`(line 65)与 `lastInteractionAt`(line 68)之间补:
+
+```java
+    /** 沟通质量分数 */
+    private java.math.BigDecimal communicationScore;
+```
+
+- [ ] **步骤 2:FamilyMemberService.listMembers 补齐 communicationScore 与 isSelf**
+
+`FamilyMemberService.java` 现有 `vo.setTrustScore(fm.getTrustScore());`(line 226)与 `vo.setIntimacyScore(fm.getIntimacyScore());`(line 227),在其后补 communicationScore;同时该方法**从未设置 `isSelf`**(已核实,全文件无 `setIsSelf`),需一并补齐——否则首页 8 处现有 FamilyMemberStrip 调用中"添加成员/邀请成员"入口永不显示,且现有 `canRemove` 的 `!actionMember.isSelf` 判断恒为 true(对自己也显示移除按钮):
+
+```java
+        vo.setCommunicationScore(fm.getCommunicationScore());
+        vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
+```
+
+> 说明:`viewerMember` 在该方法 line 175-181 已计算(当前查看者在 family_members 中的记录),直接复用。`isSelf` 语义 = 该成员就是当前登录用户在家庭中的记录。
+
+- [ ] **步骤 3:RelationshipQualityService 新增 getPairwiseRelationships 方法**
+
+该类已 import `LambdaQueryWrapper / FamilyMember / FamilyMemberMapper / BigDecimal / ArrayList / Date`,且已有 private `computeEffectiveRole(FamilyMember)`(line 211)可复用。新增注入:
+
+```java
+    @Resource
+    private com.etotem.cfc.service.FamilyRelationshipService familyRelationshipService;
+```
+
+在 `getScores` 方法后新增:
+
+```java
+    /**
+     * 获取以指定成员为中心的所有成员关系(关系类型 + 三项评分)
+     */
+    public List<FamilyMemberVO> getPairwiseRelationships(Long centerMemberId, Long familyId) {
+        List<FamilyMemberVO> result = new ArrayList<>();
+        FamilyMember center = familyMemberMapper.selectById(centerMemberId);
+        if (center == null || (familyId != null && !familyId.equals(center.getFamilyId()))) {
+            throw new RuntimeException("成员不存在或不属于该家庭");
+        }
+        if (familyId == null) {
+            familyId = center.getFamilyId();
+        }
+
+        List<FamilyMember> members = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
+
+        // 中心成员 → 各成员的关系行(评分与关系类型来源)
+        List<FamilyRelationship> rels = familyRelationshipService.getRelationsForMember(centerMemberId);
+        Map<Long, FamilyRelationship> relMap = new java.util.HashMap<>();
+        for (FamilyRelationship rel : rels) {
+            relMap.put(rel.getToMemberId(), rel);
+        }
+
+        for (FamilyMember fm : members) {
+            FamilyMemberVO vo = new FamilyMemberVO();
+            vo.setId(fm.getId());
+            vo.setSource("family_member");
+            vo.setUserId(fm.getUserId());
+            vo.setNickname(fm.getNickname());
+            vo.setPhone(fm.getPhone());
+            vo.setAvatar(fm.getAvatar());
+            vo.setGender(fm.getGender());
+            vo.setGeneration(fm.getGeneration());
+            vo.setEffectiveRole(computeEffectiveRole(fm));
+            vo.setRoleLabel(computeEffectiveRole(fm));
+            vo.setIsSelf(centerMemberId.equals(fm.getId()));
+            vo.setAge(fm.getAge());
+
+            FamilyRelationship rel = relMap.get(fm.getId());
+            if (rel != null) {
+                vo.setGenerationValue(rel.getGenerationValue());
+                String label = familyRelationshipService.computeRelationshipLabel(rel.getGenerationValue(), fm.getGender());
+                vo.setRelativeLabel(label);
+                vo.setRelationship(label);
+                vo.setTrustScore(rel.getTrustScore());
+                vo.setIntimacyScore(rel.getIntimacyScore());
+                vo.setCommunicationScore(rel.getCommunicationScore());
+            } else if (centerMemberId.equals(fm.getId())) {
+                vo.setRelationship("我");
+                vo.setRelativeLabel("我");
+            } else {
+                vo.setTrustScore(BigDecimal.ZERO);
+                vo.setIntimacyScore(BigDecimal.ZERO);
+                vo.setCommunicationScore(BigDecimal.ZERO);
+            }
+            result.add(vo);
+        }
+        return result;
+    }
+```
+
+需要新增 import:`com.etotem.cfc.entity.FamilyRelationship`、`com.etotem.cfc.dto.FamilyMemberVO`、`java.util.Map`。
+
+> **注意(已核实):** `FamilyMember` 实体自带 `age` 字段(line 67),直接 `vo.setAge(fm.getAge())`,无需调 `FamilyMemberService.calculateAge`(那是 private 方法)。`computeEffectiveRole` 是本类 private 方法,可直接调用。
+
+- [ ] **步骤 4:Controller 新增 pairwise 端点**
+
+`RelationshipQualityController` 已有 `getFamilyIdFromRequest(userId, body)`(line 30)与 `@PostMapping("/scores")` 模式。在 `getScores` 方法后新增:
+
+```java
+    @Operation(summary = "获取以指定成员为中心的所有成员关系(pairwise)")
+    @PostMapping("/pairwise")
+    public Result<List<FamilyMemberVO>> getPairwise(@RequestBody Map<String, Object> body,
+                                                    @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long centerMemberId = body.get("centerMemberId") != null
+                ? Long.valueOf(body.get("centerMemberId").toString()) : null;
+        if (centerMemberId == null) {
+            return Result.error("centerMemberId不能为空");
+        }
+        Long familyId = getFamilyIdFromRequest(userId, body);
+        try {
+            List<FamilyMemberVO> members = relationshipQualityService.getPairwiseRelationships(centerMemberId, familyId);
+            return Result.success(members);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+```
+
+需要新增 import:`com.etotem.cfc.dto.FamilyMemberVO`。
+
+- [ ] **步骤 5:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java
+git commit -m "feat(backend): 关系pairwise接口(以成员为中心) + VO补齐沟通评分"
+```
+
+---
+
+### 任务 4:前端 FamilyMemberCard 三色点
+
+**文件:**
+- 修改:`cfc-frontend/components/FamilyMemberCard.vue`
+
+- [ ] **步骤 1:模板加三色点(关系徽章下方)**
+
+`card-relation-badge` 的 `</view>` 之后、`card-spouse-mark` 之前插入:
+
+```html
+      <!-- 关系现状三色点:信任/亲密度/沟通 -->
+      <view class="card-relation-dots" v-if="hasRelationScores">
+        <view class="relation-dot" :style="{ background: dotColor(scoreKey('trust')) }"></view>
+        <view class="relation-dot" :style="{ background: dotColor(scoreKey('intimacy')) }"></view>
+        <view class="relation-dot" :style="{ background: dotColor(scoreKey('communication')) }"></view>
+      </view>
+      <view class="card-relation-dots" v-else>
+        <view class="relation-dot dot-empty"></view>
+        <view class="relation-dot dot-empty"></view>
+        <view class="relation-dot dot-empty"></view>
+      </view>
+```
+
+> **注意:** 模板中禁止带参数方法调用(`dotColor(scoreKey('trust'))` 嵌套调用在微信小程序模板编译器可能报错)。**改为计算属性返回三个色值数组:**
+
+```html
+      <view class="card-relation-dots">
+        <view class="relation-dot" v-for="(color, idx) in relationDotColors" :key="idx"
+              :style="{ background: color }"></view>
+      </view>
+```
+
+- [ ] **步骤 2:script 增加计算属性**
+
+在 `computed` 中 `relationColor` 后新增:
+
+```js
+    hasRelationScores: function() {
+      var m = this.member
+      if (!m) return false
+      var t = Number(m.trustScore) || 0
+      var i = Number(m.intimacyScore) || 0
+      var c = Number(m.communicationScore) || 0
+      return t > 0 || i > 0 || c > 0
+    },
+    relationDotColors: function() {
+      var m = this.member
+      var empty = '#E5E7EB'
+      if (!m) return [empty, empty, empty]
+      var self = this
+      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
+      return keys.map(function(k) {
+        var v = Number(m[k]) || 0
+        if (v <= 0) return empty
+        if (v >= 70) return '#10B981'
+        if (v >= 40) return '#F59E0B'
+        return '#EF4444'
+      })
+    }
+```
+
+- [ ] **步骤 3:样式**
+
+`<style scoped>` 中 `.card-relation-badge` 后新增:
+
+```css
+.card-relation-dots {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  margin-top: 6rpx;
+  height: 12rpx;
+}
+.relation-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  margin: 0 4rpx;
+}
+.dot-empty {
+  background: #E5E7EB;
+}
+```
+
+> **注意:** 卡片宽度 140rpx,徽章在头像下方 bottom:-4rpx 处,三色点放徽章下方会与昵称挤压。**实现时需验证布局**:若重叠,将 `.card-relation-dots` 的 margin-top 调小(如 2rpx)并将 `.card-nickname` 的 margin-top 压缩;或把色点移到徽章内部右侧(`position:absolute; right:4rpx`)。以视觉不重叠为准。
+
+- [ ] **步骤 4:构建验证**
+
+运行:`cd cfc-frontend && npm run build:mp-weixin`
+预期:BUILD SUCCESS(无模板编译错误)
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/components/FamilyMemberCard.vue
+git commit -m "feat(frontend): 家庭成员卡片增加关系现状三色点"
+```
+
+---
+
+### 任务 5:前端 FamilyMemberStrip 空状态 + Action Sheet 升级
+
+**文件:**
+- 修改:`cfc-frontend/components/FamilyMemberStrip.vue`
+
+- [ ] **步骤 1:模板空状态分支(单根节点重构)**
+
+Vue 2 组件模板要求单根节点,当前根 `<view class="family-member-strip" v-if="...">` 无法直接挂 `v-else`。将根节点改为常渲染,内部用 `v-if/v-else` 二选一:
+
+```html
+<template>
+  <view class="family-member-strip">
+    <!-- 有成员:滚动条 / 居中模式 -->
+    <view v-if="renderedMembers && renderedMembers.length > 0">
+      <scroll-view
+        class="strip-scroll"
+        scroll-x
+        :scroll-with-animation="true"
+        :enhanced="true"
+        show-scrollbar="false"
+        v-if="renderedMembers.length >= 4"
+      >
+        <view class="strip-inner scroll-mode">
+          <FamilyMemberCard
+            v-for="member in renderedMembers"
+            :key="member.id"
+            :member="member"
+            :isSelected="member.id === selectedMemberId"
+            :energyScore="getMemberEnergy(member)"
+            @select="onSelect"
+            @longpress="onLongPress"
+          />
+        </view>
+      </scroll-view>
+      <view class="strip-inner center-mode" v-else>
+        <FamilyMemberCard
+          v-for="member in renderedMembers"
+          :key="member.id"
+          :member="member"
+          :isSelected="member.id === selectedMemberId"
+          :energyScore="getMemberEnergy(member)"
+          @select="onSelect"
+          @longpress="onLongPress"
+        />
+      </view>
+      <!-- 长按操作菜单:原样保留,见步骤 3 升级 -->
+    </view>
+    <!-- 未创建家庭引导 -->
+    <view class="family-strip-empty" v-else>
+      <view class="empty-content">
+        <text class="empty-icon">🏠</text>
+        <text class="empty-title">还未创建家庭</text>
+        <text class="empty-desc">创建家庭,开启全家幸福之旅</text>
+        <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
+      </view>
+    </view>
+  </view>
+</template>
+```
+
+> **注意:** 此步骤同时引入 `renderedMembers`(见任务 6 步骤 3 的 data/watch 改造)。模板中所有 `members` 引用(含 action-sheet 内)统一改为 `renderedMembers`。`v-if` 里的空数组判断必须写 `renderedMembers && renderedMembers.length > 0`,避免空数组渲染异常。
+
+- [ ] **步骤 2:script 增加空状态创建家庭 / 添加成员 / 邀请成员(弹窗模式)**
+
+`import` 行追加(现有:`import { kickFamilyMemberById } from '@/utils/api'`):
+
+```js
+import { kickFamilyMemberById, createFamily, generateFamilyInviteQrCode } from '@/utils/api'
+import SharePoster from '@/components/SharePoster.vue'
+import { parseDate } from '@/utils/format.js'
+```
+
+`components` 注册追加:`SharePoster`。
+
+`data` 增加字段:
+
+```js
+      creatingFamily: false,
+      showPoster: false,
+      inviteCode: '',
+      inviteQrCode: '',
+      posterNickname: '',
+      posterAvatar: ''
+```
+
+`methods` 新增:
+
+```js
+    onCreateFamily: function() {
+      var self = this
+      if (this.creatingFamily) return
+      this.creatingFamily = true
+      // 家庭名称使用当前用户昵称 + '的家庭'(与 user-edit.vue createFamilyAction 一致)
+      var userInfo = uni.getStorageSync('userInfo')
+      var nickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || '我的'
+      uni.showLoading({ title: '创建中...' })
+      createFamily(nickname + '的家庭')
+        .then(function(res) {
+          uni.hideLoading()
+          self.creatingFamily = false
+          if (res.code === 200) {
+            uni.showToast({ title: '家庭创建成功', icon: 'success' })
+            self.$emit('created')
+          } else {
+            uni.showToast({ title: res.message || '创建失败', icon: 'none' })
+          }
+        })
+        .catch(function(err) {
+          uni.hideLoading()
+          self.creatingFamily = false
+          uni.showToast({ title: (err && err.message) || '创建失败', icon: 'none' })
+        })
+    },
+    addMember: function() {
+      this.closeMenu()
+      uni.navigateTo({ url: '/pages/family/add-member' })
+    },
+    // 邀请成员:弹窗展示分享海报(与 family-members.vue onInvite 一致的模式)
+    inviteMember: function() {
+      var self = this
+      this.closeMenu()
+      var userInfo = uni.getStorageSync('userInfo')
+      this.posterNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
+      this.posterAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
+      this.inviteCode = ''
+      this.inviteQrCode = ''
+      uni.showLoading({ title: '生成邀请...' })
+      generateFamilyInviteQrCode()
+        .then(function(res) {
+          uni.hideLoading()
+          if (res.code === 200 && res.data) {
+            self.inviteCode = res.data.inviteCode
+            self.inviteQrCode = res.data.qrCodeBase64
+            self.showPoster = true
+          } else {
+            uni.showToast({ title: res.message || '生成失败', icon: 'none' })
+          }
+        })
+        .catch(function() {
+          uni.hideLoading()
+          uni.showToast({ title: '生成失败', icon: 'none' })
+        })
+    }
+```
+
+模板中 action-sheet 内"添加成员 / 邀请成员"入口(isSelf 才显示)加在"切换视角"之前:
+
+```html
+      <view class="action-item" @click="addMember" v-if="actionMember && actionMember.isSelf">
+        <text class="action-item-icon">➕</text>
+        <text class="action-item-text">添加成员</text>
+      </view>
+      <view class="action-item" @click="inviteMember" v-if="actionMember && actionMember.isSelf">
+        <text class="action-item-icon">📨</text>
+        <text class="action-item-text">邀请成员</text>
+      </view>
+```
+
+SharePoster 挂载位置:**根节点 `.family-member-strip` 的直接子级**(与 v-if/v-else 两个分支平级,放在它们之后、根 `</view>` 闭合前),保证空态/有成员态下都能渲染:
+
+```html
+    </view>
+    <SharePoster
+      :show="showPoster"
+      :referralCode="inviteCode"
+      :qrCodeBase64="inviteQrCode"
+      :nickname="posterNickname"
+      :userAvatar="posterAvatar"
+      @close="showPoster = false" />
+  </view>
+</template>
+```
+
+- [ ] **步骤 3:Action Sheet 模板升级(头部 + 关系现状区块)**
+
+将 `action-sheet` 内头部替换为头像 + 关系标签,关系现状区块插在 header 之后、操作项之前。**已核实微信模板禁止 `:style` 内带参方法调用(`scoreWidth('trustScore')` 不可用)**,宽度/数值统一走计算属性 `scoreWidths[]` / `scoreValues[]`(下标 0=信任度 1=亲密度 2=沟通质量):
+
+```html
+    <view class="action-sheet" v-if="showActionMenu">
+      <view class="action-header">
+        <image class="action-avatar" :src="actionMember ? (actionMember.avatar || '/static/default-avatar.png') : ''" mode="aspectFill" />
+        <view class="action-header-info">
+          <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
+          <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || actionMember.roleLabel || '') : '' }}</text>
+        </view>
+      </view>
+      <!-- 关系现状区块(无数据时展示"未测评") -->
+      <view class="action-scores" v-if="actionMember">
+        <view class="score-row">
+          <text class="score-label">信任度</text>
+          <view class="score-bar-track">
+            <view class="score-bar-fill trust" :style="{ width: scoreWidths[0] }"></view>
+          </view>
+          <text class="score-value">{{ scoreValues[0] }}</text>
+        </view>
+        <view class="score-row">
+          <text class="score-label">亲密度</text>
+          <view class="score-bar-track">
+            <view class="score-bar-fill intimacy" :style="{ width: scoreWidths[1] }"></view>
+          </view>
+          <text class="score-value">{{ scoreValues[1] }}</text>
+        </view>
+        <view class="score-row">
+          <text class="score-label">沟通质量</text>
+          <view class="score-bar-track">
+            <view class="score-bar-fill communication" :style="{ width: scoreWidths[2] }"></view>
+          </view>
+          <text class="score-value">{{ scoreValues[2] }}</text>
+        </view>
+      </view>
+      <view class="action-divider"></view>
+      <!-- 添加成员/邀请成员入口已由步骤 2 加入(isSelf 时显示),位于切换视角之前 -->
+      <view class="action-item" @click="switchToMember" v-if="canSwitch">
+        ...(保留现有)
+      </view>
+      ...(其余操作项保留现有)
+```
+
+`computed` 新增:
+
+```js
+    actionScores: function() {
+      var m = this.actionMember
+      if (!m) return []
+      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
+      return keys.map(function(k) {
+        var v = Number(m[k]) || 0
+        return { width: Math.max(0, Math.min(100, v)) + '%', value: Math.round(v) }
+      })
+    },
+    scoreWidths: function() {
+      return this.actionScores.map(function(s) { return s.width })
+    },
+    scoreValues: function() {
+      return this.actionScores.map(function(s) { return s.value })
+    },
+    // 上次填写时间(问卷提交时 updateMemberScores 已更新 lastInteractionAt)
+    lastQuestionnaireText: function() {
+      var m = this.actionMember
+      if (!m || !m.lastInteractionAt) return ''
+      var d = parseDate(m.lastInteractionAt)
+      if (!d) return ''
+      var now = new Date()
+      var diffMs = now.getTime() - d.getTime()
+      var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
+      if (diffDays === 0) return '今天'
+      if (diffDays === 1) return '昨天'
+      if (diffDays < 7) return diffDays + '天前'
+      if (diffDays < 30) return Math.floor(diffDays / 7) + '周前'
+      return Math.floor(diffDays / 30) + '个月前'
+    }
+```
+
+> **注意:** 需 `import { parseDate } from '@/utils/format.js'`(小程序规范:禁止 `new Date(string)` 解析日期,统一用 `parseDate()` 归一化格式,见 cfc-frontend/AGENTS.md)。
+
+模板中"关系问卷"操作项(保留现有,追加时间后缀):
+
+```html
+      <view class="action-item" @click="questionnaireMember">
+        <text class="action-item-icon">📋</text>
+        <text class="action-item-text">关系问卷</text>
+        <text class="action-time" v-if="lastQuestionnaireText">  ·  上次填写 {{ lastQuestionnaireText }}</text>
+      </view>
+```
+
+`.action-time` 样式追加到 `.action-item-text` 同级:
+
+```css
+.action-time {
+  font-size: 22rpx;
+  color: #9CA3AF;
+  margin-left: 4rpx;
+}
+```
+
+> **未测评展示:** 三项分数全为 0/空时,进度条宽度为 `0%`、数值为 `0`。如需"未测评"文案,可在 `scoreValues` 分支内判断(`v <= 0` 时返回 `--`),实现时按视觉效果取舍,不影响数据结构。
+
+- [ ] **步骤 4:样式**
+
+`<style scoped>` 追加:
+
+```css
+/* 空状态 */
+.family-strip-empty {
+  margin: 16rpx 0;
+  padding: 40rpx 30rpx;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  display: flex;
+  justify-content: center;
+}
+.empty-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.empty-icon { font-size: 56rpx; }
+.empty-title { font-size: 30rpx; font-weight: 600; color: #1F2937; margin-top: 12rpx; }
+.empty-desc { font-size: 24rpx; color: #9CA3AF; margin-top: 8rpx; }
+.empty-btn {
+  margin-top: 24rpx;
+  padding: 16rpx 48rpx;
+  background: #F97316;
+  color: #FFFFFF;
+  font-size: 28rpx;
+  border-radius: 40rpx;
+}
+/* Action Sheet 头部 */
+.action-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.action-avatar {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  margin-right: 20rpx;
+  background: #F3F4F6;
+}
+.action-header-info {
+  display: flex;
+  flex-direction: column;
+}
+/* 关系现状 */
+.action-scores {
+  padding: 20rpx 30rpx;
+}
+.score-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.score-label {
+  width: 110rpx;
+  font-size: 24rpx;
+  color: #6B7280;
+}
+.score-bar-track {
+  flex: 1;
+  height: 14rpx;
+  background: #F3F4F6;
+  border-radius: 7rpx;
+  overflow: hidden;
+}
+.score-bar-fill {
+  height: 100%;
+  border-radius: 7rpx;
+}
+.score-bar-fill.trust { background: #10B981; }
+.score-bar-fill.intimacy { background: #3B82F6; }
+.score-bar-fill.communication { background: #F59E0B; }
+.score-value {
+  width: 70rpx;
+  text-align: right;
+  font-size: 24rpx;
+  color: #1F2937;
+  font-weight: 600;
+}
+.action-divider {
+  height: 1rpx;
+  background: #F3F4F6;
+  margin: 0 30rpx;
+}
+```
+
+- [ ] **步骤 5:构建验证**
+
+运行:`cd cfc-frontend && npm run build:mp-weixin`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-frontend/components/FamilyMemberStrip.vue
+git commit -m "feat(frontend): 成员条空状态引导 + 长按菜单升级关系现状Action Sheet"
+```
+
+---
+
+### 任务 6:前端 pairwise API + 组件 centerMemberId 模式
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`
+- 修改:`cfc-frontend/components/FamilyMemberStrip.vue`
+
+- [ ] **步骤 1:api.js 新增 pairwise 接口**
+
+在 `getVisibleFamilyMembers` 定义后新增:
+
+```js
+// 获取以指定成员为中心的所有成员关系(关系类型 + 信任/亲密度/沟通评分)
+export const getPairwiseRelationships = (centerMemberId) => {
+	return request('/api/family/relationship/pairwise', 'POST', { centerMemberId })
+}
+```
+
+- [ ] **步骤 2:FamilyMemberStrip 增加 centerMemberId prop**
+
+`props` 增加:
+
+```js
+    centerMemberId: { type: Number, default: null }
+```
+
+- [ ] **步骤 3:watch centerMemberId 自动拉取 + renderedMembers 数据代理**
+
+> **已核实:** 组件 `members` 是 prop 且为只读,不能直接赋值。引入 data `renderedMembers` 作为渲染数据源,`members` prop 变更时同步;`centerMemberId` 传入时组件内部拉取 pairwise 数据覆盖渲染。
+
+`data` 增加:
+
+```js
+      renderedMembers: []
+```
+
+`watch` 新增:
+
+```js
+  watch: {
+    members: {
+      handler: function(val) {
+        this.renderedMembers = val || []
+      },
+      immediate: true
+    },
+    centerMemberId: function(val) {
+      if (val) {
+        this.loadPairwise(val)
+      }
+    }
+  }
+```
+
+`methods` 新增:
+
+```js
+    loadPairwise: function(centerMemberId) {
+      var self = this
+      getPairwiseRelationships(centerMemberId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          var arr = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
+          self.renderedMembers = arr
+        }
+      }).catch(function() {})
+    }
+```
+
+模板中所有 `members` 引用(`v-if="members && members.length > 0"`、`v-for="member in members"`、`members.length >= 4`)统一改为 `renderedMembers`(任务 5 步骤 1 已含此改造,此处确认无遗漏)。
+
+`import` 增加 `getPairwiseRelationships`。
+
+- [ ] **步骤 4:构建验证**
+
+运行:`cd cfc-frontend && npm run build:mp-weixin`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/components/FamilyMemberStrip.vue
+git commit -m "feat(frontend): pairwise接口 + 成员条centerMemberId视角模式"
+```
+
+---
+
+### 任务 7:成员详情页接入 pairwise
+
+**文件:**
+- 修改:`cfc-frontend/pages/home-pages/member-home-detail.vue`
+
+- [ ] **步骤 1:引入组件**
+
+`import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'`,并加入 `components` 注册。
+
+- [ ] **步骤 2:模板插入关系条区块**
+
+在 `member-card` 之后、`FamilyRelationGraph` 之前插入:
+
+```html
+      <!-- 以该成员为中心的家庭成员关系条 -->
+      <FamilyMemberStrip
+        v-if="memberId"
+        :center-member-id="memberId"
+        :selected-member-id="memberId"
+        :is-parent="true" />
+```
+
+> **注意:** `centerMemberId` 是 Number 类型 prop,`options.memberId` 来自 URL 是 String。**需在 onLoad 中 `this.memberId = Number(options.memberId) || null`**,否则 Number prop 校验告警。
+
+- [ ] **步骤 3:onLoad 转 Number**
+
+`onLoad` 修改:
+
+```js
+  onLoad: function(options) {
+    this.memberId = Number(options.memberId) || null
+    this.memberType = options.memberType || 'child'
+  },
+```
+
+- [ ] **步骤 4:构建验证**
+
+运行:`cd cfc-frontend && npm run build:mp-weixin`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/pages/home-pages/member-home-detail.vue
+git commit -m "feat(frontend): 成员详情页接入以成员为中心的关系条"
+```
+
+---
+
+### 任务 8:最终验证
+
+**文件:** 无
+
+- [ ] **步骤 1:后端全量编译**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 2:前端构建**
+
+运行:`cd cfc-frontend && npm run build:mp-weixin`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:检查路由无重复**
+
+运行:`grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep pairwise`
+预期:`/api/family/relationship/pairwise` 出现一次
+
+- [ ] **步骤 4:git 状态确认**
+
+运行:`git status --short`
+预期:无未提交变更(或仅剩余待提交的临时文件)
+
+---
+
+## 自检记录
+
+**规格覆盖度:**
+- 2.1 表扩展三列 → 任务 1 ✅
+- 2.2 问卷同步 → 任务 2 ✅
+- 2.3 pairwise 接口 → 任务 3 ✅
+- 3.1 卡片三色点 → 任务 4 ✅
+- 3.2 空状态引导 → 任务 5 ✅
+- 3.3 Action Sheet 升级(含添加/邀请成员 + 上次填写时间)→ 任务 5 ✅
+- 3.4 centerMemberId prop → 任务 6 ✅
+- 3.5 成员详情页接入 → 任务 7 ✅
+- 5 错误处理边界(fallback、权限保留)→ 任务 5/6 ✅
+- 6 验证 → 任务 8 ✅
+
+**实现期风险(已核实并在任务内处理):**
+1. 微信模板禁止带参数方法调用 → 全部改用计算属性(任务 4/5)
+2. `members` 是 prop 不可赋值 → 引入 `renderedMembers` data(任务 6)
+3. URL 参数 String → Number 转换(任务 7)
+4. ~~`calculateAgeStatic` 不存在~~ → **已核实**:`FamilyMember` 实体自带 `age` 字段(line 67),直接 `vo.setAge(fm.getAge())`(任务 3 步骤 3)
+5. ~~`FamilyMemberVO.communicationScore` 缺失~~ → **已核实并修复**:任务 3 步骤 1 补字段 + 步骤 2 在 `listMembers` 补齐(否则首页 8 处现有调用第三点恒灰)
+6. ~~`FamilyRelationshipService.getRelationsForMember` 是假设~~ → **已核实存在**(line 88,返回 from 方向边),`computeRelationshipLabel(Integer, String)` 存在(line 118)
+7. ~~`computeEffectiveRole` 跨类不可调~~ → **已核实**:是 `RelationshipQualityService` 本类 private 方法(line 211),可直接调用
+8. ~~问卷服务需新增 FamilyMemberMapper~~ → **已核实**:`familyMemberMapper` 已注入(line 38),只需补 `familyRelationshipMapper`(任务 2 步骤 1)
+9. ~~`isSelf` 已有值~~ → **已核实反例**:`FamilyMemberService.listMembers` 全文件无 `setIsSelf`,若不加则首页模式"添加成员/邀请成员"入口永不显示、`canRemove` 对自己也显示 → 任务 3 步骤 2 补齐 `vo.setIsSelf(viewerMember.getId().equals(fm.getId()))`(复用已有 viewerMember)
+10. ~~邀请成员跳页~~ → **已核实改为弹窗**:与 family-members.vue `onInvite`(line 258)+ SharePoster 组件(props: show/referralCode/qrCodeBase64/nickname/userAvatar)一致,任务 5 步骤 2 已实现
+11. ~~Vue2 单根节点挂 v-else~~ → **已核实**:任务 5 步骤 1 改为根节点常渲染 + 内部 v-if/v-else 二选一,避免直接对根节点挂 v-else
+12. ~~generation_value 计算未知~~ → **已核实**:FamilyRelationshipService.createRelationsForNewMember(line 38-80)约定 `from→to: fromGen - toGen`,syncPairwiseScores 使用相同公式(respGen - targetGen)✅
+13. ~~上次填写时间无数据源~~ → **已核实**:`updateMemberScores`(line 210)在问卷提交时更新 `member.lastInteractionAt`,FamilyMemberVO 已有该字段;任务 5 步骤 3 用 `parseDate` 格式化为"X天前"展示
+14. 卡片三色点布局挤压 → 实现时视觉调优(任务 4 步骤 3 备注)
+
+**占位符扫描:** 无 TBD/TODO;所有代码块为完整实现代码,且关键 API 已对照真实源码核实(entity 字段、mapper、service 方法、controller 模式、Lombok setter)。
+
+**类型一致性:** `FamilyRelationship` 新增字段名(trustScore/intimacyScore/communicationScore)在任务 1/2/3 中一致;`FamilyMemberVO` 补齐 communicationScore 后,任务 3 的 setter 调用与任务 4/5 前端字段读取(member.communicationScore)对齐;`renderedMembers` 在任务 6 中所有模板引用统一替换。

+ 192 - 0
docs/superpowers/specs/2026-08-05-family-relationship-strip-design.md

@@ -0,0 +1,192 @@
+# 家庭成员关系条(FamilyMemberStrip)关系展示增强设计
+
+日期: 2026-08-05
+状态: 已确认
+关联组件: `cfc-frontend/components/FamilyMemberStrip.vue`, `cfc-frontend/components/FamilyMemberCard.vue`
+
+## 1. 背景与目标
+
+### 1.1 现状问题
+
+`FamilyMemberStrip` 是水平成员选择条(8 个页面共用),目前每个成员卡片只显示:
+- 头像 + 关系徽章(关系类型文本,如"爸爸"/"妈妈"/"儿子")
+- 昵称、辈分、电话、能量分、配偶标记
+
+**缺失**:关系质量信息(信任度/亲密度/沟通质量)完全没有呈现。
+
+### 1.2 数据现状(关键约束)
+
+| 数据 | 现状 | 位置 |
+|------|------|------|
+| 关系类型(pairwise) | ✅ 已有 | `family_relationships` 表:from→to + generation_value + relationship_key,双向各一条 |
+| 信任/亲密度/沟通评分 | ⚠️ 仅单成员维度 | `family_members.trust_score/intimacy_score/communication_score`——本质是问卷答卷人(当前用户)对该成员的评分(`RelationshipQuestionnaireService.updateMemberScores` 写入) |
+| 问卷答卷 | ✅ pairwise 天然成立 | `relationship_questionnaire_responses`:respondent_id → snapshot_id → family_member_id |
+
+### 1.3 目标
+
+1. **卡片上直接呈现**"当前用户与该成员"的关系类型 + 关系现状(信任/亲密度/沟通),无需交互
+2. **成员详情页**以任意成员为中心,复用本组件展示该成员与所有其他成员的关系
+3. **长按操作菜单**升级为带关系现状区块的 Action Sheet,视觉更精致
+4. **未创建家庭**时显示引导(提示 + 创建家庭按钮);**当前用户菜单**增加"添加成员/邀请成员"入口
+
+### 1.4 非目标
+
+- 不改动 `FamilyRelationGraph`(力导向关系图保持现状,作为可选全景视图)
+- 不在卡片间画连线(成员多时视觉混乱,经确认放弃连线方案)
+- 不改变 8 处现有 `FamilyMemberStrip` 调用方式(默认视角零改动)
+
+## 2. 数据层设计(后端)
+
+### 2.1 `family_relationships` 表扩展评分列
+
+在 `family_members` 单成员评分之外,将评分落库到 pairwise 关系表,支撑"以任意成员为中心"的视角。
+
+```sql
+ALTER TABLE family_relationships
+  ADD COLUMN trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)',
+  ADD COLUMN intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)',
+  ADD COLUMN communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)';
+```
+
+- **迁移入口**: `DatabaseInitializer.runMigrations()` 使用 `ensureColumn()`,幂等
+- **同步**: `schema.sql` 的 `family_relationships` CREATE TABLE 补三列(编号递增,搜索 `// 迁移` 确认最新号)
+- **实体**: `FamilyRelationship.java` 增加三个 `BigDecimal` 字段
+
+### 2.2 问卷写入同步到 pairwise 表
+
+`RelationshipQuestionnaireService.updateMemberScores(Long memberId, BigDecimal[] scores)` 增加逻辑:
+1. 通过 `memberId` 找到被评成员(to_member_id)
+2. 通过答卷人(respondent)定位其 family_member_id(from_member_id);若答卷人无 family_member 记录则跳过(不阻塞主流程)
+3. 存在 `family_relationships` 行则更新三列评分;不存在则插入新行(generation_value 从双方成员 generation 计算)
+4. 保持事务内,失败仅记日志不阻断问卷保存
+
+### 2.3 新增 pairwise 接口
+
+```
+POST /api/family/relationship/pairwise
+入参: { "centerMemberId": 123 }   // 可选,空 = 当前用户视角
+出参: Result<List<FamilyMemberVO>>  // 以 centerMember 为中心的关系列表
+```
+
+- **Controller**: `RelationshipQualityController` 新增 `pairwise` 方法(`@PostMapping`)
+- **Service**: `RelationshipQualityService` 新增 `getPairwiseRelationships(Long centerMemberId, Long familyId)`:
+  - 查 family_relationships 中 `from_member_id = centerMemberId` 的所有行
+  - join family_members 取目标成员资料(昵称/头像/性别/手机号/generation)
+  - 关系标签:优先 `relationship_key` → 否则用 `FamilyRelationshipService.computeRelationshipLabel(generationValue, gender)`
+  - 评分:取该行的 trust/intimacy/communication(无记录默认 0)
+  - 复用 `FamilyMemberVO` 结构返回(含 `isSelf` 标记 centerMember 自身)
+
+### 2.4 家庭是否存在判定
+
+前端通过 `getVisibleFamilyMembers` 的返回判定:接口返回空数组 = 无家庭或家庭成员不可见 → 显示"创建家庭"引导。
+
+## 3. 前端设计
+
+### 3.1 FamilyMemberCard 徽章区(无需交互,直接可见)
+
+关系徽章下方增加三个迷你圆点(关系现状指示):
+
+```
+[ 爸爸 ]        ← 关系类型徽章(现有,配色保留)
+● ● ●          ← 三个圆点:信任/亲密度/沟通
+```
+
+- 颜色分级:绿 `#10B981`(≥70)/ 黄 `#F59E0B`(40-69)/ 红 `#EF4444`(<40)
+- 三圆点含义需可辨识 → 增加极小的下划线/序号辅助?**不做**(空间有限,色点语义在长按菜单中解释)
+- 空数据(全 0 或未测评)→ 显示灰色 `· · ·`
+- 数据源:`member.trustScore/intimacyScore/communicationScore`(FamilyMemberVO 已有字段,当前用户视角)
+- 卡片宽度 140rpx 不变,色点放在徽章正下方 2rpx 处,直径 12rpx
+
+### 3.2 FamilyMemberStrip 空状态引导
+
+当前模板 `v-if="members && members.length > 0"` 空时整条不渲染 → 增加 `v-else` 分支:
+
+```
+┌─────────────────────────────┐
+│  🏠 还未创建家庭             │
+│  创建家庭,开启全家幸福之旅    │
+│  [+ 创建家庭]               │
+└─────────────────────────────┘
+```
+
+- 按钮 `@click` 调 `createFamily(nickname + '的家庭')`(复用 `utils/api.js` 的 `createFamily`,参照 `user-edit.vue` 的 `createFamilyAction`)
+- 创建成功后 `$emit('created')`,父组件刷新成员列表
+
+### 3.3 长按操作菜单(升级版 Action Sheet)
+
+**当前用户**(`member.isSelf === true`)与**其他成员**菜单不同:
+
+```
+┌─────────────────────────────────┐
+│  (头像) 小明                     │
+│         儿子 · 8岁               │
+│  ── 关系现状 ──                  │
+│  信任度   ████████ 82            │
+│  亲密度   ██████   60            │
+│  沟通质量 █████████ 91           │
+│  ───────────────                 │
+│  ➕ 添加成员                     │   ← 仅当前用户
+│  📨 邀请成员                     │   ← 仅当前用户
+│  🔄 切换到该成员视角              │   ← 非本人且有 userId
+│  ✏️ 编辑资料                     │   ← isParent 且可编辑
+│  📋 关系问卷  → 上次填写 3天前    │   ← 显示上次填写时间
+│  💬 记录互动                     │
+│  📞 拨打电话                     │   ← 有 phone
+│  🗑️ 移除成员                    │   ← isParent 且非本人
+└─────────────────────────────────┘
+```
+
+- **头部**: 头像 + 昵称 + 关系标签(`relationship || relativeLabel || roleLabel`)
+- **关系现状区块**: 三条迷你进度条(背景 `#F3F4F6`,填充色按分级),右侧数值;无数据时显示"未测评"
+- **当前用户专属**: "添加成员"(跳 `/pages/family/add-member`)、"邀请成员"(跳邀请二维码页,参照 `family-members.vue` 的 📨 邀请成员逻辑)
+- **权限控制**: 保留现有 `canSwitch/canEdit/canRemove` computed,新增 `isSelf` 判断
+- **视觉**: 圆角 24rpx、柔和阴影、分区标题(12px 灰)、图标统一 emoji、操作项 44rpx 行高、底部取消按钮
+
+### 3.4 FamilyMemberStrip 新增 `centerMemberId` prop
+
+- `centerMemberId: { type: Number, default: null }`
+- **不传(默认)**: 现有逻辑不变——父组件传 `members` 数组(当前用户视角)
+- **传入**: 组件内部拉取 pairwise 接口,`members` 由组件自身组装(以 centerMember 为中心)
+
+### 3.5 成员详情页接入
+
+`pages/home-pages/member-home-detail.vue` 增加 FamilyMemberStrip 区块:
+- 传入 `:center-member-id="memberId"`,展示"以该成员为中心"的关系条
+- 复用 3.3 的 Action Sheet(该页面的长按菜单沿用权限控制)
+
+## 4. 交互流程
+
+1. **首页/维度页加载** → `getVisibleFamilyMembers` 返回空 → 显示创建家庭引导卡
+2. **有成员** → 卡片行展示,关系徽章 + 三色点
+3. **点卡片** → 选中(`selectedMemberId`)或切换视角(沿用现有逻辑)
+4. **长按卡片** → 弹出 Action Sheet:关系现状 + 操作项
+5. **成员详情页** → 以该成员为中心的关系条(pairwise)
+
+## 5. 错误处理与边界
+
+| 场景 | 处理 |
+|------|------|
+| 无家庭/无成员 | 空状态引导卡 + 创建家庭按钮 |
+| 未填写关系问卷 | 色点灰色 `· · ·`,菜单关系区块"未测评" |
+| pairwise 接口失败 | 组件 fallback 为传入的 members 渲染(不阻塞页面) |
+| 答卷人无 family_member 记录 | 问卷保存不阻塞,日志记录 |
+| 长按非本人卡片 | 不显示添加/邀请成员 |
+| 非家长长按 | 不显示编辑/移除(现有 canEdit/canRemove 逻辑保留) |
+
+## 6. 验证
+
+- 后端: `mvn clean compile`
+- 前端: `npm run build:mp-weixin`(微信小程序编译通过)
+- 手动验证场景:
+  1. 未创建家庭用户 → 看到创建家庭引导
+  2. 已有家庭 → 卡片显示关系徽章 + 三色点
+  3. 长按自己 → 菜单含添加/邀请成员 + 关系现状
+  4. 长按其他成员 → 菜单不含添加/邀请,含切换/编辑/问卷/互动/电话/移除(按权限)
+  5. 成员详情页 → 以该成员为中心的关系条
+
+## 7. 不做的事(YAGNI)
+
+- 卡片间连线(已放弃)
+- hover 交互(小程序无 hover 事件)
+- FamilyRelationGraph 改动
+- 复杂评分算法(沿用问卷简化评分)

+ 37 - 0
docs/参考资料/extract_full_report_v5.py

@@ -810,6 +810,43 @@ def _parse_taxonomy_levels(reader, full_text, fmt):
             marker2 = f'{level} 名称 丰度%'
             li = section.find(marker2)
             if li == -1:
+                # 尝试压缩格式:纲名称丰度%人群水平%%人检出
+                compressed_marker = f'{level}名称丰度%人群水平%%人检出'
+                cli = section.find(compressed_marker)
+                if cli == -1:
+                    continue
+                # 压缩格式解析:用正则提取数据
+                level_start = cli + len(compressed_marker)
+                # 找下一个层级结束位置
+                level_end = len(section)
+                for next_level in ['目', '科', '属', '种']:
+                    if next_level == level or next_level == level:
+                        continue
+                    ni = section.find(f'{next_level}名称丰度%人群水平%%人检出', level_start)
+                    if ni != -1 and ni < level_end:
+                        level_end = ni
+                        break
+                level_region = section[level_start:level_end]
+                # 压缩格式正则: [中文名] 拉丁名 丰度% 人群水平% 检出率%
+                # 例如: 梭菌纲 Clostridia48.803%74%98.87%
+                compressed_pattern = re.compile(
+                    r'([\u4e00-\u9fff·]+(?:\s[\u4e00-\u9fff·]+)?\s+)?'  # 可选中文名
+                    r'([A-Za-z][A-Za-z\s.\-]*?)'  # 拉丁名(可能含空格)
+                    r'(\d+\.?\d*%)(\d+\.?\d*%)(\d+\.?\d*%)'  # 三连百分比
+                )
+                rows = []
+                for m in compressed_pattern.finditer(level_region):
+                    cn_name = (m.group(1) or '').strip()
+                    latin_name = m.group(2).strip()
+                    pct = m.group(3)
+                    pop_level = m.group(4)
+                    detection = m.group(5)
+                    # 优先用中文名,没有则用拉丁名
+                    name = cn_name if cn_name else latin_name
+                    entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level, '检出率%': detection}
+                    rows.append(entry)
+                if rows:
+                    results[level_name] = rows
                 continue
 
         # 从标题后开始解析三元组数据