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

Merge branch 'cfclub' of https://git.iwintrue.com/liaoxg/cfc into cfclub

Xiaogang Liao 3 месяцев назад
Родитель
Сommit
f2442a70ba
48 измененных файлов с 4138 добавлено и 749 удалено
  1. 3 3
      cfc-backend/.classpath
  2. 99 168
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  4. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
  5. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  6. 3 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java
  7. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  8. 89 67
      cfc-frontend/components/DimensionActivities.vue
  9. 2 10
      cfc-frontend/components/DimensionArticles.vue
  10. 52 36
      cfc-frontend/components/DimensionProducts.vue
  11. 0 323
      cfc-frontend/components/FamilyRelationshipSection.vue
  12. 2 2
      cfc-frontend/config.js
  13. 6 12
      cfc-frontend/pages.json
  14. 7 32
      cfc-frontend/pages/body/index.vue
  15. 383 0
      cfc-frontend/pages/health/gut-flora-detail.vue
  16. 27 0
      cfc-frontend/pages/health/gut-index.vue
  17. 10 0
      cfc-frontend/pages/health/report-confirm.vue
  18. 7 3
      cfc-frontend/pages/health/report-list.vue
  19. 1 12
      cfc-frontend/pages/mind/index.vue
  20. 1 1
      cfc-frontend/pages/profile/index.vue
  21. 366 0
      cfc-frontend/pages/wealth-sub/checkin.vue
  22. 312 0
      cfc-frontend/pages/wealth-sub/insurance-add.vue
  23. 296 0
      cfc-frontend/pages/wealth-sub/insurance-list.vue
  24. 2 11
      cfc-frontend/pages/wisdom/index.vue
  25. 1 1
      cfc-web/.last_build_commit
  26. 2 2
      cfc-web/package-lock.json
  27. 1 1
      cfc-web/package.json
  28. 0 63
      cfc-web/public/CHANGELOG.md
  29. BIN
      docs/参考资料/dan_reports/A1 儿童核心认知发展_李长龙_2025102513015067836.pdf
  30. BIN
      docs/参考资料/dan_reports/A2 核心素养_张馨月_202402221120441849.pdf
  31. BIN
      docs/参考资料/dan_reports/A2_B5青春期挑战_肖媛媛.pdf
  32. 96 0
      docs/参考资料/dan_reports/AGENTS.md
  33. BIN
      docs/参考资料/dan_reports/B2 儿童自我与家庭教养_李长龙_2025102514271465449.pdf
  34. BIN
      docs/参考资料/dan_reports/B3 核心学习能力_张馨月_2026022417360882898.pdf
  35. BIN
      docs/参考资料/dan_reports/B4 核心认知能力和成长型思维_张馨月_2026022416334518042.pdf
  36. BIN
      docs/参考资料/dan_reports/B6 职业发展_张馨月_2026022415182958757.pdf
  37. BIN
      docs/参考资料/dan_reports/B6_生涯规划(高中版)_习常静.pdf
  38. BIN
      docs/参考资料/dan_reports/C1校园标准版(1-3年级)_胡家诚_2025121616362391951.pdf
  39. BIN
      docs/参考资料/dan_reports/C1校园标准版(4-12年级)_陈风然_2025121310362662392.pdf
  40. 134 0
      docs/参考资料/dan_reports/dedup_reports.py
  41. 64 0
      docs/参考资料/dan_reports/delete_duplicate_a1.py
  42. 1266 0
      docs/参考资料/dan_reports/extract_all_types.py
  43. 153 0
      docs/参考资料/dan_reports/extract_c1_names.py
  44. 308 0
      docs/参考资料/dan_reports/merge_all_types.py
  45. 99 0
      docs/参考资料/dan_reports/process_all_pdfs.py
  46. 35 0
      docs/参考资料/dan_reports/read_pdf.py
  47. 225 0
      docs/参考资料/dan_reports/type_detector.py
  48. 74 0
      docs/参考资料/dan_reports/update_materials.py

+ 3 - 3
cfc-backend/.classpath

@@ -36,18 +36,18 @@
 			<attribute name="maven.pomderived" value="true"/>
 			<attribute name="maven.pomderived" value="true"/>
 		</attributes>
 		</attributes>
 	</classpathentry>
 	</classpathentry>
-	<classpathentry kind="src" path="target/generated-sources/annotations">
+	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
 		<attributes>
 		<attributes>
 			<attribute name="optional" value="true"/>
 			<attribute name="optional" value="true"/>
+			<attribute name="test" value="true"/>
 			<attribute name="maven.pomderived" value="true"/>
 			<attribute name="maven.pomderived" value="true"/>
 			<attribute name="ignore_optional_problems" value="true"/>
 			<attribute name="ignore_optional_problems" value="true"/>
 			<attribute name="m2e-apt" value="true"/>
 			<attribute name="m2e-apt" value="true"/>
 		</attributes>
 		</attributes>
 	</classpathentry>
 	</classpathentry>
-	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
+	<classpathentry kind="src" path="target/generated-sources/annotations">
 		<attributes>
 		<attributes>
 			<attribute name="optional" value="true"/>
 			<attribute name="optional" value="true"/>
-			<attribute name="test" value="true"/>
 		</attributes>
 		</attributes>
 	</classpathentry>
 	</classpathentry>
 	<classpathentry kind="output" path="target/classes"/>
 	<classpathentry kind="output" path="target/classes"/>

+ 99 - 168
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -5245,65 +5245,9 @@ try {
         runMigration80();
         runMigration80();
         runMigration81();
         runMigration81();
         runMigration82();
         runMigration82();
-        runMigration86();
-    }
-
-    private void runMigration81() {
-
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_mapping (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "product_id BIGINT NOT NULL COMMENT '商品 ID', " +
-                    "dimension_code VARCHAR(32) NOT NULL COMMENT '维度:body/wisdom/mind/action/wealth', " +
-                    "match_score INT DEFAULT 100 COMMENT '匹配度 0-100', " +
-                    "match_reason VARCHAR(200) COMMENT '匹配原因', " +
-                    "tags VARCHAR(500) COMMENT '推荐标签 JSON', " +
-                    "enabled TINYINT DEFAULT 1, " +
-                    "created_at DATETIME, " +
-                    "updated_at DATETIME, " +
-                    "INDEX idx_product (product_id), " +
-                    "INDEX idx_dimension (dimension_code) " +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表'");
-            log.info("已创建 product_dimension_mapping 表");
-        } catch (Exception e) {
-            log.warn("创建 product_dimension_mapping 表失败:{}", e.getMessage());
-        }
-
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" +
-                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                "user_id BIGINT NOT NULL COMMENT '用户ID', " +
-                "conversation_id VARCHAR(100) NOT NULL COMMENT 'Dify会话ID', " +
-                "summary_text TEXT COMMENT '对话摘要文本', " +
-                "summary_tokens INT DEFAULT 0 COMMENT '摘要token数', " +
-                "message_count INT DEFAULT 0 COMMENT '本次对话消息数', " +
-                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
-                "INDEX idx_conversation (conversation_id), " +
-                "INDEX idx_user (user_id)" +
-            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI对话摘要'");
-            log.info("已创建ai_conversation_summary表");
-        } catch (Exception e) {
-            log.warn("创建ai_conversation_summary表失败: {}", e.getMessage());
-        }
-
-
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_user_facts (" +
-                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                "user_id BIGINT NOT NULL COMMENT '用户ID', " +
-                "fact_key VARCHAR(100) NOT NULL COMMENT '事实键', " +
-                "fact_value VARCHAR(500) COMMENT '事实值', " +
-                "source_conversation VARCHAR(100) COMMENT '来源会话ID', " +
-                "confidence DECIMAL(3,2) DEFAULT 1.00 COMMENT '置信度0-1', " +
-                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
-                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
-                "INDEX idx_user_key (user_id, fact_key), " +
-                "INDEX idx_conversation (source_conversation)" +
-            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI用户关键事实'");
-            log.info("已创建ai_user_facts表");
-        } catch (Exception e) {
-            log.warn("创建ai_user_facts表失败: {}", e.getMessage());
-        }
+        runMigration83();
+        runMigration84();
+        runMigration85();
 
 
         // 迁移 81: products 表添加 recommendation_tags 字段
         // 迁移 81: products 表添加 recommendation_tags 字段
         ensureColumn("products", "recommendation_tags", "VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain");
         ensureColumn("products", "recommendation_tags", "VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain");
@@ -5378,6 +5322,27 @@ try {
         } catch (Exception e) { /* 表已存在忽略 */ }
         } catch (Exception e) { /* 表已存在忽略 */ }
     }
     }
 
 
+    private void runMigration81() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_mapping (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "product_id BIGINT NOT NULL COMMENT '商品 ID', " +
+                    "dimension_code VARCHAR(32) NOT NULL COMMENT '维度:body/wisdom/mind/action/wealth', " +
+                    "match_score INT DEFAULT 100 COMMENT '匹配度 0-100', " +
+                    "match_reason VARCHAR(200) COMMENT '匹配原因', " +
+                    "tags VARCHAR(500) COMMENT '推荐标签 JSON', " +
+                    "enabled TINYINT DEFAULT 1, " +
+                    "created_at DATETIME, " +
+                    "updated_at DATETIME, " +
+                    "INDEX idx_product (product_id), " +
+                    "INDEX idx_dimension (dimension_code) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表'");
+            log.info("已创建 product_dimension_mapping 表");
+        } catch (Exception e) {
+            log.warn("创建 product_dimension_mapping 表失败:{}", e.getMessage());
+        }
+    }
+
     private void runMigration82() {
     private void runMigration82() {
         try {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" +
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" +
@@ -5415,128 +5380,94 @@ try {
         }
         }
     }
     }
 
 
-    private void runMigration86() {
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_hierarchy (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "system_id BIGINT NOT NULL COMMENT '供应商体系ID', " +
-                    "parent_id BIGINT NOT NULL COMMENT '上级供应商ID', " +
-                    "child_id BIGINT NOT NULL COMMENT '下级供应商ID', " +
-                    "level INT DEFAULT 1 COMMENT '层级深度', " +
-                    "status VARCHAR(20) DEFAULT 'active', " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
-                    "UNIQUE KEY uk_system_parent_child (system_id, parent_id, child_id), " +
-                    "KEY idx_system_child (system_id, child_id), " +
-                    "KEY idx_parent (parent_id)" +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商上下级关系表'");
-            log.info("已创建supply_hierarchy表");
-        } catch (Exception e) {
-            log.warn("创建supply_hierarchy表失败: {}", e.getMessage());
-        }
-
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_hierarchy_change_request (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "system_id BIGINT NOT NULL, " +
-                    "applicant_id BIGINT NOT NULL COMMENT '申请人ID', " +
-                    "target_parent_id BIGINT NOT NULL COMMENT '目标上级ID', " +
-                    "status VARCHAR(20) DEFAULT 'pending', " +
-                    "reason VARCHAR(500), " +
-                    "reject_reason VARCHAR(500), " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "reviewed_at DATETIME, " +
-                    "reviewed_by BIGINT, " +
-                    "KEY idx_system_applicant (system_id, applicant_id), " +
-                    "KEY idx_status (status)" +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商更换上级申请表'");
-            log.info("已创建supply_hierarchy_change_request表");
-        } catch (Exception e) {
-            log.warn("创建supply_hierarchy_change_request表失败: {}", e.getMessage());
-        }
+    private void runMigration83() {
+        // 迁移83: activities/products/articles添加dimension_weights JSON列(五维权重百分比)
+        ensureColumn("activities", "dimension_weights",
+                "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
+        ensureColumn("products", "dimension_weights",
+                "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
+        ensureColumn("articles", "dimension_weights",
+                "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
+    }
 
 
+    private void runMigration84() {
+        // 迁移84: 存量数据回填 — 根据dimension_code/domain/relatedDimensions初始化dimension_weights
         try {
         try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_profiles (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "user_id BIGINT NOT NULL, " +
-                    "tier VARCHAR(10), " +
-                    "status VARCHAR(20) DEFAULT 'pending', " +
-                    "member_count INT DEFAULT 0, " +
-                    "max_members INT DEFAULT 50, " +
-                    "annual_fee_l1 BIGINT, " +
-                    "annual_fee_l2 BIGINT, " +
-                    "description TEXT, " +
-                    "certificates TEXT, " +
-                    "reject_reason VARCHAR(500), " +
-                    "commission_rate_l1 INT DEFAULT 1000, " +
-                    "commission_rate_l2 INT DEFAULT 5000, " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
-                    "approved_at DATETIME, " +
-                    "UNIQUE KEY uk_user_id (user_id), " +
-                    "INDEX idx_status (status), " +
-                    "INDEX idx_tier (tier)" +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家服务提供商档案'");
-            log.info("已创建butler_profiles表");
+            int actCount = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM activities WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NULL",
+                Integer.class);
+            if (actCount > 0) {
+                jdbcTemplate.update(
+                    "UPDATE activities SET dimension_weights = " +
+                    "JSON_OBJECT(" +
+                    "  'body', IF(dimension_code='body',100,0)," +
+                    "  'mind', IF(dimension_code='mind',100,0)," +
+                    "  'wisdom', IF(dimension_code='wisdom',100,0)," +
+                    "  'action', IF(dimension_code='action',100,0)," +
+                    "  'wealth', IF(dimension_code='wealth',100,0)" +
+                    ") WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NULL");
+                log.info("已回填{}条activities的dimension_weights", actCount);
+            }
         } catch (Exception e) {
         } catch (Exception e) {
-            log.warn("创建butler_profiles表失败: {}", e.getMessage());
+            log.warn("回填activities.dimension_weights失败: {}", e.getMessage());
         }
         }
 
 
         try {
         try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_service_records (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "butler_id BIGINT NOT NULL, " +
-                    "member_user_id BIGINT NOT NULL, " +
-                    "subscription_id BIGINT, " +
-                    "service_type VARCHAR(20) NOT NULL, " +
-                    "amount_p BIGINT NOT NULL, " +
-                    "platform_fee_p BIGINT DEFAULT 0, " +
-                    "butler_amount_p BIGINT DEFAULT 0, " +
-                    "member_tier VARCHAR(10), " +
-                    "year_month VARCHAR(7), " +
-                    "remark VARCHAR(500), " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "INDEX idx_butler_id (butler_id), " +
-                    "INDEX idx_member_user_id (member_user_id), " +
-                    "INDEX idx_year_month (year_month)" +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家服务记录'");
-            log.info("已创建butler_service_records表");
+            int prodCount = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM products WHERE domain IS NOT NULL AND domain != '' AND dimension_weights IS NULL",
+                Integer.class);
+            if (prodCount > 0) {
+                jdbcTemplate.update(
+                    "UPDATE products SET dimension_weights = " +
+                    "JSON_OBJECT(" +
+                    "  'body', IF(domain='body',100,0)," +
+                    "  'mind', IF(domain='mind',100,0)," +
+                    "  'wisdom', IF(domain='wisdom',100,0)," +
+                    "  'action', IF(domain='action',100,0)," +
+                    "  'wealth', IF(domain='wealth',100,0)" +
+                    ") WHERE domain IS NOT NULL AND domain != '' AND dimension_weights IS NULL");
+                log.info("已回填{}条products的dimension_weights", prodCount);
+            }
         } catch (Exception e) {
         } catch (Exception e) {
-            log.warn("创建butler_service_records表失败: {}", e.getMessage());
+            log.warn("回填products.dimension_weights失败: {}", e.getMessage());
         }
         }
 
 
         try {
         try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_commission_settlements (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "butler_id BIGINT NOT NULL, " +
-                    "year_month VARCHAR(7) NOT NULL, " +
-                    "member_count INT DEFAULT 0, " +
-                    "annual_base_amount_p BIGINT DEFAULT 0, " +
-                    "annual_butler_amount_p BIGINT DEFAULT 0, " +
-                    "annual_platform_amount_p BIGINT DEFAULT 0, " +
-                    "per_session_total_p BIGINT DEFAULT 0, " +
-                    "per_session_platform_total_p BIGINT DEFAULT 0, " +
-                    "per_session_butler_total_p BIGINT DEFAULT 0, " +
-                    "total_butler_amount_p BIGINT DEFAULT 0, " +
-                    "status VARCHAR(20) DEFAULT 'pending', " +
-                    "settled_at DATETIME, " +
-                    "settled_by BIGINT, " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "UNIQUE KEY uk_butler_month (butler_id, year_month), " +
-                    "INDEX idx_status (status), " +
-                    "INDEX idx_year_month (year_month)" +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家月度佣金结算单'");
-            log.info("已创建butler_commission_settlements表");
+            int artCount = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM articles WHERE related_dimensions IS NOT NULL AND related_dimensions != '' AND dimension_weights IS NULL",
+                Integer.class);
+            if (artCount > 0) {
+                jdbcTemplate.update("UPDATE articles SET dimension_weights = JSON_OBJECT(" +
+                    "'body', IF(INSTR(COALESCE(related_dimensions,''),'body')>0 OR INSTR(COALESCE(related_dimensions,''),'身')>0, 100, 0)," +
+                    "'mind', IF(INSTR(COALESCE(related_dimensions,''),'mind')>0 OR INSTR(COALESCE(related_dimensions,''),'心')>0, 100, 0)," +
+                    "'wisdom', IF(INSTR(COALESCE(related_dimensions,''),'wisdom')>0 OR INSTR(COALESCE(related_dimensions,''),'智')>0, 100, 0)," +
+                    "'action', IF(INSTR(COALESCE(related_dimensions,''),'action')>0 OR INSTR(COALESCE(related_dimensions,''),'行')>0, 100, 0)," +
+                    "'wealth', IF(INSTR(COALESCE(related_dimensions,''),'wealth')>0 OR INSTR(COALESCE(related_dimensions,''),'富')>0, 100, 0)" +
+                    ") WHERE related_dimensions IS NOT NULL AND related_dimensions != '' AND dimension_weights IS NULL");
+                log.info("已回填{}条articles的dimension_weights", artCount);
+            }
         } catch (Exception e) {
         } catch (Exception e) {
-            log.warn("创建butler_commission_settlements表失败: {}", e.getMessage());
+            log.warn("回填articles.dimension_weights失败: {}", e.getMessage());
         }
         }
+    }
 
 
-        try {
-            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_status VARCHAR(20)");
-            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_info TEXT");
-            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_approved_at DATETIME");
-            log.info("已添加butler相关列到users表");
-        } catch (Exception e) {
+    private void runMigration85() {
+        // 迁移85: 创建dimension_weights表(管理端保存维度权重使用)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dimension_weights (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "target_type VARCHAR(20) NOT NULL COMMENT '目标类型: activity/product/article'," +
+                    "target_id BIGINT NOT NULL COMMENT '目标ID'," +
+                    "dimension VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth'," +
+                    "weight INT DEFAULT 0 COMMENT '权重百分比'," +
+                    "enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "INDEX idx_target (target_type, target_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度权重表'");
+            log.info("已创建dimension_weights表");
+        } catch (Exception e) {
+            log.warn("创建dimension_weights表失败: {}", e.getMessage());
         }
         }
     }
     }
 }
 }

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

@@ -25,6 +25,8 @@ public class ProductDTO {
     private String vendorName;
     private String vendorName;
     private String status;
     private String status;
     private String domain;
     private String domain;
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
     private String memberEligible;
     private String memberEligible;
     private String rejectReason;
     private String rejectReason;
     private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
     private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
@@ -79,6 +81,7 @@ public class ProductDTO {
         d.vendorName = p.getVendorName();
         d.vendorName = p.getVendorName();
         d.status = p.getStatus();
         d.status = p.getStatus();
         d.domain = p.getDomain();
         d.domain = p.getDomain();
+        d.dimensionWeights = p.getDimensionWeights();
         d.memberEligible = p.getMemberEligible();
         d.memberEligible = p.getMemberEligible();
         d.rejectReason = p.getRejectReason();
         d.rejectReason = p.getRejectReason();
         d.deliveryMethod = p.getDeliveryMethod();
         d.deliveryMethod = p.getDeliveryMethod();

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

@@ -31,6 +31,8 @@ public class Article implements Serializable {
     // 文章字数(自动计算,用于阅读能量换算)
     // 文章字数(自动计算,用于阅读能量换算)
     private Integer wordCount;
     private Integer wordCount;
     private String relatedDimensions;
     private String relatedDimensions;
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
     private String visibility;
     private String visibility;
     private String visibleTo;
     private String visibleTo;
     private String status;
     private String status;

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

@@ -36,6 +36,8 @@ public class Product implements Serializable {
     private Integer memberPrice; // 会员价(分)
     private Integer memberPrice; // 会员价(分)
     private String memberEligible;
     private String memberEligible;
     private String domain;
     private String domain;
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
     private String externalSource;
     private String externalSource;
     private String externalId;
     private String externalId;
     private String externalData;
     private String externalData;
@@ -62,6 +64,7 @@ public class Product implements Serializable {
     public String getStatus() { return status; }
     public String getStatus() { return status; }
     public String getMemberEligible() { return memberEligible; }
     public String getMemberEligible() { return memberEligible; }
     public String getDomain() { return domain; }
     public String getDomain() { return domain; }
+    public String getDimensionWeights() { return dimensionWeights; }
     public Long getVendorId() { return vendorId; }
     public Long getVendorId() { return vendorId; }
     public String getVendorName() { return vendorName; }
     public String getVendorName() { return vendorName; }
     public String getIntro() { return intro; }
     public String getIntro() { return intro; }

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

@@ -55,7 +55,9 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
                 .eq(Activity::getStatus, "published")
                 .eq(Activity::getStatus, "published")
                 .orderByAsc(Activity::getStartTime);
                 .orderByAsc(Activity::getStartTime);
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
-            query.eq(Activity::getDimensionCode, dimensionCode);
+            // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧的 dimension_code
+            query.and(w -> w.apply("COALESCE(JSON_EXTRACT(dimension_weights, '$." + dimensionCode + "'), 0) > 0")
+                    .or().eq(Activity::getDimensionCode, dimensionCode));
         }
         }
         // 游客过滤:只返回公开活动
         // 游客过滤:只返回公开活动
         if (userId == null) {
         if (userId == null) {

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -52,7 +52,10 @@ public class ProductService {
             wrapper.eq(Product::getProductType, query.getProductType());
             wrapper.eq(Product::getProductType, query.getProductType());
         }
         }
         if (query.getDomain() != null && !query.getDomain().isEmpty()) {
         if (query.getDomain() != null && !query.getDomain().isEmpty()) {
-            wrapper.eq(Product::getDomain, query.getDomain());
+            // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧的 domain
+            String domain = query.getDomain();
+            wrapper.and(w -> w.apply("COALESCE(JSON_EXTRACT(dimension_weights, '$." + domain + "'), 0) > 0")
+                    .or().eq(Product::getDomain, domain));
         }
         }
         if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
         if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
             wrapper.like(Product::getName, query.getKeyword());
             wrapper.like(Product::getName, query.getKeyword());

+ 89 - 67
cfc-frontend/components/DimensionActivities.vue

@@ -4,30 +4,47 @@
       <text class="section-title">🔥 推荐活动</text>
       <text class="section-title">🔥 推荐活动</text>
       <text class="section-more" @click="$emit('moreActivities')">更多 ›</text>
       <text class="section-more" @click="$emit('moreActivities')">更多 ›</text>
     </view>
     </view>
-    <view v-if="!displayActivities || displayActivities.length === 0" class="empty-state">
-      <text class="empty-tip">{{ isLoggedIn ? '暂无相关活动' : '登录后查看更多活动' }}</text>
-    </view>
-    <view class="activity-list" v-if="displayActivities && displayActivities.length > 0">
-      <view class="activity-card" v-for="act in displayActivities" :key="act.id" @click.stop="$emit('activityClick', act)">
-        <image class="activity-cover" :src="act.coverImage || '/static/default-activity.png'" mode="aspectFill" />
-        <view class="activity-info">
-          <text class="activity-name line-clamp-1">{{ act.title }}</text>
-          <view class="activity-meta">
-            <text class="activity-time">{{ act.startTime }}</text>
+    <view class="activity-list">
+      <!-- 无数据时显示占位卡片 -->
+      <template v-if="!displayActivities || displayActivities.length === 0">
+        <view class="activity-card" v-for="i in 3" :key="'ph-' + i">
+          <view class="article-category" :style="{ background: gradientColors[(i-1) % gradientColors.length] }">
+            <text class="article-category-text">推荐活动</text>
           </view>
           </view>
-          <view class="activity-bottom">
-            <text class="activity-status" :class="'status-' + (act.status || 'upcoming')">{{ statusText(act.status) }}</text>
-            <text class="activity-fee" v-if="act.priceLabel">{{ act.priceLabel }}</text>
-            <text class="activity-fee" v-else-if="act.price && act.price > 0">{{ formatPriceWithSymbol(act.price) }}</text>
-            <text class="activity-fee fee-free" v-else>免费</text>
+          <text class="article-title">暂无推荐活动</text>
+          <text class="article-summary">精彩活动即将上线,敬请期待</text>
+          <view class="activity-bottom-placeholder">
+            <text class="article-date">--</text>
           </view>
           </view>
         </view>
         </view>
+      </template>
+      <!-- 有数据时显示活动卡片(垂直布局) -->
+      <template v-else><view class="activity-card" v-for="act in displayActivities" :key="act.id" @click.stop="$emit('activityClick', act)">
+        <view class="activity-color-bar" :style="{ background: gradientColors[act.id % gradientColors.length] }">
+          <text class="activity-color-text">{{ statusText(act.status) }}</text>
+        </view>
+        <text class="article-title">{{ act.title }}</text>
+        <text class="article-summary" v-if="act.startTime">{{ act.startTime }}</text>
+        <view class="article-meta">
+          <text class="activity-fee" v-if="act.priceLabel">{{ act.priceLabel }}</text>
+          <text class="activity-fee" v-else-if="act.price && act.price > 0">{{ formatPriceWithSymbol(act.price) }}</text>
+          <text class="activity-fee fee-free" v-else>免费</text>
+        </view>
       </view>
       </view>
+    </template>
     </view>
     </view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
+var activityGradientColors = [
+  'linear-gradient(135deg, #10B981, #34D399)',
+  'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
+  'linear-gradient(135deg, #8B5CF6, #C084FC)',
+  'linear-gradient(135deg, #F97316, #FB923C)',
+  'linear-gradient(135deg, #6366F1, #818CF8)'
+]
+
 export default {
 export default {
   props: {
   props: {
     dimensionCode: { type: String, required: true },
     dimensionCode: { type: String, required: true },
@@ -36,6 +53,7 @@ export default {
   },
   },
   data: function() {
   data: function() {
     return {
     return {
+      gradientColors: activityGradientColors
     }
     }
   },
   },
   computed: {
   computed: {
@@ -47,6 +65,10 @@ export default {
     statusText: function(status) {
     statusText: function(status) {
       var map = { upcoming: '即将开始', ongoing: '进行中', ended: '已结束', cancelled: '已取消' }
       var map = { upcoming: '即将开始', ongoing: '进行中', ended: '已结束', cancelled: '已取消' }
       return map[status] || '即将开始'
       return map[status] || '即将开始'
+    },
+    formatPriceWithSymbol: function(price) {
+      if (price == null) return '免费'
+      return '¥' + (Number(price) / 100).toFixed(0)
     }
     }
   }
   }
 }
 }
@@ -74,78 +96,78 @@ export default {
 .activity-list {
 .activity-list {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
+  gap: 16rpx;
 }
 }
 .activity-card {
 .activity-card {
-  display: flex;
-  flex-direction: row;
   background: #fff;
   background: #fff;
   border-radius: 16rpx;
   border-radius: 16rpx;
-  overflow: hidden;
+  padding: 24rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-  margin-bottom: 20rpx;
-}
-.activity-card:active { opacity: 0.8; }
-.activity-cover {
-  width: 220rpx;
-  height: 200rpx;
-  flex-shrink: 0;
-  background: #f0f0f0;
 }
 }
-.activity-info {
-  flex: 1;
-  padding: 16rpx 20rpx;
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-}
-.activity-name {
-  font-size: 28rpx;
-  color: #333;
-  font-weight: 500;
-}
-.activity-meta {
-  margin-top: 8rpx;
-}
-.activity-time {
-  font-size: 24rpx;
-  color: #999;
-}
-.activity-bottom {
-  display: flex;
-  flex-direction: row;
-  justify-content: space-between;
-  align-items: center;
-  margin-top: 12rpx;
+.activity-card:active {
+  opacity: 0.8;
 }
 }
-.activity-status {
-  font-size: 22rpx;
-  padding: 4rpx 12rpx;
+.activity-color-bar {
+  display: inline-block;
+  padding: 4rpx 14rpx;
   border-radius: 8rpx;
   border-radius: 8rpx;
+  margin-bottom: 10rpx;
+}
+.activity-color-text {
+  font-size: 20rpx;
+  color: #fff;
 }
 }
-.status-upcoming { background: #FFF7ED; color: #F97316; }
-.status-ongoing { background: #F0FDF4; color: #22C55E; }
-.status-ended { background: #F5F5F5; color: #999; }
-.status-cancelled { background: #FEE2E2; color: #EF4444; }
 .activity-fee {
 .activity-fee {
-  font-size: 24rpx;
+  font-size: 22rpx;
   color: #F97316;
   color: #F97316;
   font-weight: 500;
   font-weight: 500;
 }
 }
 .fee-free {
 .fee-free {
   color: #22C55E;
   color: #22C55E;
 }
 }
-.empty-state {
+.activity-bottom-placeholder {
   display: flex;
   display: flex;
-  justify-content: center;
-  padding: 40rpx 0;
+  align-items: center;
+  gap: 16rpx;
 }
 }
-.empty-tip {
-  font-size: 24rpx;
-  color: #999;
+/* 共享文章卡片样式 */
+.article-category {
+  display: inline-block;
+  padding: 4rpx 14rpx;
+  border-radius: 8rpx;
+  margin-bottom: 10rpx;
+}
+.article-category-text {
+  font-size: 20rpx;
+  color: #fff;
 }
 }
-.line-clamp-1 {
+.article-title {
+  display: block;
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 8rpx;
+  line-height: 1.4;
+}
+.article-summary {
+  display: block;
+  font-size: 24rpx;
+  color: #888;
+  line-height: 1.5;
+  margin-bottom: 12rpx;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
-  white-space: nowrap;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+.article-meta {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.article-date {
+  font-size: 22rpx;
+  color: #bbb;
 }
 }
 </style>
 </style>

+ 2 - 10
cfc-frontend/components/DimensionArticles.vue

@@ -23,7 +23,7 @@
         </view>
         </view>
       </template>
       </template>
       <!-- 有数据时显示文章卡片 -->
       <!-- 有数据时显示文章卡片 -->
-      <view v-else class="article-card" v-for="article in displayArticles" :key="article.id" @click="$emit('articleClick', article)">
+      <template v-else><view class="article-card" v-for="article in displayArticles" :key="article.id" @click="$emit('articleClick', article)">
         <view class="article-category" :style="{ background: article.categoryColor || defaultColors[0] }">
         <view class="article-category" :style="{ background: article.categoryColor || defaultColors[0] }">
           <text class="article-category-text">{{ article.category || '推荐文章' }}</text>
           <text class="article-category-text">{{ article.category || '推荐文章' }}</text>
         </view>
         </view>
@@ -41,6 +41,7 @@
           <text class="article-date" v-if="article.date">{{ article.date }}</text>
           <text class="article-date" v-if="article.date">{{ article.date }}</text>
         </view>
         </view>
       </view>
       </view>
+    </template>
     </view>
     </view>
   </view>
   </view>
 </template>
 </template>
@@ -154,13 +155,4 @@ export default {
   color: #bbb;
   color: #bbb;
   margin-left: auto;
   margin-left: auto;
 }
 }
-.empty-state {
-  display: flex;
-  justify-content: center;
-  padding: 40rpx 0;
-}
-.empty-tip {
-  font-size: 24rpx;
-  color: #999;
-}
 </style>
 </style>

+ 52 - 36
cfc-frontend/components/DimensionProducts.vue

@@ -4,17 +4,25 @@
       <text class="section-title">🛍️ 推荐商品</text>
       <text class="section-title">🛍️ 推荐商品</text>
       <text class="section-more" @click="$emit('moreProducts')">更多 ›</text>
       <text class="section-more" @click="$emit('moreProducts')">更多 ›</text>
     </view>
     </view>
-    <view v-if="!displayProducts || displayProducts.length === 0" class="empty-state">
-      <text class="empty-tip">{{ isLoggedIn ? '暂无商品' : '登录后查看更多商品' }}</text>
-    </view>
-    <view class="product-list" v-if="displayProducts && displayProducts.length > 0">
-      <view class="product-card" v-for="prod in displayProducts" :key="prod.id" @click="$emit('productClick', prod)">
-        <image class="product-img" :src="prod.coverImage || '/static/default-product.png'" mode="aspectFill" />
-        <text class="product-name line-clamp-2">{{ prod.name }}</text>
-        <text class="product-price" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
-        <text class="product-price" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
-        <text class="product-price" v-else>免费</text>
-      </view>
+    <view class="product-list">
+      <!-- 无数据时显示占位卡片 -->
+      <template v-if="!displayProducts || displayProducts.length === 0">
+        <view class="product-card" v-for="i in 2" :key="'ph-' + i">
+          <view class="product-img-placeholder"></view>
+          <text class="product-name line-clamp-2">暂无推荐商品</text>
+          <text class="product-price">--</text>
+        </view>
+      </template>
+      <!-- 有数据时显示商品卡片 -->
+      <template v-else>
+        <view class="product-card" v-for="prod in displayProducts" :key="prod.id" @click="$emit('productClick', prod)">
+          <image class="product-img" :src="prod.coverImage || '/static/default-product.png'" mode="aspectFill" />
+          <text class="product-name line-clamp-2">{{ prod.name }}</text>
+          <text class="product-price" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
+          <text class="product-price" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
+          <text class="product-price" v-else>免费</text>
+        </view>
+      </template>
     </view>
     </view>
   </view>
   </view>
 </template>
 </template>
@@ -26,16 +34,16 @@ export default {
     products: { type: Array, default: function() { return [] } },
     products: { type: Array, default: function() { return [] } },
     isLoggedIn: { type: Boolean, default: false }
     isLoggedIn: { type: Boolean, default: false }
   },
   },
-  data: function() {
-    return {
-    }
-  },
   computed: {
   computed: {
     displayProducts: function() {
     displayProducts: function() {
       return this.products
       return this.products
     }
     }
   },
   },
   methods: {
   methods: {
+    formatPriceWithSymbol: function(price) {
+      if (price == null) return '免费'
+      return '¥' + (Number(price) / 100).toFixed(0)
+    }
   }
   }
 }
 }
 </script>
 </script>
@@ -61,36 +69,53 @@ export default {
 }
 }
 .product-list {
 .product-list {
   display: flex;
   display: flex;
+  flex-direction: row;
   flex-wrap: wrap;
   flex-wrap: wrap;
+  gap: 16rpx;
 }
 }
 .product-card {
 .product-card {
-  width: calc(50% - 10rpx);
+  width: calc(50% - 8rpx);
   background: #fff;
   background: #fff;
   border-radius: 16rpx;
   border-radius: 16rpx;
-  overflow: hidden;
+  padding: 16rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-  margin-right: 20rpx;
-  margin-bottom: 20rpx;
+  box-sizing: border-box;
 }
 }
-.product-card:nth-child(2n) {
-  margin-right: 0;
+.product-card:active {
+  opacity: 0.8;
 }
 }
 .product-img {
 .product-img {
   width: 100%;
   width: 100%;
-  height: 240rpx;
+  height: 200rpx;
+  border-radius: 12rpx;
   background: #f0f0f0;
   background: #f0f0f0;
+  display: block;
+}
+.product-img-placeholder {
+  width: 100%;
+  height: 200rpx;
+  border-radius: 12rpx;
+  background: linear-gradient(135deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  margin-bottom: 10rpx;
+}
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
 }
 }
 .product-name {
 .product-name {
   font-size: 26rpx;
   font-size: 26rpx;
   color: #333;
   color: #333;
-  padding: 12rpx 16rpx 0;
+  font-weight: 500;
+  display: block;
+  margin: 8rpx 0 6rpx;
+  min-height: 36rpx;
 }
 }
 .product-price {
 .product-price {
-  font-size: 28rpx;
+  font-size: 24rpx;
   color: #F97316;
   color: #F97316;
-  font-weight: bold;
-  padding: 8rpx 16rpx 16rpx;
-  display: block;
+  font-weight: 600;
 }
 }
 .line-clamp-2 {
 .line-clamp-2 {
   overflow: hidden;
   overflow: hidden;
@@ -99,13 +124,4 @@ export default {
   -webkit-line-clamp: 2;
   -webkit-line-clamp: 2;
   -webkit-box-orient: vertical;
   -webkit-box-orient: vertical;
 }
 }
-.empty-state {
-  display: flex;
-  justify-content: center;
-  padding: 40rpx 0;
-}
-.empty-tip {
-  font-size: 24rpx;
-  color: #999;
-}
 </style>
 </style>

+ 0 - 323
cfc-frontend/components/FamilyRelationshipSection.vue

@@ -1,323 +0,0 @@
-<template>
-  <view class="family-relationship-wrapper">
-    <!-- ===== 重要关系维护 ===== -->
-    <view class="contact-section" v-if="isLoggedIn">
-    <view class="contact-header">
-      <text class="contact-section-title">重要关系</text>
-      <view class="contact-header-right">
-        <text class="contact-more" @click="$emit('refresh')">刷新</text>
-        <text class="contact-import-btn" @click="$emit('import')">+ 导入</text>
-        <text class="contact-manage-btn" @click="$emit('manage')">⚙️</text>
-      </view>
-    </view>
-    <scroll-view class="contact-scroll" scroll-x enable-flex>
-      <view class="contact-scroll-inner">
-        <view class="contact-card-wrap" v-for="item in contactList" :key="item.id" @click="$emit('contactClick', item)">
-          <ContactCard :contact="item" />
-        </view>
-        <view class="contact-card-wrap contact-add-card" @click="$emit('import')">
-          <view class="add-card-inner">
-            <text class="add-card-icon">+</text>
-            <text class="add-card-label">导入通讯录</text>
-          </view>
-        </view>
-      </view>
-    </scroll-view>
-    <view class="contact-empty" v-if="contactList.length === 0">
-      <text class="contact-empty-text">暂无重要联系人,点击导入添加</text>
-    </view>
-  </view>
-
-  <!-- ===== 关系健康概览 ===== -->
-  <view class="rq-section" v-if="isLoggedIn && (healthAlerts.length > 0 || milestones.length > 0)">
-    <view class="rq-header">
-      <text class="rq-title">关系健康</text>
-      <text class="rq-more" @click="$emit('questionnaire')">去做问卷</text>
-    </view>
-    <!-- 健康预警列表 -->
-    <view class="rq-alerts" v-if="healthAlerts.length > 0">
-      <view class="rq-alert-card" v-for="alert in healthAlerts" :key="alert.memberId" @click="$emit('interactionLog', alert.memberId)">
-        <view class="rq-alert-icon">
-          <text class="rq-alert-emoji">⚠️</text>
-        </view>
-        <view class="rq-alert-info">
-          <text class="rq-alert-name">{{ alert.memberName }}</text>
-          <text class="rq-alert-desc">已{{ alert.daysSinceInteraction }}天无互动</text>
-        </view>
-        <view class="rq-alert-action">
-          <text class="rq-alert-btn">去互动</text>
-        </view>
-      </view>
-    </view>
-    <!-- 里程碑列表 -->
-    <view class="rq-milestones" v-if="milestones.length > 0">
-      <view class="rq-milestone-card" v-for="m in milestones" :key="m.memberId">
-        <view class="rq-milestone-icon">
-          <text class="rq-milestone-emoji">🎂</text>
-        </view>
-        <view class="rq-milestone-info">
-          <text class="rq-milestone-name">{{ m.memberName }}</text>
-          <text class="rq-milestone-desc">{{ m.daysUntil > 0 ? m.daysUntil + '天后生日' : '今天生日' }}</text>
-        </view>
-      </view>
-    </view>
-    <!-- 快捷操作 -->
-    <view class="rq-quick-actions">
-      <view class="rq-action-btn" @click="$emit('interactionLog')">
-        <text class="rq-action-icon">📝</text>
-        <text class="rq-action-label">记录互动</text>
-      </view>
-      <view class="rq-action-btn" @click="$emit('questionnaire')">
-        <text class="rq-action-icon">🤝</text>
-        <text class="rq-action-label">做关系问卷</text>
-      </view>
-    </view>
-  </view>
-  </view>
-</template>
-
-<script>
-export default {
-  props: {
-    contactList: { type: Array, default: function() { return [] } },
-    healthAlerts: { type: Array, default: function() { return [] } },
-    milestones: { type: Array, default: function() { return [] } },
-    isLoggedIn: { type: Boolean, default: false }
-  }
-}
-</script>
-
-<style scoped>
-/* ===== 重要关系 ===== */
-.contact-section {
-  margin: 20rpx 20rpx 0;
-}
-.contact-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-.contact-section-title {
-  font-size: 30rpx;
-  font-weight: 600;
-  color: #333;
-}
-.contact-header-right {
-  display: flex;
-  gap: 16rpx;
-  align-items: center;
-}
-.contact-more {
-  font-size: 24rpx;
-  color: #999;
-}
-.contact-import-btn {
-  font-size: 24rpx;
-  color: #F97316;
-  font-weight: 500;
-}
-.contact-manage-btn {
-  font-size: 30rpx;
-  color: #999;
-  padding: 4rpx;
-}
-.contact-scroll {
-  white-space: nowrap;
-  overflow: hidden;
-}
-.contact-scroll-inner {
-  display: flex;
-  flex-direction: row;
-  gap: 16rpx;
-  padding: 8rpx 0 16rpx;
-}
-.contact-card-wrap {
-  flex-shrink: 0;
-  width: 220rpx;
-}
-.contact-card-wrap:active {
-  opacity: 0.8;
-}
-.contact-add-card {
-  width: 160rpx;
-}
-.add-card-inner {
-  width: 160rpx;
-  height: 280rpx;
-  background: #fff;
-  border-radius: 16rpx;
-  border: 2rpx dashed #ddd;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  gap: 12rpx;
-}
-.add-card-icon {
-  font-size: 48rpx;
-  color: #ccc;
-}
-.add-card-label {
-  font-size: 22rpx;
-  color: #999;
-}
-.contact-empty {
-  padding: 40rpx 0;
-  text-align: center;
-}
-.contact-empty-text {
-  font-size: 26rpx;
-  color: #ccc;
-}
-
-/* ===== 关系健康概览 ===== */
-.rq-section {
-  margin: 20rpx 20rpx 0;
-}
-.rq-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-.rq-title {
-  font-size: 30rpx;
-  font-weight: 600;
-  color: #333;
-}
-.rq-more {
-  font-size: 24rpx;
-  color: #F97316;
-  font-weight: 500;
-}
-/* 健康预警卡片 */
-.rq-alerts {
-  display: flex;
-  flex-direction: column;
-  gap: 12rpx;
-  margin-bottom: 16rpx;
-}
-.rq-alert-card {
-  background: #FFF7ED;
-  border-radius: 16rpx;
-  padding: 20rpx;
-  display: flex;
-  align-items: center;
-  gap: 16rpx;
-  border: 1rpx solid #FED7AA;
-}
-.rq-alert-card:active {
-  opacity: 0.8;
-}
-.rq-alert-icon {
-  width: 64rpx;
-  height: 64rpx;
-  border-radius: 50%;
-  background: #fff;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-shrink: 0;
-}
-.rq-alert-emoji {
-  font-size: 32rpx;
-}
-.rq-alert-info {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  gap: 4rpx;
-}
-.rq-alert-name {
-  font-size: 28rpx;
-  font-weight: 600;
-  color: #333;
-}
-.rq-alert-desc {
-  font-size: 24rpx;
-  color: #F97316;
-}
-.rq-alert-action {
-  flex-shrink: 0;
-}
-.rq-alert-btn {
-  font-size: 24rpx;
-  color: #fff;
-  background: #F97316;
-  padding: 8rpx 20rpx;
-  border-radius: 20rpx;
-}
-/* 里程碑卡片 */
-.rq-milestones {
-  display: flex;
-  flex-direction: column;
-  gap: 12rpx;
-  margin-bottom: 16rpx;
-}
-.rq-milestone-card {
-  background: #F0F9FF;
-  border-radius: 16rpx;
-  padding: 20rpx;
-  display: flex;
-  align-items: center;
-  gap: 16rpx;
-  border: 1rpx solid #BAE6FD;
-}
-.rq-milestone-icon {
-  width: 64rpx;
-  height: 64rpx;
-  border-radius: 50%;
-  background: #fff;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-shrink: 0;
-}
-.rq-milestone-emoji {
-  font-size: 32rpx;
-}
-.rq-milestone-info {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  gap: 4rpx;
-}
-.rq-milestone-name {
-  font-size: 28rpx;
-  font-weight: 600;
-  color: #333;
-}
-.rq-milestone-desc {
-  font-size: 24rpx;
-  color: #0EA5E9;
-}
-/* 快捷操作 */
-.rq-quick-actions {
-  display: flex;
-  gap: 16rpx;
-  margin-top: 8rpx;
-}
-.rq-action-btn {
-  flex: 1;
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 20rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 10rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.rq-action-btn:active {
-  opacity: 0.8;
-}
-.rq-action-icon {
-  font-size: 36rpx;
-}
-.rq-action-label {
-  font-size: 26rpx;
-  color: #333;
-  font-weight: 500;
-}
-</style>

+ 2 - 2
cfc-frontend/config.js

@@ -10,7 +10,7 @@
  * 无需手动修改任何变量,发布到对应环境自动生效。
  * 无需手动修改任何变量,发布到对应环境自动生效。
  */
  */
 
 
-let API_BASE_URL = 'http://cfc.iwintrue.com:9082'
+let API_BASE_URL = 'http://cfc.iwintrue.com'
 
 
 try {
 try {
   const accountInfo = uni.getAccountInfoSync()
   const accountInfo = uni.getAccountInfoSync()
@@ -19,7 +19,7 @@ try {
   if (env) {
   if (env) {
     switch (env) {
     switch (env) {
       case 'develop':
       case 'develop':
-        API_BASE_URL = 'http://localhost:9082'
+        API_BASE_URL = 'https://cf-club.iwintrue.com'
         break
         break
       case 'trial':
       case 'trial':
         API_BASE_URL = 'https://cfc.iwintrue.com'
         API_BASE_URL = 'https://cfc.iwintrue.com'

+ 6 - 12
cfc-frontend/pages.json

@@ -1,11 +1,5 @@
 {
 {
   "pages": [
   "pages": [
-    {
-      "path": "pages/wealth/index",
-      "style": {
-        "navigationBarTitleText": "富"
-      }
-    },
     {
     {
       "path": "pages/profile/index",
       "path": "pages/profile/index",
       "style": {
       "style": {
@@ -49,6 +43,12 @@
         "navigationBarTitleText": "智"
         "navigationBarTitleText": "智"
       }
       }
     },
     },
+    {
+      "path": "pages/wealth/index",
+      "style": {
+        "navigationBarTitleText": "富"
+      }
+    },
     {
     {
       "path": "pages/profile/profile",
       "path": "pages/profile/profile",
       "style": {
       "style": {
@@ -953,12 +953,6 @@
           "style": {
           "style": {
             "navigationBarTitleText": "菌群"
             "navigationBarTitleText": "菌群"
           }
           }
-        },
-        {
-          "path": "gut-flora-detail",
-          "style": {
-            "navigationBarTitleText": "菌群报告详情"
-          }
         }
         }
       ]
       ]
     },
     },

+ 7 - 32
cfc-frontend/pages/body/index.vue

@@ -37,16 +37,6 @@
       :intimacyMap="intimacyMapForGraph"
       :intimacyMap="intimacyMapForGraph"
       @memberTap="goMemberDetail" />
       @memberTap="goMemberDetail" />
 
 
-    <!-- 登录后:用户快捷入口 -->
-    <UserQuickEntry
-      v-if="isLoggedIn"
-      :userName="userName"
-      roleName="家长"
-      :currentChildName="activeChildName"
-      :children="children"
-      @childChanged="onChildChanged"
-      @scrollTo="scrollToSection" />
-
     <!-- 健康打卡卡片 -->
     <!-- 健康打卡卡片 -->
     <HealthCheckinCard
     <HealthCheckinCard
       v-if="isLoggedIn && activeChildId"
       v-if="isLoggedIn && activeChildId"
@@ -184,22 +174,6 @@
       @articleClick="goArticleDetail"
       @articleClick="goArticleDetail"
       @moreArticles="goMoreArticles" />
       @moreArticles="goMoreArticles" />
 
 
-    <!-- 快捷操作按钮 -->
-    <view class="section quick-actions" v-if="isLoggedIn">
-      <view class="action-btn" @click="goToHealthReport">
-        <text class="action-icon">📋</text>
-        <text class="action-text">报告详情</text>
-      </view>
-      <view class="action-btn" @click="goToCheckin">
-        <text class="action-icon">🏃</text>
-        <text class="action-text">健康打卡</text>
-      </view>
-      <view class="action-btn" @click="goToNutritionProfile">
-        <text class="action-icon">🥗</text>
-        <text class="action-text">营养档案</text>
-      </view>
-    </view>
-
     
     
 
 
     <!-- 底部占位 -->
     <!-- 底部占位 -->
@@ -213,7 +187,6 @@ import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -255,7 +228,7 @@ const healthRequest = function(url, data) {
 }
 }
 
 
 export default {
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, RadarChart, HealthDimensionsSection, HealthCheckinCard, AIFloatingAvatar },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, RadarChart, HealthDimensionsSection, HealthCheckinCard, AIFloatingAvatar },
   data() {
   data() {
     return {
     return {
       isLoggedIn: false,
       isLoggedIn: false,
@@ -283,7 +256,8 @@ export default {
         { icon: '\u{1F634}', label: '作息', needLogin: true, page: '/pages/body/sleep-record' },
         { icon: '\u{1F634}', label: '作息', needLogin: true, page: '/pages/body/sleep-record' },
         { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '/pages/health/meditation-index' },
         { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '/pages/health/meditation-index' },
         { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: '/pages/health/report-upload?reportType=tongue' }
         { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: '/pages/health/report-upload?reportType=tongue' }
-      }
+      ]
+    }
   },
   },
   computed: {
   computed: {
     reportTypeLabel: function() {
     reportTypeLabel: function() {
@@ -388,12 +362,13 @@ export default {
       this.loadHealthDimensions()
       this.loadHealthDimensions()
     },
     },
     loadHealthDimensions: function() {
     loadHealthDimensions: function() {
-      if (!this.activeChildId) {
-        console.log('[body] loadHealthDimensions skipped: no activeChildId')
+      var memberId = this.activeChildId || (this.userInfo && this.userInfo.userId)
+      if (!memberId) {
+        console.log('[body] loadHealthDimensions skipped: no memberId')
         return
         return
       }
       }
       var self = this
       var self = this
-      getDimensionOverview({ memberId: this.activeChildId }).then(function(res) {
+      getDimensionOverview({ memberId: memberId }).then(function(res) {
         if (res && res.data) {
         if (res && res.data) {
           self.dimensionData = res.data
           self.dimensionData = res.data
           console.log('[body] dimensionData loaded:', res.data.dimensions ? res.data.dimensions.length : 0, 'dimensions')
           console.log('[body] dimensionData loaded:', res.data.dimensions ? res.data.dimensions.length : 0, 'dimensions')

+ 383 - 0
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -0,0 +1,383 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">菌群报告详情</text>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <!-- 报告头部 -->
+      <view class="report-header">
+        <text class="report-title">肠道菌群检测报告</text>
+        <text class="report-date" v-if="report.reportDate">报告日期: {{ report.reportDate }}</text>
+        <text class="report-number" v-if="report.reportNumber">报告编号: {{ report.reportNumber }}</text>
+      </view>
+
+      <!-- 5个评分圆环 -->
+      <view class="scores-section">
+        <view class="score-ring" v-for="(score, idx) in scoreRings" :key="idx">
+          <view class="ring-wrapper">
+            <canvas class="ring-canvas" :canvas-id="'ring_' + idx" style="width:120rpx;height:120rpx;"></canvas>
+            <text class="ring-value">{{ score.value }}</text>
+          </view>
+          <text class="ring-label">{{ score.label }}</text>
+        </view>
+      </view>
+
+      <!-- 菌群分类 -->
+      <view class="section-card" v-for="(group, gIdx) in bacteriaGroups" :key="gIdx">
+        <view class="section-title">
+          <text>{{ group.name }}</text>
+          <text class="section-count">{{ group.items.length }}种</text>
+        </view>
+        <view class="bacteria-item" v-for="(item, iIdx) in group.items" :key="iIdx">
+          <view class="bacteria-header">
+            <text class="bacteria-name">{{ item.name }}</text>
+            <text class="bacteria-status" :class="'status-' + (item.status || 'normal')">{{ statusText(item.status) }}</text>
+          </view>
+          <view class="bar-track">
+            <view class="bar-fill" :style="'width:' + (item.abundance || 0) + '%'"></view>
+          </view>
+          <text class="bar-label">{{ (item.abundance || 0).toFixed(1) }}%</text>
+        </view>
+        <view class="empty-hint" v-if="group.items.length === 0">暂无数据</view>
+      </view>
+
+      <!-- 饮食推荐 -->
+      <view class="section-card" v-if="foods.length > 0">
+        <view class="section-title">
+          <text>饮食推荐</text>
+          <text class="section-count">{{ foods.length }}项</text>
+        </view>
+        <view class="food-item" v-for="(food, fIdx) in foods" :key="fIdx">
+          <text class="food-name">{{ food.foodName || food.name }}</text>
+          <text class="food-category" v-if="food.category">{{ food.category }}</text>
+          <text class="food-reason" v-if="food.reason">{{ food.reason }}</text>
+        </view>
+      </view>
+
+      <view class="bottom-spacer"></view>
+    </scroll-view>
+
+    <!-- 营养咨询 FAB -->
+    <view class="fab" @tap="goToNutritionChat">
+      <text class="fab-icon">💬</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getReportDetail } from '../../utils/api.js'
+import { createReportSurvey } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      reportId: null,
+      report: {},
+      scoreRings: [],
+      bacteriaGroups: [
+        { name: '有益菌', items: [] },
+        { name: '有害菌', items: [] },
+        { name: '中性菌', items: [] }
+      ],
+      foods: []
+    }
+  },
+  onLoad: function(options) {
+    this.reportId = options.reportId ? parseInt(options.reportId) : null
+    if (this.reportId) {
+      this.loadReportDetail()
+    }
+  },
+  onReady: function() {
+    // canvas需要DOM就绪后绘制
+    setTimeout(() => {
+      this.drawRings()
+    }, 500)
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    },
+    loadReportDetail: function() {
+      var self = this
+      uni.showLoading({ title: '加载中...' })
+      getReportDetail(this.reportId).then(function(res) {
+        uni.hideLoading()
+        if (res && res.code === 200 && res.data) {
+          var data = res.data
+          self.report = {
+            reportDate: data.reportDate || '',
+            reportNumber: data.reportNumber || '',
+            personName: data.personName || ''
+          }
+          // 解析评分
+          self.scoreRings = [
+            { label: '综合评分', value: data.overallScore || 0, max: 100 },
+            { label: '肠道健康', value: data.gutHealthScore || 0, max: 100 },
+            { label: '营养评分', value: data.nutritionScore || 0, max: 100 },
+            { label: '有益菌', value: data.beneficialScore || 0, max: 100 },
+            { label: '多样性', value: data.diversityScore || 0, max: 100 }
+          ]
+          // 解析 payload 中的菌群数据
+          var payload = data.payload
+          if (payload) {
+            self.parseBacteria(payload)
+            if (payload.foods) {
+              self.foods = payload.foods
+            }
+          }
+          // 延迟绘制canvas
+          self.$nextTick(function() {
+            setTimeout(function() { self.drawRings() }, 300)
+          })
+        } else {
+          uni.showToast({ title: '加载失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      })
+    },
+    parseBacteria: function(payload) {
+      var species = payload.probioticSpecies || payload.gutFlora || []
+      var groups = [
+        { name: '有益菌', items: [] },
+        { name: '有害菌', items: [] },
+        { name: '中性菌', items: [] }
+      ]
+      for (var i = 0; i < species.length; i++) {
+        var item = species[i]
+        var category = item.category || 'beneficial'
+        var groupIdx = 0
+        if (category === 'beneficial') groupIdx = 0
+        else if (category === 'harmful') groupIdx = 1
+        else groupIdx = 2
+        groups[groupIdx].items.push({
+          name: item.name || item.speciesName || '未知',
+          abundance: item.abundance || item.relativeAbundance || 0,
+          status: item.status || 'normal'
+        })
+      }
+      this.bacteriaGroups = groups
+    },
+    drawRings: function() {
+      var self = this
+      this.scoreRings.forEach(function(score, idx) {
+        var ctx = uni.createCanvasContext('ring_' + idx, self)
+        var size = 120
+        var cx = size / 2
+        var cy = size / 2
+        var radius = 40
+        var lineWidth = 10
+        var percent = Math.min(score.value / (score.max || 100), 1)
+
+        // 背景圆环
+        ctx.setStrokeStyle('#E8E8E8')
+        ctx.setLineWidth(lineWidth)
+        ctx.setLineCap('round')
+        ctx.beginPath()
+        ctx.arc(cx, cy, radius, 0, 2 * Math.PI)
+        ctx.stroke()
+
+        // 前景圆环
+        var colors = ['#5B9BD5', '#FF8C42', '#10B981', '#6366F1', '#FF6B9D']
+        ctx.setStrokeStyle(colors[idx % colors.length])
+        ctx.setLineWidth(lineWidth)
+        ctx.setLineCap('round')
+        ctx.beginPath()
+        ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * percent)
+        ctx.stroke()
+
+        ctx.draw()
+      })
+    },
+    statusText: function(status) {
+      if (status === 'high') return '偏高'
+      if (status === 'low') return '偏低'
+      if (status === 'abnormal') return '异常'
+      return '正常'
+    },
+    goToNutritionChat: function() {
+      if (!this.reportId) return
+      createReportSurvey(this.reportId, '').then(function(res) {
+        if (res && res.code === 200) {
+          uni.navigateTo({
+            url: '/pages/health/nutrition-chat?reportId=' + (res.data && res.data.surveyId ? res.data.surveyId : '')
+          })
+        }
+      }).catch(function() {
+        uni.navigateTo({
+          url: '/pages/health/nutrition-chat?reportId=' + this.reportId
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+}
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: var(--status-bar-height, 0);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+.back-text { font-size: 28rpx; color: #333; }
+.nav-title { font-size: 32rpx; font-weight: 600; color: #333; }
+
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.report-header {
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 30rpx;
+}
+.report-title { font-size: 34rpx; font-weight: bold; color: #fff; display: block; margin-bottom: 12rpx; }
+.report-date { font-size: 24rpx; color: rgba(255,255,255,0.85); display: block; margin-bottom: 6rpx; }
+.report-number { font-size: 22rpx; color: rgba(255,255,255,0.7); display: block; }
+
+.scores-section {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx 20rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
+}
+.score-ring {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-width: 120rpx;
+}
+.ring-wrapper {
+  position: relative;
+  width: 120rpx;
+  height: 120rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.ring-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+.ring-value {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  z-index: 1;
+}
+.ring-label {
+  font-size: 22rpx;
+  color: #666;
+  margin-top: 10rpx;
+}
+
+.section-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
+}
+.section-title {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+  padding-bottom: 16rpx;
+  border-bottom: 2rpx solid #f0f0f0;
+}
+.section-count { font-size: 24rpx; color: #999; font-weight: normal; }
+
+.bacteria-item {
+  margin-bottom: 20rpx;
+}
+.bacteria-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+.bacteria-name { font-size: 26rpx; color: #333; font-weight: 500; }
+.bacteria-status { font-size: 22rpx; padding: 2rpx 12rpx; border-radius: 6rpx; }
+.status-normal { background: #E8F5E9; color: #2E7D32; }
+.status-high { background: #FFEBEE; color: #C62828; }
+.status-low { background: #FFF3E0; color: #E65100; }
+.status-abnormal { background: #FFF3E0; color: #E65100; }
+
+.bar-track {
+  height: 12rpx;
+  background: #F0F0F0;
+  border-radius: 6rpx;
+  overflow: hidden;
+}
+.bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #5B9BD5, #3A7CC4);
+  border-radius: 6rpx;
+}
+.bar-label { font-size: 22rpx; color: #999; margin-top: 4rpx; display: block; }
+.empty-hint { font-size: 26rpx; color: #999; text-align: center; padding: 40rpx 0; }
+
+.food-item {
+  display: flex;
+  flex-direction: column;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.food-item:last-child { border-bottom: none; }
+.food-name { font-size: 26rpx; color: #333; font-weight: 500; }
+.food-category { font-size: 22rpx; color: #5B9BD5; margin-top: 4rpx; }
+.food-reason { font-size: 22rpx; color: #666; margin-top: 4rpx; }
+
+.bottom-spacer { height: 40rpx; }
+
+.fab {
+  position: fixed;
+  right: 30rpx;
+  bottom: 100rpx;
+  width: 100rpx;
+  height: 100rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4rpx 20rpx rgba(91, 155, 213, 0.4);
+  z-index: 100;
+}
+.fab-icon { font-size: 44rpx; }
+</style>

+ 27 - 0
cfc-frontend/pages/health/gut-index.vue

@@ -90,6 +90,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
+import { getLatestHealthReport } from '../../utils/api.js'
 export default {
 export default {
   data() {
   data() {
     return {}
     return {}
@@ -102,6 +103,27 @@ export default {
       uni.navigateTo({
       uni.navigateTo({
         url: '/pages/health/report-upload'
         url: '/pages/health/report-upload'
       })
       })
+    },
+    goToMyReport: function() {
+      var self = this
+      var childId = getApp().globalData.currentChildId || ''
+      uni.showLoading({ title: '加载中...' })
+      getLatestHealthReport(childId).then(function(res) {
+        uni.hideLoading()
+        if (res && res.code === 200 && res.data) {
+          var reportId = res.data.id
+          if (reportId) {
+            uni.showToast({ title: '报告详情页开发中', icon: 'none' })
+          } else {
+            uni.showToast({ title: '暂无肠道菌群报告', icon: 'none' })
+          }
+        } else {
+          uni.showToast({ title: '暂无肠道菌群报告', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '暂无肠道菌群报告', icon: 'none' })
+      })
     }
     }
   }
   }
 }
 }
@@ -225,6 +247,11 @@ export default {
   box-shadow: 0 4rpx 12rpx rgba(255, 140, 66, 0.3);
   box-shadow: 0 4rpx 12rpx rgba(255, 140, 66, 0.3);
 }
 }
 
 
+.action-btn.primary {
+  background-color: #5B9BD5;
+  box-shadow: 0 4rpx 12rpx rgba(91, 155, 213, 0.3);
+}
+
 .action-btn-text {
 .action-btn-text {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #FFFFFF;
   color: #FFFFFF;

+ 10 - 0
cfc-frontend/pages/health/report-confirm.vue

@@ -44,6 +44,10 @@
           <text class="field-label">综合评分</text>
           <text class="field-label">综合评分</text>
           <input class="field-input" type="number" v-model="payload.summary.overallScore" />
           <input class="field-input" type="number" v-model="payload.summary.overallScore" />
         </view>
         </view>
+        <view class="field-row" v-if="payload.summary && payload.type !== 'tongue'">
+          <text class="field-label">报告日期</text>
+          <input class="field-input" type="text" v-model="payload.summary.reportDate" placeholder="如: 2025-06-15" />
+        </view>
         <view class="field-row" v-if="payload.summary && payload.type !== 'tongue'">
         <view class="field-row" v-if="payload.summary && payload.type !== 'tongue'">
           <text class="field-label">报告摘要</text>
           <text class="field-label">报告摘要</text>
           <textarea class="field-textarea" v-model="payload.summary.summary" maxlength="500" />
           <textarea class="field-textarea" v-model="payload.summary.summary" maxlength="500" />
@@ -308,6 +312,12 @@ export default {
     this.extractedGender = options.extractedGender || ''
     this.extractedGender = options.extractedGender || ''
     this.extractedAge = options.extractedAge || ''
     this.extractedAge = options.extractedAge || ''
     this.matchedMemberId = options.matchedMemberId ? parseInt(options.matchedMemberId) : null
     this.matchedMemberId = options.matchedMemberId ? parseInt(options.matchedMemberId) : null
+
+    // 从解析结果填充报告日期
+    var extractedReportDate = options.extractedReportDate || ''
+    if (extractedReportDate && this.payload.summary) {
+      this.payload.summary.reportDate = extractedReportDate
+    }
     this.needBind = options.needBind === 'true' || options.needBind === '1'
     this.needBind = options.needBind === 'true' || options.needBind === '1'
     this.matchConfidence = options.matchConfidence ? parseFloat(options.matchConfidence) : null
     this.matchConfidence = options.matchConfidence ? parseFloat(options.matchConfidence) : null
     this.matchLabel = decodeURIComponent(options.matchLabel || '')
     this.matchLabel = decodeURIComponent(options.matchLabel || '')

+ 7 - 3
cfc-frontend/pages/health/report-list.vue

@@ -18,7 +18,7 @@
         class="report-card"
         class="report-card"
         v-for="item in reports"
         v-for="item in reports"
         :key="item.id"
         :key="item.id"
-        @click="goToDetail(item.id)"
+        @click="goToDetail(item.id, item.reportType)"
       >
       >
         <view class="report-card-left">
         <view class="report-card-left">
           <view class="report-type-badge" :class="'badge-' + (item.reportType === 'gut_flora' ? 'gut' : item.reportType === 'tongue' ? 'tongue' : 'exam')">
           <view class="report-type-badge" :class="'badge-' + (item.reportType === 'gut_flora' ? 'gut' : item.reportType === 'tongue' ? 'tongue' : 'exam')">
@@ -77,8 +77,12 @@ export default {
     goBack() {
     goBack() {
       uni.navigateBack()
       uni.navigateBack()
     },
     },
-    goToDetail(reportId) {
-      uni.navigateTo({ url: '/pages/body/health-report?reportId=' + reportId })
+    goToDetail(reportId, reportType) {
+      if (reportType === 'gut_flora') {
+        uni.showToast({ title: '报告详情页开发中', icon: 'none' })
+      } else {
+        uni.navigateTo({ url: '/pages/body/health-report?reportId=' + reportId })
+      }
     },
     },
     goToUpload() {
     goToUpload() {
       uni.navigateTo({ url: '/pages/health/report-upload' })
       uni.navigateTo({ url: '/pages/health/report-upload' })

+ 1 - 12
cfc-frontend/pages/mind/index.vue

@@ -42,16 +42,6 @@
       :compatibilityMap="compatibilityMapForGraph"
       :compatibilityMap="compatibilityMapForGraph"
       @memberTap="goMemberDetail" />
       @memberTap="goMemberDetail" />
 
 
-    <!-- 登录后:用户快捷入口 -->
-    <UserQuickEntry
-      v-if="isLoggedIn"
-      :userName="userName"
-      roleName="家长"
-      :currentChildName="currentChildName"
-      :children="children"
-      @childChanged="onChildChanged"
-      @scrollTo="scrollToSection" />
-
     <!-- ===== 心理能量概况 ===== -->
     <!-- ===== 心理能量概况 ===== -->
     <view class="energy-overview" v-if="emiData">
     <view class="energy-overview" v-if="emiData">
       <view class="energy-badge">
       <view class="energy-badge">
@@ -316,7 +306,6 @@ import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -329,7 +318,7 @@ import { getVisibleSections, getFeaturedArticles, getEmiReport, getFamilyEnergyS
 import config from '../../config.js'
 import config from '../../config.js'
 
 
 export default {
 export default {
-  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner, AIFloatingAvatar },
+  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner, AIFloatingAvatar },
   data() {
   data() {
     return {
     return {
       isLoggedIn: false,
       isLoggedIn: false,

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

@@ -15,7 +15,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import PageBanner from '../components/PageBanner.vue'
+import PageBanner from '../../components/PageBanner.vue'
 import ProfileHeader from './components/ProfileHeader.vue'
 import ProfileHeader from './components/ProfileHeader.vue'
 import ProfileMenu from './components/ProfileMenu.vue'
 import ProfileMenu from './components/ProfileMenu.vue'
 
 

+ 366 - 0
cfc-frontend/pages/wealth-sub/checkin.vue

@@ -0,0 +1,366 @@
+<template>
+  <scroll-view class="container" scroll-y>
+    <!-- 能量提示 -->
+    <view class="energy-tip">
+      <text class="energy-tip-text">⚡ 每次打卡可获得 5 能量值</text>
+    </view>
+
+    <!-- 快速打卡 -->
+    <view class="quick-checkin">
+      <view class="section-title">
+        <text class="section-title-text">今日打卡</text>
+      </view>
+      <view class="checkin-types">
+        <view
+          v-for="(item, index) in checkinTypes"
+          :key="index"
+          class="checkin-btn"
+          :class="{ disabled: todayTimer }"
+          @click="doCheckin(item.type)">
+          <text class="checkin-icon">{{ item.icon }}</text>
+          <text class="checkin-label">{{ item.label }}</text>
+        </view>
+      </view>
+      <view v-if="todayCount > 0" class="today-count">
+        <text>今日已打卡 {{ todayCount }} 次</text>
+      </view>
+    </view>
+
+    <!-- 统计卡片 -->
+    <view class="stats-card">
+      <view class="stat-item">
+        <text class="stat-value">{{ stats.totalCheckins || 0 }}</text>
+        <text class="stat-label">总打卡</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-value">{{ stats.totalEnergy || 0 }}</text>
+        <text class="stat-label">总能量</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-value">{{ stats.dailyPlanCount || 0 }}</text>
+        <text class="stat-label">计划</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-value">{{ stats.savingCount || 0 }}</text>
+        <text class="stat-label">储蓄</text>
+      </view>
+    </view>
+
+    <!-- 打卡记录 -->
+    <view class="record-section">
+      <view class="section-title">
+        <text class="section-title-text">打卡记录</text>
+      </view>
+      <view v-if="checkins.length === 0" class="empty-state">
+        <text>暂无打卡记录,开始打卡吧!</text>
+      </view>
+      <view
+        v-for="(item, index) in checkins"
+        :key="index"
+        class="record-item"
+        @longpress="confirmDelete(item)">
+        <view class="record-left">
+          <text class="record-icon">{{ getTypeIcon(item.checkinType) }}</text>
+          <view class="record-info">
+            <text class="record-type">{{ getTypeLabel(item.checkinType) }}</text>
+            <text class="record-date">{{ formatDate(item.createdAt) }}</text>
+          </view>
+        </view>
+        <view class="record-right">
+          <text class="record-energy">+{{ item.earnedEnergy || 5 }}</text>
+        </view>
+      </view>
+    </view>
+  </scroll-view>
+</template>
+
+<script>
+import { getCheckinList, getCheckinStats, createCheckin, deleteCheckin } from '../../utils/api'
+
+export default {
+  data() {
+    return {
+      checkinTypes: [
+        { type: 'daily_plan', label: '记账规划', icon: '📋' },
+        { type: 'saving', label: '储蓄记录', icon: '💰' },
+        { type: 'expense', label: '消费记录', icon: '🛒' },
+        { type: 'review', label: '财务回顾', icon: '📊' }
+      ],
+      checkins: [],
+      stats: {},
+      todayTimer: null,
+      todayCount: 0
+    }
+  },
+
+  onShow() {
+    this.loadData()
+  },
+
+  methods: {
+    async loadData() {
+      try {
+        const res = await getCheckinList({})
+        if (res && res.code === 200) {
+          this.checkins = res.data || []
+        }
+        const statsRes = await getCheckinStats({})
+        if (statsRes && statsRes.code === 200) {
+          this.stats = statsRes.data || {}
+        }
+        this.calcTodayCount()
+      } catch (e) {
+        console.error('加载打卡数据失败', e)
+      }
+    },
+
+    calcTodayCount() {
+      const today = new Date()
+      const todayStr = today.toISOString().split('T')[0]
+      this.todayCount = this.checkins.filter(function(c) {
+        if (!c.createdAt) return false
+        return c.createdAt.indexOf(todayStr) === 0
+      }).length
+    },
+
+    async doCheckin(type) {
+      if (this.todayTimer) {
+        uni.showToast({ title: '操作太频繁,请稍后', icon: 'none' })
+        return
+      }
+      try {
+        const res = await createCheckin({
+          checkinType: type,
+          checkinDate: new Date().toISOString().split('T')[0],
+          earnedEnergy: 5
+        })
+        if (res && res.code === 200) {
+          uni.showToast({ title: '打卡成功!+5能量', icon: 'success' })
+          this.loadData()
+        } else {
+          uni.showToast({ title: res && res.message || '打卡失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络错误', icon: 'none' })
+      }
+    },
+
+    confirmDelete(item) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确定删除此打卡记录?',
+        success: function(res) {
+          if (res.confirm) {
+            self.deleteRecord(item)
+          }
+        }
+      })
+    },
+
+    async deleteRecord(item) {
+      try {
+        const res = await deleteCheckin(item.id)
+        if (res && res.code === 200) {
+          uni.showToast({ title: '已删除', icon: 'success' })
+          this.loadData()
+        }
+      } catch (e) {
+        console.error('删除失败', e)
+      }
+    },
+
+    getTypeIcon(type) {
+      const map = { daily_plan: '📋', saving: '💰', expense: '🛒', review: '📊' }
+      return map[type] || '📌'
+    },
+
+    getTypeLabel(type) {
+      const map = { daily_plan: '记账规划', saving: '储蓄记录', expense: '消费记录', review: '财务回顾' }
+      return map[type] || type
+    },
+
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      return dateStr.substring(0, 16).replace('T', ' ')
+    }
+  }
+}
+</script>
+
+<style>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 20rpx 30rpx 120rpx;
+}
+
+.energy-tip {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  border-radius: 16rpx;
+  padding: 20rpx 30rpx;
+  margin-bottom: 24rpx;
+}
+
+.energy-tip-text {
+  color: #fff;
+  font-size: 26rpx;
+  font-weight: 500;
+}
+
+.section-title {
+  margin-bottom: 20rpx;
+}
+
+.section-title-text {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.quick-checkin {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+}
+
+.checkin-types {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+}
+
+.checkin-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 150rpx;
+  height: 120rpx;
+  background: #FFF7ED;
+  border-radius: 16rpx;
+  border: 2rpx solid #FED7AA;
+}
+
+.checkin-btn.disabled {
+  opacity: 0.5;
+}
+
+.checkin-icon {
+  font-size: 40rpx;
+  margin-bottom: 8rpx;
+}
+
+.checkin-label {
+  font-size: 24rpx;
+  color: #666;
+}
+
+.today-count {
+  text-align: center;
+  margin-top: 16rpx;
+  font-size: 24rpx;
+  color: #999;
+}
+
+.stats-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.stat-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.stat-value {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #F97316;
+}
+
+.stat-label {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+
+.stat-divider {
+  width: 2rpx;
+  height: 60rpx;
+  background: #eee;
+}
+
+.record-section {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 48rpx 0;
+  color: #999;
+  font-size: 26rpx;
+}
+
+.record-item {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx 0;
+  border-bottom: 2rpx solid #f5f5f5;
+}
+
+.record-item:last-child {
+  border-bottom: none;
+}
+
+.record-left {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.record-icon {
+  font-size: 36rpx;
+  margin-right: 16rpx;
+}
+
+.record-info {
+  display: flex;
+  flex-direction: column;
+}
+
+.record-type {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+
+.record-date {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+
+.record-right {}
+
+.record-energy {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: 600;
+}
+</style>

+ 312 - 0
cfc-frontend/pages/wealth-sub/insurance-add.vue

@@ -0,0 +1,312 @@
+<template>
+  <scroll-view class="container" scroll-y>
+    <view class="form-card">
+      <view class="form-group">
+        <text class="form-label">保单名称 <text class="required">*</text></text>
+        <input class="form-input" v-model="form.policyName" placeholder="请输入保单名称" />
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">保险公司</text>
+        <input class="form-input" v-model="form.insuranceCompany" placeholder="请输入保险公司名称" />
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">保单类型</text>
+        <picker class="form-picker" :value="policyTypeIndex" :range="policyTypes" @change="onTypeChange">
+          <view class="picker-value">{{ policyTypes[policyTypeIndex] || '请选择保单类型' }}</view>
+        </picker>
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">被保人 <text class="required">*</text></text>
+        <input class="form-input" v-model="form.insuredPerson" placeholder="请输入被保人姓名" />
+      </view>
+
+      <view class="form-row">
+        <view class="form-group half">
+          <text class="form-label">保费</text>
+          <input class="form-input" v-model="form.premium" placeholder="0.00" type="digit" />
+        </view>
+        <view class="form-group half">
+          <text class="form-label">保额</text>
+          <input class="form-input" v-model="form.sumInsured" placeholder="0.00" type="digit" />
+        </view>
+      </view>
+
+      <view class="form-row">
+        <view class="form-group half">
+          <text class="form-label">生效日期</text>
+          <picker class="form-picker" mode="date" :value="form.startDate" @change="onStartDateChange">
+            <view class="picker-value">{{ form.startDate || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-group half">
+          <text class="form-label">到期日期</text>
+          <picker class="form-picker" mode="date" :value="form.endDate" @change="onEndDateChange">
+            <view class="picker-value">{{ form.endDate || '请选择' }}</view>
+          </picker>
+        </view>
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">续费类型</text>
+        <picker class="form-picker" :value="renewalTypeIndex" :range="renewalTypes" @change="onRenewalChange">
+          <view class="picker-value">{{ renewalTypes[renewalTypeIndex] || '请选择续费类型' }}</view>
+        </picker>
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">保单号</text>
+        <input class="form-input" v-model="form.policyNo" placeholder="请输入保单号" />
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">备注</text>
+        <textarea class="form-textarea" v-model="form.remark" placeholder="备注信息" />
+      </view>
+    </view>
+
+    <view class="submit-btn-wrap">
+      <button class="submit-btn" :loading="submitting" @click="submitForm">
+        {{ isEdit ? '更新保单' : '提交保存' }}
+      </button>
+    </view>
+  </scroll-view>
+</template>
+
+<script>
+import { getInsuranceDetail, createInsurance, updateInsurance } from '../../utils/api'
+
+export default {
+  data() {
+    return {
+      isEdit: false,
+      submitting: false,
+      policyTypes: ['医疗险', '重疾险', '意外险', '教育金', '养老险'],
+      policyTypeValues: ['medical', 'life', 'accident', 'education', 'endowment'],
+      policyTypeIndex: -1,
+      renewalTypes: ['年缴', '月缴', '趸交'],
+      renewalTypeValues: ['annual', 'monthly', 'single'],
+      renewalTypeIndex: -1,
+      form: {
+        policyName: '',
+        insuranceCompany: '',
+        policyType: '',
+        insuredPerson: '',
+        premium: '',
+        sumInsured: '',
+        startDate: '',
+        endDate: '',
+        renewalType: 'annual',
+        policyNo: '',
+        remark: ''
+      }
+    }
+  },
+
+  onLoad(options) {
+    if (options && options.id) {
+      this.isEdit = true
+      this.loadDetail(options.id)
+    }
+  },
+
+  methods: {
+    async loadDetail(id) {
+      try {
+        const res = await getInsuranceDetail(id)
+        if (res && res.code === 200 && res.data) {
+          const data = res.data
+          this.form.policyName = data.policyName || ''
+          this.form.insuranceCompany = data.insuranceCompany || ''
+          this.form.policyType = data.policyType || ''
+          this.form.insuredPerson = data.insuredPerson || ''
+          this.form.premium = data.premium ? String(data.premium) : ''
+          this.form.sumInsured = data.sumInsured ? String(data.sumInsured) : ''
+          this.form.startDate = data.startDate ? data.startDate.substring(0, 10) : ''
+          this.form.endDate = data.endDate ? data.endDate.substring(0, 10) : ''
+          this.form.renewalType = data.renewalType || 'annual'
+          this.form.policyNo = data.policyNo || ''
+          this.form.remark = data.remark || ''
+
+          this.policyTypeIndex = this.policyTypeValues.indexOf(data.policyType)
+          this.renewalTypeIndex = this.renewalTypeValues.indexOf(data.renewalType)
+
+          uni.setNavigationBarTitle({ title: '保单详情' })
+        }
+      } catch (e) {
+        console.error('加载保单详情失败', e)
+      }
+    },
+
+    onTypeChange(e) {
+      this.policyTypeIndex = parseInt(e.detail.value)
+      this.form.policyType = this.policyTypeValues[this.policyTypeIndex]
+    },
+
+    onRenewalChange(e) {
+      this.renewalTypeIndex = parseInt(e.detail.value)
+      this.form.renewalType = this.renewalTypeValues[this.renewalTypeIndex]
+    },
+
+    onStartDateChange(e) {
+      this.form.startDate = e.detail.value
+    },
+
+    onEndDateChange(e) {
+      this.form.endDate = e.detail.value
+    },
+
+    async submitForm() {
+      if (!this.form.policyName) {
+        uni.showToast({ title: '请输入保单名称', icon: 'none' })
+        return
+      }
+      if (!this.form.insuredPerson) {
+        uni.showToast({ title: '请输入被保人姓名', icon: 'none' })
+        return
+      }
+
+      this.submitting = true
+      try {
+        var payload = {
+          policyName: this.form.policyName,
+          insuranceCompany: this.form.insuranceCompany,
+          policyType: this.form.policyType,
+          insuredPerson: this.form.insuredPerson,
+          premium: this.form.premium ? parseFloat(this.form.premium) : null,
+          sumInsured: this.form.sumInsured ? parseFloat(this.form.sumInsured) : null,
+          startDate: this.form.startDate || null,
+          endDate: this.form.endDate || null,
+          renewalType: this.form.renewalType,
+          policyNo: this.form.policyNo,
+          remark: this.form.remark
+        }
+
+        var res
+        if (this.isEdit) {
+          payload.id = this.$mp.query && this.$mp.query.id
+          res = await updateInsurance(payload)
+        } else {
+          res = await createInsurance(payload)
+        }
+
+        if (res && res.code === 200) {
+          uni.showToast({ title: this.isEdit ? '更新成功' : '创建成功', icon: 'success' })
+          setTimeout(function() {
+            uni.navigateBack()
+          }, 1500)
+        } else {
+          uni.showToast({ title: res && res.message || '提交失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络错误', icon: 'none' })
+      } finally {
+        this.submitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 20rpx 30rpx 120rpx;
+}
+
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 24rpx;
+}
+
+.form-group {
+  margin-bottom: 28rpx;
+}
+
+.form-label {
+  display: block;
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+}
+
+.required {
+  color: #DC2626;
+}
+
+.form-input {
+  width: 100%;
+  height: 80rpx;
+  line-height: 80rpx;
+  background: #F5F7FA;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  box-sizing: border-box;
+}
+
+.form-picker {
+  width: 100%;
+  height: 80rpx;
+  line-height: 80rpx;
+  background: #F5F7FA;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  box-sizing: border-box;
+}
+
+.picker-value {
+  font-size: 28rpx;
+  color: #333;
+  line-height: 80rpx;
+}
+
+.form-textarea {
+  width: 100%;
+  height: 160rpx;
+  background: #F5F7FA;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  box-sizing: border-box;
+}
+
+.form-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+}
+
+.form-group.half {
+  width: 48%;
+}
+
+.submit-btn-wrap {
+  padding: 20rpx 0 40rpx;
+}
+
+.submit-btn {
+  width: 100%;
+  height: 88rpx;
+  line-height: 88rpx;
+  text-align: center;
+  background: #F97316;
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  border-radius: 44rpx;
+  border: none;
+}
+
+.submit-btn::after {
+  border: none;
+}
+</style>

+ 296 - 0
cfc-frontend/pages/wealth-sub/insurance-list.vue

@@ -0,0 +1,296 @@
+<template>
+  <scroll-view class="container" scroll-y>
+    <!-- 状态过滤 -->
+    <view class="filter-tabs">
+      <view
+        v-for="(tab, index) in filterTabs"
+        :key="index"
+        class="filter-tab"
+        :class="{ active: currentFilter === tab.value }"
+        @click="currentFilter = tab.value; loadPolicies()">
+        <text>{{ tab.label }}</text>
+      </view>
+    </view>
+
+    <!-- 保单列表 -->
+    <view v-if="policies.length === 0" class="empty-state">
+      <text class="empty-icon">📋</text>
+      <text class="empty-text">暂无保单</text>
+      <text class="empty-hint">点击下方按钮添加保单</text>
+    </view>
+
+    <view
+      v-for="(item, index) in policies"
+      :key="index"
+      class="policy-card"
+      @click="viewDetail(item)">
+      <view class="policy-header">
+        <text class="policy-name">{{ item.policyName }}</text>
+        <text class="policy-status" :class="item.status === 'active' ? 'status-active' : item.status === 'expired' ? 'status-expired' : 'status-cancelled'">{{ getStatusLabel(item.status) }}</text>
+      </view>
+      <view class="policy-body">
+        <view class="policy-row">
+          <text class="policy-label">保险公司</text>
+          <text class="policy-value">{{ item.insuranceCompany || '未知' }}</text>
+        </view>
+        <view class="policy-row">
+          <text class="policy-label">被保人</text>
+          <text class="policy-value">{{ item.insuredPerson }}</text>
+        </view>
+        <view class="policy-row">
+          <text class="policy-label">保费</text>
+          <text class="policy-value orange">{{ formatPriceWithSymbol(item.premium || 0) }}</text>
+        </view>
+        <view class="policy-row">
+          <text class="policy-label">到期日</text>
+          <text class="policy-value">{{ item.endDate ? item.endDate.substring(0, 10) : '长期' }}</text>
+        </view>
+      </view>
+      <view class="policy-footer" @click.stop="deletePolicy(item)">
+        <text class="delete-btn">删除</text>
+      </view>
+    </view>
+
+    <!-- 新增按钮 -->
+    <view class="add-btn-wrap">
+      <button class="add-btn" @click="addPolicy">+ 新增保单</button>
+    </view>
+  </scroll-view>
+</template>
+
+<script>
+import { getInsuranceList, deleteInsurance } from '../../utils/api'
+
+export default {
+  data() {
+    return {
+      filterTabs: [
+        { label: '全部', value: '' },
+        { label: '有效', value: 'active' },
+        { label: '已过期', value: 'expired' },
+        { label: '已取消', value: 'cancelled' }
+      ],
+      currentFilter: '',
+      policies: []
+    }
+  },
+
+  onShow() {
+    this.loadPolicies()
+  },
+
+  methods: {
+    async loadPolicies() {
+      try {
+        const res = await getInsuranceList({ status: this.currentFilter || undefined })
+        if (res && res.code === 200) {
+          this.policies = res.data || []
+        }
+      } catch (e) {
+        console.error('加载保单失败', e)
+      }
+    },
+
+    viewDetail(item) {
+      uni.navigateTo({
+        url: '/pages/wealth/insurance-add?id=' + item.id
+      })
+    },
+
+    addPolicy() {
+      uni.navigateTo({
+        url: '/pages/wealth/insurance-add'
+      })
+    },
+
+    deletePolicy(item) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确定删除该保单?',
+        success: async function(res) {
+          if (res.confirm) {
+            try {
+              const result = await deleteInsurance(item.id)
+              if (result && result.code === 200) {
+                uni.showToast({ title: '已删除', icon: 'success' })
+                self.loadPolicies()
+              }
+            } catch (e) {
+              console.error('删除失败', e)
+            }
+          }
+        }
+      })
+    },
+
+    getStatusClass(status) {
+      return status === 'active' ? 'status-active' : status === 'expired' ? 'status-expired' : 'status-cancelled'
+    },
+
+    getStatusLabel(status) {
+      return status === 'active' ? '有效' : status === 'expired' ? '已过期' : '已取消'
+    }
+  }
+}
+</script>
+
+<style>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 20rpx 30rpx 120rpx;
+}
+
+.filter-tabs {
+  display: flex;
+  flex-direction: row;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 12rpx;
+  margin-bottom: 24rpx;
+}
+
+.filter-tab {
+  flex: 1;
+  text-align: center;
+  padding: 12rpx 0;
+  font-size: 26rpx;
+  color: #666;
+  border-radius: 12rpx;
+}
+
+.filter-tab.active {
+  background: #F97316;
+  color: #fff;
+  font-weight: 600;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+}
+
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+
+.empty-text {
+  font-size: 30rpx;
+  color: #999;
+}
+
+.empty-hint {
+  font-size: 24rpx;
+  color: #ccc;
+  margin-top: 8rpx;
+}
+
+.policy-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+
+.policy-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+  padding-bottom: 16rpx;
+  border-bottom: 2rpx solid #f5f5f5;
+}
+
+.policy-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.policy-status {
+  font-size: 24rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 8rpx;
+}
+
+.status-active {
+  background: #DCFCE7;
+  color: #16A34A;
+}
+
+.status-expired {
+  background: #FEF2F2;
+  color: #DC2626;
+}
+
+.status-cancelled {
+  background: #F5F5F5;
+  color: #999;
+}
+
+.policy-body {
+  margin-bottom: 12rpx;
+}
+
+.policy-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  padding: 8rpx 0;
+}
+
+.policy-label {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.policy-value {
+  font-size: 26rpx;
+  color: #333;
+}
+
+.policy-value.orange {
+  color: #F97316;
+  font-weight: 600;
+}
+
+.policy-footer {
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-end;
+  padding-top: 12rpx;
+  border-top: 2rpx solid #f5f5f5;
+}
+
+.delete-btn {
+  font-size: 24rpx;
+  color: #DC2626;
+  padding: 8rpx 20rpx;
+}
+
+.add-btn-wrap {
+  padding: 20rpx 0 40rpx;
+}
+
+.add-btn {
+  width: 100%;
+  height: 88rpx;
+  line-height: 88rpx;
+  text-align: center;
+  background: #F97316;
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  border-radius: 44rpx;
+  border: none;
+}
+
+.add-btn::after {
+  border: none;
+}
+</style>

+ 2 - 11
cfc-frontend/pages/wisdom/index.vue

@@ -52,15 +52,7 @@
       :intimacyMap="intimacyMapForGraph"
       :intimacyMap="intimacyMapForGraph"
       @memberTap="goMemberDetail" />
       @memberTap="goMemberDetail" />
 
 
-    <!-- 6. 用户快捷入口 -->
-    <UserQuickEntry
-      v-if="isLoggedIn"
-      :userName="userName"
-      roleName="家长"
-      :currentChildName="activeChildName"
-      :children="children"
-      @childChanged="onChildChanged"
-      @scrollTo="scrollToSection" />
+
 
 
     <!-- 7. 功能入口 -->
     <!-- 7. 功能入口 -->
     <view class="func-section" v-if="sectionVisible('func_entries')">
     <view class="func-section" v-if="sectionVisible('func_entries')">
@@ -171,7 +163,6 @@ import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -181,7 +172,7 @@ import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles, getContactList, getHealthAlerts, getMilestones, getDanReportByChild } from '../../utils/api.js'
 import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles, getContactList, getHealthAlerts, getMilestones, getDanReportByChild } from '../../utils/api.js'
 
 
 export default {
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, AIFloatingAvatar },
+  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, AIFloatingAvatar },
   data() {
   data() {
     return {
     return {
       isLoggedIn: false,
       isLoggedIn: false,

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-5f0b1948cf6877facb3ccc0b7d0f45156a2c5f7d
+89f487475d6ee9b48d82e03c4590d94119e8c177

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

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

+ 1 - 1
cfc-web/package.json

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

+ 0 - 63
cfc-web/public/CHANGELOG.md

@@ -1,68 +1,6 @@
 # 更新日志 (Changelog)
 # 更新日志 (Changelog)
 
 
 此文件记录所有构建版本的变更。
 此文件记录所有构建版本的变更。
-## v1.0.310 (2026-07-14)
-
-### Bug 修复
-- resolve merge conflicts in package.json, CHANGELOG.md, .last_build_commit
-
-
-## v1.0.308 (2026-07-14)
-
-
-## v1.0.307 (2026-07-14)
-
-### Bug 修复
-- add missing closing brace for export default in Detail.vue — fixes build error
-
-
-## v1.0.301 (2026-07-14)
-
-### 其他
-- merge: integrate remote changes + supply hierarchy management
-- - cfc-web: supply-hierarchy management (new), supply-system/Detail update
-- - cfc-frontend: App.vue, invite/join.vue updates
-- - cfc-web: admin.js API, router updates
-- 
-
-
-## v1.0.299 (2026-07-14)
-
-### Bug 修复
-- resolve merge conflicts in cfc-web build files
-
-
-## v1.0.298 (2026-07-14)
-
-
-## v1.0.297 (2026-07-14)
-
-
-## v1.0.296 (2026-07-14)
-
-
-## v1.0.294 (2026-07-14)
-
-
-## v1.0.293 (2026-07-14)
-
-### Bug 修复
-- add missing Map import in SupplySystemService
-
-
-## v1.0.292 (2026-07-14)
-
-
-## v1.0.291 (2026-07-14)
-
-### 新功能
-- add dan_reports - assessment reports and processing scripts
-- 肠道菌群报告详情页 + 上传日期评分优化 + 身页面重构 + 首页角色分流
-
-
-## v1.0.290 (2026-07-14)
-
-
 ## v1.0.289 (2026-07-14)
 ## v1.0.289 (2026-07-14)
 
 
 ### 文档
 ### 文档
@@ -82,7 +20,6 @@
 ## v1.0.288 (2026-07-14)
 ## v1.0.288 (2026-07-14)
 
 
 
 
-
 ## v1.0.287 (2026-07-14)
 ## v1.0.287 (2026-07-14)
 
 
 ### 新功能
 ### 新功能

BIN
docs/参考资料/dan_reports/A1 儿童核心认知发展_李长龙_2025102513015067836.pdf


BIN
docs/参考资料/dan_reports/A2 核心素养_张馨月_202402221120441849.pdf


BIN
docs/参考资料/dan_reports/A2_B5青春期挑战_肖媛媛.pdf


+ 96 - 0
docs/参考资料/dan_reports/AGENTS.md

@@ -0,0 +1,96 @@
+# scripts/reports/ — 报告处理流水线
+
+PDF测评报告 → 结构化CSV数据的核心模块。
+
+## 流水线
+
+```bash
+python scripts/reports/update_materials.py  # 全量更新(推荐)
+```
+
+4步顺序执行:
+
+1. **process_all_pdfs.py** — 按内容识别报告类型,重命名文件(添加A1/B4等前缀)
+2. **extract_all_types.py** — 从所有PDF提取结构化数据 → `素材库/测评数据_优化提取.csv`
+3. **merge_all_types.py** — 按学生+30天窗口合并 → `素材库/学生汇总数据.csv`
+4. **delete_duplicate_a1.py** — 清理内容重复的A1报告
+
+## 文件清单
+
+| 文件 | 用途 |
+|------|------|
+| `update_materials.py` | 流水线编排,依次调用下面4个脚本 |
+| `process_all_pdfs.py` | PDF分类重命名(内容指纹检测 → 文件名前缀) |
+| `extract_all_types.py` | **核心**:8个类型提取器 + 图像分析(1266行) |
+| `merge_all_types.py` | 学生记录合并(姓名分组 + 30天窗口聚类) |
+| `type_detector.py` | 报告类型指纹检测(8类型 × 3-6指纹 + 排除规则 + 文件名加成) |
+| `delete_duplicate_a1.py` | 删除内容重复的A1报告 |
+| `dedup_reports.py` | 按姓名+生日+测评日期+报告类型去重(CSV+PDF) |
+| `extract_c1_names.py` | 从C1校园版PDF提取学生姓名并重命名 |
+| `read_pdf.py` | 调试工具:pdfplumber读取PDF内容到临时文件 |
+
+## extract_all_types.py 核心函数
+
+### 主入口
+
+- `extract_all_data(pdf_path, filename)` → 完整row dict
+  - 调用 `extract_text_from_pdf()` 获取全文+逐页文本
+  - 调用 `type_detector.detect_type()` 识别报告类型
+  - 分派到对应 `extract_*_data()` 提取器
+
+### 8个类型提取器
+
+| 函数 | 类型 | 维度 |
+|------|------|------|
+| `extract_a1_data()` | A1 儿童认知 | 6认知维度(感知觉/注意力/记忆力/推理能力/空间能力/加工速度) |
+| `extract_a2_data()` | A2 核心素养 | 认知3维 + 情绪4维 + 大五人格5维 + 社会关系9维 + 健康 |
+| `extract_b2_data()` | B2 儿童行为 | 自我概念6维 + 儿童行为8维 + 家庭环境10维 |
+| `extract_b3_data()` | B3 学习能力 | 执行功能3维 + 学习动机3维 + 学习策略3维 |
+| `extract_b4_data()` | B4 核心认知+成长 | 认知6维 + 自我概念6维 + 自驱力3维 + 成长型思维(0-100) |
+| `extract_b5_data()` | B5 青春期 | 情绪调节2维 + 学业压力5维 + 人际关系4维+19子维度 + 社交5维 + 睡眠/运动/网络 |
+| `extract_b6_data()` | B6 职业发展 | Holland兴趣6型 + 多元智能8维 + 职业价值观 |
+| `extract_c1_data()` | C1 校园版 | 认知6维 + 大五人格5维 + 自驱力3维 + 自我概念6维 |
+
+### 图像提取(B4成长型思维 + B5指南针)
+
+| 函数 | 用途 |
+|------|------|
+| `compute_growth_mindset_score_from_needle(doc)` | B4仪表盘指针检测 → 0-100连续分数 |
+| `_extract_1090x320_image(doc)` | 提取B4第12页1090×320 GIF仪表盘 |
+| `_measure_needle_position(img)` | 三游程分离法检测指针水平位置 |
+| `extract_growth_mindset_from_b4_vec(pdf_path)` | B4矢量复选框颜色 → 固定型/成长型倾向 |
+| `_extract_b5_compass_scores(doc)` | B5第9页指南针图19子维度分数 |
+| `_measure_fill_at_angle(img, cx, cy, angle)` | 沿角度射线测量填充半径 |
+
+### 通用函数
+
+| 函数 | 用途 |
+|------|------|
+| `extract_text_from_pdf(path, return_pages)` | PyMuPDF提取全文或逐页文本 |
+| `extract_name_from_filename(filename)` | 从文件名提取中文姓名 |
+| `extract_birthday_from_pdf(text, page_texts)` | 提取出生日期 |
+| `extract_score_and_percentile(text)` | 所有类型共用的总分/百分位提取 |
+| `extract_date_from_filename(filename)` | 从文件名提取测评日期YYYYMMDD |
+
+## type_detector.py
+
+内容指纹评分系统:8类型各有3-6个文本指纹(权重18-35),排除扣分规则(-20到-50),文件名信号加成(+50),阈值保护(<10回退文件名匹配)。
+
+```python
+from type_detector import detect_type
+report_type, scores = detect_type(text, filename)
+```
+
+## merge_all_types.py 合并逻辑
+
+1. 按姓名分组
+2. 30天窗口聚类(从第一个记录开始,≤30天归入同组)
+3. 同类型去重(保留文件名时间戳最新者)
+4. 跨类型合并成一条记录(认知维度优先A1 > B4 > C1)
+
+## 已知约束
+
+- 依赖 `PyMuPDF` (fitz)、`PIL` (Pillow)、`pdfplumber`(仅read_pdf.py)
+- B4成长型思维需要 Layout B(16页)格式才有仪表盘图像
+- B5指南针19子维度依赖第9页嵌入的PNG图像
+- `process_all_pdfs.py` 使用 `type_detector` 做内容检测(非旧版硬编码关键词)

BIN
docs/参考资料/dan_reports/B2 儿童自我与家庭教养_李长龙_2025102514271465449.pdf


BIN
docs/参考资料/dan_reports/B3 核心学习能力_张馨月_2026022417360882898.pdf


BIN
docs/参考资料/dan_reports/B4 核心认知能力和成长型思维_张馨月_2026022416334518042.pdf


BIN
docs/参考资料/dan_reports/B6 职业发展_张馨月_2026022415182958757.pdf


BIN
docs/参考资料/dan_reports/B6_生涯规划(高中版)_习常静.pdf


BIN
docs/参考资料/dan_reports/C1校园标准版(1-3年级)_胡家诚_2025121616362391951.pdf


BIN
docs/参考资料/dan_reports/C1校园标准版(4-12年级)_陈风然_2025121310362662392.pdf


+ 134 - 0
docs/参考资料/dan_reports/dedup_reports.py

@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+"""
+去重重复报告:找出 姓名+生日+测评日期+报告类型 完全相同的记录,
+保留格式规范的最新文件,删除旧格式的重复PDF。
+
+用法: python scripts/reports/dedup_reports.py
+"""
+import os, csv, re, shutil
+from collections import Counter
+
+PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+REPORT_DIR = os.path.join(PROJECT_DIR, '报告')
+CSV_PATH = os.path.join(PROJECT_DIR, '素材库', '测评数据_优化提取.csv')
+BACKUP_PATH = CSV_PATH.replace('.csv', '_before_dedup.csv')
+
+
+def is_proper_format(filename):
+    """判断是否为规范格式的文件名(保留此类)。
+    
+    规范格式:
+      - 带时间戳: "B4 核心认知能力和成长型思维_姓名_202403231742254603.pdf"
+      - 无时间戳: "B6_生涯规划(高中版)_姓名.pdf"
+      - 核心特征: 文件名不包含 "DAN素质能力测评报告" 等旧格式标记,
+                 且前缀类型与文件名其他内容之间不含旧命名痕迹
+    
+    旧格式: "A1_DAN素质能力测评报告_姓名*.pdf", 
+            "A2_B6生涯规划(高中版)_姓名*.pdf",
+            "B6_DAN素质能力测评报告-姓名*.pdf"
+    """
+    # 旧格式特征:包含 DAN素质能力测评报告 字样
+    if 'DAN素质能力测评报告' in filename:
+        return False
+    
+    # 旧格式特征:A2_B6 开头的旧生涯规划文件
+    if filename.startswith('A2_B6'):
+        return False
+    
+    # 规范格式特征:
+    # 1. {类型}{空格}{中文描述}_{姓名}_{时间戳(14-18位)}.pdf
+    if re.match(r'^[A-Z]\d\s+\S+?_\S+?_\d{14,}\.pdf$', filename):
+        return True
+    # 2. {类型}_{中文描述}_{姓名}.pdf(无时间戳,有中文描述字符≥3)
+    if re.match(r'^[A-Z]\d_\S{3,}_.+\.pdf$', filename):
+        return True
+    
+    return False
+
+
+def main():
+    # 1. 读取CSV
+    with open(CSV_PATH, 'r', encoding='utf-8-sig') as f:
+        reader = csv.DictReader(f)
+        rows = list(reader)
+        fieldnames = reader.fieldnames
+    
+    print(f'总记录数: {len(rows)}')
+    
+    # 2. 按 姓名+生日+测评日期+报告类型 分组
+    groups = {}
+    for r in rows:
+        k = (r.get('姓名', ''), r.get('生日', ''), r.get('测评日期', ''), r.get('报告类型', ''))
+        groups.setdefault(k, []).append(r)
+    
+    # 3. 找出重复组
+    dup_groups = {k: v for k, v in groups.items() if len(v) > 1}
+    print(f'重复组数: {len(dup_groups)}')
+    
+    to_delete_files = []
+    kept_rows = []
+    
+    for k, dup_rows in sorted(dup_groups.items(), key=lambda x: -len(x[1])):
+        name, birthday, date, rtype = k
+        print(f'\n组: {name} | {birthday} | {date} | {rtype} (x{len(dup_rows)})')
+        
+        # 按文件规范程度排序:规范格式优先保留
+        # 如果多个规范格式,保留文件名排序最后的(时间戳最新的)
+        proper = [r for r in dup_rows if is_proper_format(r.get('filename', ''))]
+        old = [r for r in dup_rows if not is_proper_format(r.get('filename', ''))]
+        
+        if proper:
+            # 保留规范格式中时间戳最新的
+            proper.sort(key=lambda r: r.get('filename', ''), reverse=True)
+            keep = proper[0]
+            delete = proper[1:] + old
+        else:
+            # 没有规范格式,保留文件名排序最后的
+            dup_rows.sort(key=lambda r: r.get('filename', ''), reverse=True)
+            keep = dup_rows[0]
+            delete = dup_rows[1:]
+        
+        print(f'  保留: {keep["filename"][:50]}')
+        kept_rows.append(keep)
+        
+        for r in delete:
+            fname = r['filename']
+            fpath = os.path.join(REPORT_DIR, fname)
+            print(f'  删除: {fname[:50]}')
+            if os.path.exists(fpath):
+                to_delete_files.append(fpath)
+    
+    # 4. 加入非重复的记录
+    non_dup = [rows for k, rows in groups.items() if len(rows) == 1]
+    for group in non_dup:
+        kept_rows.extend(group)
+    
+    print(f'\n保留记录: {len(kept_rows)}')
+    print(f'待删除文件: {len(to_delete_files)}')
+    
+    # 5. 备份原CSV
+    shutil.copy2(CSV_PATH, BACKUP_PATH)
+    print(f'备份CSV: {BACKUP_PATH}')
+    
+    # 6. 写入新CSV
+    with open(CSV_PATH, 'w', encoding='utf-8-sig', newline='') as f:
+        writer = csv.DictWriter(f, fieldnames=fieldnames)
+        writer.writeheader()
+        writer.writerows(kept_rows)
+    print(f'写入新CSV: {CSV_PATH} ({len(kept_rows)} 记录)')
+    
+    # 7. 删除旧文件
+    if to_delete_files:
+        print(f'\n删除 {len(to_delete_files)} 个重复PDF...')
+        for fpath in to_delete_files:
+            try:
+                os.remove(fpath)
+                print(f'  DELETED: {os.path.basename(fpath)}')
+            except Exception as e:
+                print(f'  FAILED: {os.path.basename(fpath)} - {e}')
+    
+    print(f'\n完成!共去重 {sum(len(v) for v in dup_groups.values()) - len(dup_groups)} 条记录,删除 {len(to_delete_files)} 个文件')
+
+
+if __name__ == '__main__':
+    main()

+ 64 - 0
docs/参考资料/dan_reports/delete_duplicate_a1.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+检查报告中所有A1_开头的文件,如果同目录中有内容完全相同的文件,则删掉这个A1_开头的文件
+"""
+
+import os
+import hashlib
+from pathlib import Path
+
+def file_hash(filepath):
+    """计算文件 SHA256 哈希"""
+    h = hashlib.sha256()
+    with open(filepath, 'rb') as f:
+        for chunk in iter(lambda: f.read(65536), b''):
+            h.update(chunk)
+    return h.hexdigest()
+
+def main():
+    # 自动计算项目根目录 (scripts/reports/ → 项目根)
+    report_dir = Path(__file__).parent.parent.parent / "报告"
+    
+    # 找出所有 A1_ 开头的文件
+    a1_files = list(report_dir.glob("A1_*.pdf"))
+    print(f"找到 {len(a1_files)} 个 A1_ 开头的文件")
+    
+    # 计算所有文件的哈希
+    all_files = list(report_dir.glob("*.pdf"))
+    hash_map = {}
+    
+    for f in all_files:
+        h = file_hash(str(f))
+        if h not in hash_map:
+            hash_map[h] = []
+        hash_map[h].append(f)
+    
+    # 找出重复的文件
+    deleted_count = 0
+    for h, files in hash_map.items():
+        if len(files) > 1:
+            a1_dups = [f for f in files if f.name.startswith("A1_")]
+            non_a1 = [f for f in files if not f.name.startswith("A1_")]
+            
+            if a1_dups and non_a1:
+                for f in a1_dups:
+                    try:
+                        f.unlink()
+                        print(f"[DELETED] {f.name} (与 {non_a1[0].name} 内容相同)")
+                        deleted_count += 1
+                    except Exception as e:
+                        print(f"[FAIL] {f.name}: {e}")
+            elif len(a1_dups) > 1:
+                keep = a1_dups[0]
+                for f in a1_dups[1:]:
+                    try:
+                        f.unlink()
+                        print(f"[DELETED] {f.name} (与 {keep.name} 内容相同)")
+                        deleted_count += 1
+                    except Exception as e:
+                        print(f"[FAIL] {f.name}: {e}")
+    print(f"\n总共删除了 {deleted_count} 个重复的 A1_ 文件")
+
+if __name__ == "__main__":
+    main()

+ 1266 - 0
docs/参考资料/dan_reports/extract_all_types.py

@@ -0,0 +1,1266 @@
+# -*- coding: utf-8 -*-
+"""
+完善素材库 - 处理所有报告类型
+提取A1, A2, B2, B3, B4, B6, C1报告数据
+"""
+import fitz
+import re
+import os
+import csv
+from type_detector import detect_type
+import math
+import io
+from PIL import Image
+
+def extract_text_from_pdf(pdf_path, return_pages=False):
+    """使用PyMuPDF提取文本"""
+    try:
+        doc = fitz.open(pdf_path)
+        
+        if return_pages:
+            page_texts = []
+            for page in doc:
+                page_texts.append(page.get_text())
+            doc.close()
+            return page_texts
+        
+        text = ""
+        for page in doc:
+            text += page.get_text()
+        doc.close()
+        return text
+    except Exception as e:
+        print(f"Error extracting text from {pdf_path}: {e}")
+        return "" if not return_pages else []
+
+def extract_name_from_filename(filename):
+    """从文件名提取姓名 - 改进版"""
+    name = filename.replace('.pdf', '')
+    
+    # 优先尝试从文件名中提取姓名
+    # 注意:只用下划线分割,不用连字符(-),避免把"(1-3年级)"切碎
+    parts = re.split(r'_', name)
+    
+    # 先找包含中文的部分(姓名通常含中文)
+    chinese_parts = []
+    for p in parts:
+        if re.search(r'[\u4e00-\u9fff]{2,}', p):
+            # 排除包含关键词的
+            keywords = ['儿童', '核心', '认知', '发展', 'DAN', '测评', '自我', '综合', 
+                       '家庭', '职业', '校园', '准备', '素养', '人格', '思维', '能力',
+                       '青少年', '素质', '成长', '学习', '动机', '兴趣', '规划',
+                       '认知能力', '成长型', '多元', '智能', '教养', '亲子', '欺凌',
+                       '心理', '教育', '教师', '反馈', '报告', '班级', '校级',
+                       '加工速度', '注意力', '记忆力', '推理', '空间', '知觉',
+                       'B5', 'B6', 'C1', '标准版', '专业版', '高中段']
+            p_clean = re.sub(r'[A-Za-z0-9\-()]', '', p)  # 去掉字母数字和连字符/括号
+            if p_clean and not any(kw in p for kw in keywords):
+                chinese_parts.append(p_clean)
+    
+    if chinese_parts:
+        # 取第一个/最长的中文名;等长时优先取不含特殊字符的
+        max_len = max(len(c) for c in chinese_parts)
+        candidates = [c for c in chinese_parts if len(c) == max_len]
+        # 等长时取最不像描述文本的(包含 级/挑战/版/段 这类字眼的靠后)
+        if len(candidates) > 1:
+            bad_markers = ['级', '挑战', '版', '段', '测', '试', '报', '告']
+            def badness(s):
+                return sum(1 for ch in bad_markers if ch in s)
+            candidates.sort(key=lambda s: (badness(s), chinese_parts.index(s)))
+        return candidates[0]
+    
+    # 备选1: 对含连字符(-)的part再按-拆分(兼容旧版DAN格式如"素质能力测评-小明2024-10-069193")
+    sub_chinese = []
+    for p in parts:
+        if '-' not in p:
+            continue
+        sub_parts = re.split(r'-', p)
+        for sp in sub_parts:
+            if re.search(r'[\u4e00-\u9fff]{2,}', sp):
+                sp_clean = re.sub(r'[A-Za-z0-9\-()]', '', sp)
+                if sp_clean and not any(kw in sp for kw in keywords):
+                    sub_chinese.append(sp_clean)
+    if sub_chinese:
+        max_len = max(len(c) for c in sub_chinese)
+        candidates = [c for c in sub_chinese if len(c) == max_len]
+        if len(candidates) > 1:
+            bad_markers = ['级', '挑战', '版', '段', '测', '试', '报', '告']
+            candidates.sort(key=lambda s: (sum(1 for ch in bad_markers if ch in s), sub_chinese.index(s)))
+        return candidates[0]
+    
+    # 备选2: 按关键词过滤整块part
+    keywords = ['儿童', '核心', '认知', '发展', 'A1', 'A2', 'B2', 'B3', 'B4', 'B5', 'B6', 
+                'DAN', '测评', '自我', '综合', '家庭', '职业', '校园', '准备', '素养', 
+                '人格', '思维', '能力', '青少年', '素质', '成长', '学习', '动机', '兴趣',
+                '规划', '加工速度', '注意力', '记忆力', '推理', '空间', '知觉']
+    for p in parts:
+        if not p:
+            continue
+        if re.match(r'^\d{8,}$', p):
+            continue
+        if any(kw == p or (p.startswith(kw) and len(p) > len(kw)) for kw in keywords):
+            continue
+        if any(kw in p for kw in keywords):
+            continue
+        if len(p) >= 2 and not re.match(r'^\d+$', p):
+            return p.strip()
+    return parts[0] if parts else name
+
+def extract_birthday_from_pdf(text, page_texts=None):
+    """从PDF中提取生日"""
+    if page_texts and len(page_texts) > 1:
+        page2_text = page_texts[1]
+    else:
+        page2_text = text
+    
+    patterns = [
+        r'出生日期[::\s]*(\d{4}[-/年]\d{1,2}[-/月]\d{1,2})',
+        r'(\d{4}[-/年]\d{1,2}[-/月]\d{1,2})',
+    ]
+    
+    for pattern in patterns:
+        match = re.search(pattern, page2_text)
+        if match:
+            date_str = match.group(1)
+            date_str = date_str.replace('年', '-').replace('月', '-').replace('/', '-')
+            if re.match(r'\d{4}-\d{2}-\d{2}', date_str):
+                return date_str
+    return ""
+
+def extract_score_and_percentile(text):
+    """通用提取总分和百分位(所有报告类型共用)
+    
+    PDF格式: "112\n总得分\nTotal Score\n79\n百分位(%)"
+    总分 = "总得分"前面的数字, 百分位 = "总得分"和"百分位"之间的数字
+    """
+    result = {}
+    
+    # 主格式: 数字\n总得分  (所有类型报告通用)
+    score_match = re.search(r'(\d+)\s*\n\s*总得分', text)
+    if score_match:
+        result['总分'] = score_match.group(1)
+    
+    # 百分位: 总得分...数字...百分位
+    pct_match = re.search(r'总得分.*?(\d+)\s*百分位', text, re.DOTALL)
+    if pct_match:
+        result['百分位'] = pct_match.group(1)
+    
+    # 后备1: 旧版 "总分" 格式  
+    if '总分' not in result:
+        total_match = re.search(r'总分[^\d]*(\d+)', text)
+        if total_match:
+            result['总分'] = total_match.group(1)
+    
+    # 后备2: 旧版 "百分位" 格式
+    if '百分位' not in result:
+        pct_match2 = re.search(r'百分位[^\d]*(\d+)', text)
+        if pct_match2:
+            result['百分位'] = pct_match2.group(1)
+    
+    return result
+
+def extract_a1_data(text, page_texts=None):
+    """提取A1报告数据 - 儿童核心认知发展(6个认知维度)"""
+    result = {}
+    
+    result.update(extract_score_and_percentile(text))
+    
+    # 6个核心认知维度: 感知觉, 注意力, 记忆力, 推理能力, 空间能力, 加工速度
+    cognitive_dims = ['感知觉', '注意力', '记忆力', '推理能力', '空间能力', '加工速度']
+    
+    # 方法1: 从summary页面提取百分位 (格式: "感知觉 |  Perception\n百分位(%)\n14")
+    for dim in cognitive_dims:
+        pct_match = re.search(
+            rf'{re.escape(dim)}\s*\|\s*\w+\s*\n\s*百分位(%)\s*\n\s*(\d+)',
+            text
+        )
+        if pct_match:
+            result[f'{dim}_pct'] = pct_match.group(1)
+    
+    # 方法2: 从detail页面提取原始分和百分位 (格式: "我的感知觉得分 41  | 14%")
+    for dim in cognitive_dims:
+        detail_match = re.search(
+            rf'我的{re.escape(dim)}得分\s*(\d+)\s*\|\s*(\d+)%',
+            text
+        )
+        if detail_match:
+            result[f'{dim}_score'] = detail_match.group(1)
+            if f'{dim}_pct' not in result:
+                result[f'{dim}_pct'] = detail_match.group(2)
+    
+    # 方法3: 后备 - 直接搜索 "维度名\n百分位(%)\n数字" (某些PDF格式)
+    if len([k for k in result if k.endswith('_pct')]) < 6:
+        for dim in cognitive_dims:
+            if f'{dim}_pct' not in result:
+                fallback = re.search(
+                    rf'{re.escape(dim)}.*?百分位(%).*?(\d+)',
+                    text,
+                    re.DOTALL
+                )
+                if fallback:
+                    result[f'{dim}_pct'] = fallback.group(1)
+    
+    return result
+
+def extract_a2_data(text, page_texts=None):
+    """提取A2报告数据 - 核心素养16+(认知+人格+情绪+关系+健康)"""
+    result = {}
+    
+    # 总分和百分位 (通用格式)
+    result.update(extract_score_and_percentile(text))
+    
+    # ===== 认知维度 (3个: 感知觉, 记忆力, 注意力) =====
+    # 在summary页面中: "感知觉\n89%"
+    for dim in ['感知觉', '记忆力', '注意力']:
+        cog_match = re.search(rf'{re.escape(dim)}\s*\n\s*(\d+)%', text)
+        if cog_match:
+            result[f'{dim}_pct'] = cog_match.group(1)
+    
+    # ===== 情绪状态 (第5页/索引4) =====
+    if page_texts and len(page_texts) > 4:
+        page5_text = page_texts[4]
+        
+        # 方法1: 原始格式 "数字\nInferiority"
+        emotion_names = ['自卑-自信', '抑郁-安详', '焦虑-安详', '无力感-掌控感']
+        emotion_eng = ['Inferiority', 'Depression', 'Anxiety', 'Helpless']
+        
+        for eng, cn in zip(emotion_eng, emotion_names):
+            em_match = re.search(rf'(\d+(?:\.?\d+)?)\s*\n\s*{re.escape(eng)}', page5_text)
+            if em_match:
+                result[cn] = em_match.group(1)
+        
+        # 方法2: 旧格式 (后备)
+        if not any(k in result for k in emotion_names):
+            emotion_pattern = re.findall(r'(\d+)\s+10\s+(\d+\.?\d*)\s+(\d+\.?\d*)', page5_text)
+            if len(emotion_pattern) >= 1:
+                for i in range(min(len(emotion_pattern), 4)):
+                    result[emotion_names[i]] = emotion_pattern[i][-1]
+        
+        # 情绪总分: "在4个分测验中的总得分是 XX分"
+        total_em = re.search(r'在4个分测验中的总得分是\s*(\d+(?:\.?\d+)?)分', page5_text)
+        if total_em:
+            result['情绪总分'] = total_em.group(1)
+    
+    # ===== 大五人格 (第7页/索引6) =====
+    if page_texts and len(page_texts) > 6:
+        page7_text = page_texts[6]
+        
+        # 方法1: 直接匹配 "您在"XX"上的得分是 X.X 分"
+        big5_names = ['开放性', '宜人性', '责任心', '外倾性', '神经质']
+        for dim in big5_names:
+            b5_match = re.search(rf'您在[""「]{re.escape(dim)}[""」].*?得分是\s*(\d+\.?\d*)\s*分', page7_text)
+            if b5_match:
+                result[dim] = b5_match.group(1)
+        
+        # 方法2: 小数点回退 (旧方法)
+        if len([k for k in result if k in big5_names]) < 5:
+            decimal_pattern = re.findall(r'\b(\d+\.\d+)\b', page7_text)
+            valid_decimals = [d for d in decimal_pattern if 1.0 <= float(d) <= 10.0]
+            for i, dim_name in enumerate(big5_names):
+                if i < len(valid_decimals) and dim_name not in result:
+                    result[dim_name] = valid_decimals[i]
+    
+    # ===== 社会关系 (第9页/索引8) =====
+    if page_texts and len(page_texts) > 8:
+        page9_text = page_texts[8]
+        all_num_matches = re.findall(r'(\d+)\s*分', page9_text)
+        valid_nums = [n for n in all_num_matches if 1 <= int(n) <= 100]
+        
+        if len(valid_nums) >= 9:
+            # 页面9段落中数字按子维度分组(信赖→沟通→亲近), 每组内按母亲→父亲→同伴排列
+            # "信赖...37分,40分,34分;沟通上...32分,32分,25分;亲近上...15分,16分,17分"
+            result['与母亲信任'] = valid_nums[0]
+            result['与父亲信任'] = valid_nums[1]
+            result['与同伴信任'] = valid_nums[2]
+            result['与母亲沟通'] = valid_nums[3]
+            result['与父亲沟通'] = valid_nums[4]
+            result['与同伴沟通'] = valid_nums[5]
+            result['与母亲亲近'] = valid_nums[6]
+            result['与父亲亲近'] = valid_nums[7]
+            result['与同伴亲近'] = valid_nums[8]
+    
+    # ===== 身体健康 (第11页/索引10) =====
+    # 格式: "BMI:22kg/m²" "身高:156cm" "体重:53kg" "10小时/周" "9小时/每天"
+    if page_texts and len(page_texts) > 10:
+        page11_text = page_texts[10]
+        
+        # BMI格式: "BMI:22kg/m²"
+        bmi_match = re.search(r'BMI[::]*\s*(\d+(?:\.\d+)?)\s*kg', page11_text)
+        if bmi_match:
+            result['BMI'] = bmi_match.group(1)
+        
+        # 身高: "身高:156cm"
+        height_match = re.search(r'身高[::]*\s*(\d+)\s*cm', page11_text)
+        if height_match:
+            result['身高'] = height_match.group(1)
+        
+        # 体重: "体重:53kg"
+        weight_match = re.search(r'体重[::]*\s*(\d+(?:\.\d+)?)\s*kg', page11_text)
+        if weight_match:
+            result['体重'] = weight_match.group(1)
+        
+        # 睡眠: "10小时/周" 或 "9小时/每天" (先找周再找天)
+        sleep_week = re.search(r'(\d+)\s*小时\s*/\s*周', page11_text)
+        if sleep_week:
+            result['睡眠_小时'] = sleep_week.group(1)
+        else:
+            sleep_day = re.search(r'(\d+)\s*小时\s*/\s*每天', page11_text)
+            if sleep_day:
+                result['睡眠_小时'] = sleep_day.group(1)
+        
+        # 饮食: "9小时/每天"
+        diet = re.search(r'(\d+)\s*小时\s*/\s*每天', page11_text)
+        if diet and '饮食' not in result:
+            result['饮食_小时'] = diet.group(1)
+        
+        # 运动: 找"运动习惯"附近的"X小时/周"
+        exercise = re.search(r'运动.*?(\d+)\s*小时\s*/\s*周', page11_text, re.DOTALL)
+        if exercise:
+            result['运动_小时'] = exercise.group(1)
+    
+    return result
+
+def extract_b2_data(text, page_texts=None):
+    """提取B2报告数据 - 儿童自我与家庭教养"""
+    result = {}
+    result.update(extract_score_and_percentile(text))
+    
+    # ===== 自我概念 (6维度) =====
+    # 格式: "行为表现 9分 能力与学校表现 8分 躯体外貌 9分..."
+    # 注意: 实际PDF中维度的准确名称
+    self_concept_dims = [
+        ('行为表现', '行为表现'),
+        ('能力与学校表现', '能力与学校'),  # PDF用"能力与学校表现"
+        ('躯体外貌', '躯体外貌'),
+        ('情绪状态', '情绪状态'),
+        ('合群', '合群'),
+        ('幸福与满足', '幸福与满足'),
+    ]
+    for dim_pdf, dim_out in self_concept_dims:
+        sc_match = re.search(rf'{re.escape(dim_pdf)}\s*(\d+)\s*分', text)
+        if sc_match:
+            result[f'自我概念_{dim_out}'] = sc_match.group(1)
+        else:
+            # 后备: 包含匹配
+            sc_match2 = re.search(rf'{re.escape(dim_pdf)}.*?(\d+)\s*分', text[:2000])
+            if sc_match2:
+                result[f'自我概念_{dim_out}'] = sc_match2.group(1)
+    
+    # ===== 儿童行为 (8维度) =====
+    # 格式: "Conduct problems  1分 优秀  情绪问题 Emotional state  2分 良好..."
+    behavior_dims_cn = ['品行问题', '情绪问题', '学习问题', '社交问题', '生活习惯', '多动倾向', '刻板行为', '拖延行为']
+    for dim in behavior_dims_cn:
+        b_match = re.search(rf'{re.escape(dim)}\s*(\d+)\s*分', text)
+        if b_match:
+            result[f'行为_{dim}'] = b_match.group(1)
+    
+    # 备选: 英文关键词
+    behavior_eng = [
+        (r'Conduct problems\s+(\d+)分', '品行问题'),
+        (r'Emotional state\s+(\d+)分', '情绪问题'),
+        (r'Learning situation\s+(\d+)分', '学习问题'),
+        (r'Social situation\s+(\d+)分', '社交问题'),
+        (r'Habits.*?customs\s+(\d+)分', '生活习惯'),
+        (r'Hyperactivity\s+(\d+)分', '多动倾向'),
+        (r'Stereotypic\s+(\d+)分', '刻板行为'),
+        (r'Procrastination\s+(\d+)分', '拖延行为'),
+    ]
+    for eng_pattern, cn_name in behavior_eng:
+        if f'行为_{cn_name}' not in result:
+            eng_match = re.search(eng_pattern, text)
+            if eng_match:
+                result[f'行为_{cn_name}'] = eng_match.group(1)
+    
+    # ===== 家庭环境 (10维度) =====
+    # 格式: "亲密9分 情感表达7分 和谐5分..."
+    family_dims = ['亲密', '情感表达', '和谐', '独立性', '成就向导', 
+                   '文化氛围', '娱乐活动', '道德观念', '家务安排', '家庭规则']
+    # 方法1: 逐个匹配 "维度名+数字+分"
+    for dim in family_dims:
+        f_match = re.search(rf'{re.escape(dim)}\s*(\d+)\s*分', text)
+        if f_match:
+            result[f'家庭_{dim}'] = f_match.group(1)
+    
+    # 方法2: 在家庭环境相关页面批量提取数字序列
+    if len([k for k in result if k.startswith('家庭_')]) < 5:
+        if page_texts and len(page_texts) > 5:
+            page6_text = page_texts[5]
+            family_nums = re.findall(r'(\d+)\s*分', page6_text)
+            valid = [n for n in family_nums if 1 <= int(n) <= 15]
+            if len(valid) >= 10:
+                for i, dim in enumerate(family_dims):
+                    if f'家庭_{dim}' not in result and i < len(valid):
+                        result[f'家庭_{dim}'] = valid[i]
+    
+    return result
+
+def extract_b3_data(text, page_texts=None):
+    """提取B3报告数据 - 核心学习能力(执行功能+学习动机+学习策略)"""
+    result = {}
+    result.update(extract_score_and_percentile(text))
+    
+    # ===== 执行功能 (3维度, 百分位) =====
+    # 格式: "抑制控制 94%  工作记忆 89%  认知灵活性 60%"
+    exec_dims = ['抑制控制', '工作记忆', '认知灵活性']
+    for dim in exec_dims:
+        ef_match = re.search(rf'{re.escape(dim)}\s*(\d+)%', text)
+        if ef_match:
+            result[f'{dim}_pct'] = ef_match.group(1)
+    
+    # ===== 学习动机 (3维度, 十分制) =====
+    # 格式: "深层动机\n我的得分:8分" 或 "深层动机 8分"
+    motivation_dims = ['深层动机', '表面动机', '自我效能感']
+    for dim in motivation_dims:
+        # 方法1: "深层动机...我的得分:8分"
+        lm_match = re.search(
+            rf'{re.escape(dim)}.*?我的得分[::]\s*(\d+(?:\.\d+)?)',
+            text,
+            re.DOTALL
+        )
+        if lm_match:
+            result[dim] = lm_match.group(1)
+        else:
+            # 方法2: "深层动机 8分"
+            lm_match2 = re.search(rf'{re.escape(dim)}\s*(\d+(?:\.\d+)?)\s*分', text)
+            if lm_match2:
+                result[dim] = lm_match2.group(1)
+    
+    # ===== 学习策略 (3维度, 十分制) =====
+    # 格式: "深层方法与策略...我的得分:6.8分"
+    strategy_dims = ['深层方法与策略', '表面方法与策略', '学习自我调节']
+    for dim in strategy_dims:
+        ls_match = re.search(
+            rf'{re.escape(dim)}.*?我的得分[::]\s*(\d+(?:\.\d+)?)',
+            text,
+            re.DOTALL
+        )
+        if ls_match:
+            result[dim] = ls_match.group(1)
+        else:
+            ls_match2 = re.search(rf'{re.escape(dim)}\s*(\d+(?:\.\d+)?)\s*分', text)
+            if ls_match2:
+                result[dim] = ls_match2.group(1)
+    
+    return result
+
+# ── 成长型思维仪表盘连续分数(基于表盘指针位置)──
+# 方法:从Layout B B4报告第12页提取1090×320半圆形仪表盘图像,
+# 测量三角指针的指向角度,通过atan2映射到0-100连续分数。
+# 校准基准:陈嘉梁(成长型倾向)指针角度≈-6.68°→score=44.0
+
+
+def _extract_1090x320_image(doc):
+    """从PDF文档中提取1090×320仪表盘图像的RGB像素数据。
+    
+    Returns:
+        PIL.Image对象 (RGB模式) 或 None
+    """
+    try:
+        page = doc[12]
+        for img_info in page.get_images():
+            xref = img_info[0]
+            data = doc.extract_image(xref)
+            w, h = data['width'], data['height']
+            if (w, h) == (1090, 320):
+                img = Image.open(io.BytesIO(data['image']))
+                if img.mode != 'RGB':
+                    img = img.convert('RGB')
+                return img
+    except Exception:
+        pass
+    return None
+
+
+def _measure_needle_position(img, center_x=544):
+    """从1090×320仪表盘图像中检测三角指针位置。
+    
+    原理:指针在图像中表现为左右刻度弧线之间的第三段彩色区域。
+    从上往下扫描(y=60-220),找到指针与弧线分离最清晰的y坐标,
+    返回指针横截面中心位置。
+    
+    Returns:
+        (needle_x, needle_y, needle_width) 或 (None, None, None)
+    """
+    W, H = img.size
+    
+    for y in range(60, 220):
+        runs = []
+        in_run = False
+        run_start = 0
+        x_min = max(0, center_x - 250)
+        x_max = min(W, center_x + 250)
+        for x in range(x_min, x_max):
+            p = img.getpixel((x, y))
+            if p[0] > 30 or p[1] > 30 or p[2] > 30:
+                if not in_run:
+                    run_start = x
+                    in_run = True
+            else:
+                if in_run:
+                    runs.append((run_start, x - 1))
+                    in_run = False
+        if in_run:
+            runs.append((run_start, x_max - 1))
+        
+        big = [(s, e) for s, e in runs if e - s + 1 > 5]
+        
+        if len(big) == 3:
+            left_end = big[0][1]
+            needle = big[1]
+            right_start = big[2][0]
+            if left_end < needle[0] and needle[1] < right_start:
+                needle_mid = (needle[0] + needle[1]) / 2.0
+                needle_width = needle[1] - needle[0] + 1
+                return needle_mid, y, needle_width
+    
+    return None, None, None
+
+
+def compute_growth_mindset_score_from_needle(doc):
+    """从PDF文档的仪表盘图像计算成长型思维连续分数。
+    
+    三步流程:
+    1. 提取1090×320仪表盘图像
+    2. 检测三角指针位置(3段分离法)
+    3. 计算指针角度并映射到0-100连续分数
+    
+    校准基准:
+    - 陈嘉梁(成长型倾向44分): 指针中心偏移center_x=-18.5px, y=97
+      → atan2(-18.5, 193.8-97) = atan2(-18.5, 96.8) = -10.8°
+      → score = (-10.8 + 90) / 180 * 100 = 44.0
+    
+    Returns:
+        float 分数(0-100) 或 None(无法检测)
+    """
+    img = _extract_1090x320_image(doc)
+    if img is None:
+        return None
+    
+    # 固定参数:表盘中心x坐标和指针旋转中心y坐标(从陈嘉梁标定)
+    center_x = 544
+    center_y = 193.8  # 指针旋转中心y坐标(由陈嘉梁44分标定)
+    
+    needle_x, needle_y, _ = _measure_needle_position(img, center_x)
+    if needle_x is None:
+        return None
+    
+    dx = needle_x - center_x
+    dy = center_y - needle_y
+    if dy <= 0:
+        return None
+    
+    angle_rad = math.atan2(dx, dy)
+    angle_deg = math.degrees(angle_rad)
+    
+    # 映射到0-100: 角度-90°(左) → 0分, 0°(上) → 50分, 90°(右) → 100分
+    score = (angle_deg + 90) / 180.0 * 100.0
+    return max(0.0, min(100.0, round(score, 1)))
+
+
+def extract_growth_mindset_from_b4_vec(pdf_path):
+    """分析B4 PDF矢量图形提取成长型思维倾向(Layout B第12页的复选框+条形图颜色)
+    
+    研究发现: Layout B (16页) 的113份文件中:
+    - 59份: 第12页为纯说明性内容, 无矢量复选框 → 无成长型思维数据
+    - 43份: 复选框在LEFT(固定型)teal高亮, RIGHT(成长型)黄色 → 固定型倾向
+    - 11份: 复选框在LEFT黄色, RIGHT teal高亮 → 成长型倾向
+    
+    参数:
+        pdf_path: PDF文件路径
+    
+    返回: '固定型倾向' / '成长型倾向' / '' (无数据)
+    """
+    try:
+        doc = fitz.open(pdf_path)
+        # 仅Layout B (16页) 可能有生长型思维矢量图形
+        if len(doc) != 16:
+            doc.close()
+            return ''
+        
+        page = doc[12]  # 成长型思维页面 (Layout B)
+        paths = page.get_drawings()
+        
+        # 1) 检测teal色条形图宽度 (3种: 103/135/151)
+        bar_width = 0
+        for path in paths:
+            fill = path.get('fill')
+            rect = path.get('rect')
+            if not fill:
+                continue
+            w = rect[2] - rect[0]
+            if w > 80 and fill[0] > 0.1 and fill[0] < 0.3 and fill[1] > 0.6:
+                bar_width = round(w)
+                break
+        
+        if bar_width == 0 or bar_width == 135:
+            # 135 = 没有复选框的Layout变体 → 无个性化数据
+            doc.close()
+            return ''
+        
+        # 2) 检测复选框颜色 (48x17的矩形)
+        # LEFT侧 (x~79) = 固定型思维特征; RIGHT侧 (x~467) = 成长型思维特征
+        teal_on_left = 0
+        teal_on_right = 0
+        for path in paths:
+            fill = path.get('fill')
+            rect = path.get('rect')
+            if not fill:
+                continue
+            w = round(rect[2] - rect[0])
+            h = round(rect[3] - rect[1])
+            if w == 48 and h == 17:
+                if rect[0] < 200:  # LEFT = 固定型
+                    if fill[0] > 0.1 and fill[0] < 0.3 and fill[1] > 0.6:
+                        teal_on_left += 1
+                elif rect[0] > 300:  # RIGHT = 成长型
+                    if fill[0] > 0.1 and fill[0] < 0.3 and fill[1] > 0.6:
+                        teal_on_right += 1
+        
+        # 判断: 合并条形图宽度和复选框证据
+        result = ''
+        if teal_on_left >= 3 and teal_on_right == 0:
+            result = '固定型倾向'
+        elif teal_on_right >= 3 and teal_on_left == 0:
+            result = '成长型倾向'
+        elif bar_width == 151:
+            result = '固定型倾向'
+        elif bar_width == 103:
+            result = '成长型倾向'
+        
+        doc.close()
+        return result
+    except Exception as e:
+        print(f"Error extracting growth mindset from {pdf_path}: {e}")
+        return ''
+
+
+def extract_b4_data(text, page_texts=None, pdf_path=None):
+    """提取B4报告数据 - 核心认知能力+自我概念+自驱力+成长型思维"""
+    result = {}
+    result.update(extract_score_and_percentile(text))
+    
+    # ===== 核心认知维度 (同A1, 6维度) =====
+    cognitive_dims = ['感知觉', '注意力', '记忆力', '推理能力', '空间能力', '加工速度']
+    
+    # 格式2 (优先): summary认知模型页面 "感知觉 |  Perception\n...描述...\n百分位(%)\n14"
+    # 该页面百分位数据最准确, 不会被detail页面的刻度标签(0%-100%)干扰
+    for dim in cognitive_dims:
+        pct_match = re.search(
+            rf'{re.escape(dim)}\s*\|\s*\w+[\s\S]*?百分位(%)\s*\n\s*(\d+)',
+            text
+        )
+        if pct_match:
+            result[f'{dim}_pct'] = pct_match.group(1)
+    
+    # 格式1 (后补): detail页面, 仅当格式2未提取到时使用
+    #  格式1a: "我的感知觉得分\n17|18%" 或 带乱码分隔符 "17ح18%"
+    #  格式1b: "我的加工速度得分\n33 | 33 | 88%" (原始分|标准分|百分位)
+    #  使用两步法避免回溯bug: 先截取"得分"后文本段, 再分别搜score和pct
+    #  注意: 11页B4只有推理能力/空间能力/加工速度3维有detail页, 所以只补这3维
+    for dim in cognitive_dims:
+        detail_section = re.search(
+            rf'我的{re.escape(dim)}得分(.{{1,300}})',
+            text, re.DOTALL
+        )
+        if detail_section:
+            section_text = detail_section.group(1)
+            # 提取第一个数字%作为百分位 (首个出现的是实际分数)
+            pct_match = re.search(r'(\d+)%', section_text)
+            if pct_match:
+                if f'{dim}_pct' not in result:
+                    result[f'{dim}_pct'] = pct_match.group(1)
+            # 提取第一个数字作为原始分
+            score_match = re.search(r'(\d+)', section_text)
+            if score_match:
+                result[f'{dim}_score'] = score_match.group(1)
+    
+    # ===== 自我概念 (6维度, 0-10分制, 第11页柱状图) =====
+    # B4第11页(索引10)是横向柱状图, 每维度一个横条, 0-10刻度
+    # 分数印制在横条右端上方, font_size≈9.6(区别于坐标轴标签6.2和维度名称8.6)
+    # 使用positioned text提取, 禁止使用%匹配(会拿到认知页的百分位值)
+    self_concept_out = ['行为表现', '能力与学校', '躯体外貌', '情绪状态', '合群', '幸福与满足']
+    if pdf_path:
+        try:
+            doc = fitz.open(pdf_path)
+            n_pages = len(doc)
+            # 两种B4格式:
+            #   16页格式: self-concept在第11页(索引10), 分数font_size≈9.6
+            #   11页格式: self-concept在第6页(索引5), 分数font_size≈6.4
+            candidates = [(10, 8, 11), (5, 6, 8)] if n_pages > 12 else [(5, 6, 8), (10, 8, 11)]
+            found = False
+            for page_idx, fs_min, fs_max in candidates:
+                if page_idx >= n_pages:
+                    continue
+                page = doc[page_idx]
+                blocks = page.get_text('dict')['blocks']
+                scores = []
+                for b in blocks:
+                    if b['type'] == 0:
+                        for line in b['lines']:
+                            for span in line['spans']:
+                                span_text = span['text'].strip()
+                                if not span_text:
+                                    continue
+                                size = span['size']
+                                x = span['bbox'][0]
+                                if (fs_min <= size <= fs_max and x > 250
+                                    and span_text.isdigit() and 0 <= int(span_text) <= 10):
+                                    scores.append((span['bbox'][1], int(span_text)))
+                scores.sort(key=lambda s: s[0])
+                if len(scores) >= 6:
+                    for i, dim in enumerate(self_concept_out):
+                        result[f'自我概念_{dim}'] = str(scores[i][1])
+                    found = True
+                    break
+            if not found:
+                # 第三次尝试: 在所有页面中搜索"自我概念"字样, 提取相邻数字
+                for pi in range(n_pages):
+                    page_text = doc[pi].get_text()
+                    if '自我概念' in page_text and '行为表现' in page_text:
+                        blocks = doc[pi].get_text('dict')['blocks']
+                        scores = []
+                        for b in blocks:
+                            if b['type'] == 0:
+                                for line in b['lines']:
+                                    for span in line['spans']:
+                                        st = span['text'].strip()
+                                        if not st:
+                                            continue
+                                        size = span['size']
+                                        x = span['bbox'][0]
+                                        if (5 <= size <= 12 and x > 250
+                                            and st.isdigit() and 0 <= int(st) <= 10):
+                                            scores.append((span['bbox'][1], int(st)))
+                        scores.sort(key=lambda s: s[0])
+                        if len(scores) >= 6:
+                            for i, dim in enumerate(self_concept_out):
+                                result[f'自我概念_{dim}'] = str(scores[i][1])
+                        break
+            doc.close()
+        except Exception as e:
+            print(f"  [WARN] B4 self-concept extraction failed: {e}")
+    
+    # ===== 自驱力 =====
+    all_scores = re.findall(r'我的得分[::](\d+\.?\d*)', text)
+    if len(all_scores) >= 3:
+        result['自主性'] = all_scores[0]
+        result['胜任感'] = all_scores[1]
+        result['归属感'] = all_scores[2]
+    elif len(all_scores) >= 1:
+        result['自主性'] = all_scores[0]
+    
+    if '自主性' not in result:
+        parts = text.split('Autonomy')
+        if len(parts) >= 3:
+            match = re.search(r'(\d+\.?\d*)', parts[2])
+            if match:
+                result['自主性'] = match.group(1)
+    
+    if '胜任感' not in result:
+        parts = text.split('Competence')
+        if len(parts) >= 3:
+            match = re.search(r'(\d+\.?\d*)', parts[3] if len(parts) > 3 else parts[2])
+            if match:
+                result['胜任感'] = match.group(1)
+    
+    if '归属感' not in result:
+        parts = text.split('Relatedness')
+        if len(parts) >= 3:
+            match = re.search(r'(\d+\.?\d*)', parts[3] if len(parts) > 3 else parts[2])
+            if match:
+                result['归属感'] = match.group(1)
+    
+    # ===== 成长型思维(连续分数) =====
+    # 从1090×320仪表盘图像检测指针位置,映射到0-100刻度
+    # Layout A (11页) 的B4文件无仪表盘图像 → 留空
+    result['成长型思维'] = ''
+    
+    if pdf_path:
+        try:
+            doc = fitz.open(pdf_path)
+            score = compute_growth_mindset_score_from_needle(doc)
+            doc.close()
+            if score is not None:
+                result['成长型思维'] = score
+        except Exception:
+            pass
+    
+    return result
+
+def extract_b6_data(text, page_texts=None):
+    """提取B6报告数据 - 职业发展(兴趣+能力+价值观)"""
+    result = {}
+    result.update(extract_score_and_percentile(text))
+    
+    # ===== 职业兴趣 (Holland 6类型) =====
+    # 格式: "NO.1: 艺术型 Artistic NO.2: 社会型 Social 7分 5分"
+    interest_dims = {
+        '艺术型': '兴趣_艺术型',
+        '社会型': '兴趣_社会型',
+        '事业型': '兴趣_事业型',
+        '常规型': '兴趣_常规型',
+        '现实型': '兴趣_现实型',
+        '研究型': '兴趣_研究型',
+    }
+    
+    # 方法1: NO.X模式
+    for cn_name, field_name in interest_dims.items():
+        # Match "X型 ... X分" where X分 follows the interest type name
+        h_match = re.search(
+            rf'{re.escape(cn_name)}\s+\w+\s+(\d+)\s*分',
+            text
+        )
+        if h_match:
+            result[field_name] = h_match.group(1)
+    
+    # ===== 多元智能 (8能力) =====
+    # 格式: "内省能力 INTRAPERSONAL 8分  人际关系能力 INTERPERSONAL 4分"
+    ability_dims = {
+        '内省能力': '能力_内省',
+        '空间能力': '能力_空间',
+        '音乐能力': '能力_音乐',
+        '人际关系能力': '能力_人际关系',
+        '自然能力': '能力_自然',
+        '身体运动能力': '能力_身体运动',
+        '语言能力': '能力_语言',
+        '逻辑数学能力': '能力_逻辑数学',
+    }
+    
+    for cn_name, field_name in ability_dims.items():
+        ab_match = re.search(
+            rf'{re.escape(cn_name)}\s+\w+\s+(\d+)\s*分',
+            text
+        )
+        if ab_match:
+            result[field_name] = ab_match.group(1)
+        else:
+            # 更简单: "内省能力 8分"
+            ab_match2 = re.search(
+                rf'{re.escape(cn_name)}\s*(\d+)\s*分',
+                text
+            )
+            if ab_match2:
+                # Make sure it's not matching a multi-digit number in a different context
+                score = ab_match2.group(1)
+                if 1 <= int(score) <= 15:
+                    result[field_name] = score
+    
+    # ===== 职业价值观 (可选) =====
+    # 格式: 在"我的职业价值观"区域有因子得分如 "8.11\n声望地位" "5.41\n美的追求"
+    # 取第一个因子得分作为代表性分数(限1-15分)
+    if '我的职业价值观' in text:
+        m = re.search(r'我的职业价值观.*?(\d+(?:\.\d+)?)', text, re.DOTALL)
+        if m:
+            val = float(m.group(1))
+            if 1 <= val <= 15:
+                result['职业价值观'] = m.group(1)
+    # 后备: "最高分"附近的明确数值
+    if '职业价值观' not in result:
+        m = re.search(r'职业价值观.*?最高分[^\d]*?(\d+(?:\.\d+)?)', text, re.DOTALL)
+        if m:
+            val = float(m.group(1))
+            if 1 <= val <= 15:
+                result['职业价值观'] = m.group(1)
+    
+    return result
+
+def extract_c1_data(text, page_texts=None):
+    """提取C1报告数据 - 校园版综合(认知+人格+自驱力+自我概念)"""
+    result = {}
+    result.update(extract_score_and_percentile(text))
+    
+    # ===== 核心认知维度 (同A1, 6维度) =====
+    cognitive_dims = ['感知觉', '注意力', '记忆力', '推理能力', '空间能力', '加工速度']
+    
+    # 格式1: "我的感知觉得分 41  | 14%"
+    for dim in cognitive_dims:
+        detail_match = re.search(
+            rf'我的{re.escape(dim)}得分\s*(\d+)\s*\|\s*(\d+)%',
+            text
+        )
+        if detail_match:
+            result[f'{dim}_score'] = detail_match.group(1)
+            result[f'{dim}_pct'] = detail_match.group(2)
+    
+    # 格式2: summary页面 "感知觉 | Perception\n百分位(%)\n14"
+    for dim in cognitive_dims:
+        if f'{dim}_pct' not in result:
+            pct_match = re.search(
+                rf'{re.escape(dim)}\s*\|\s*\w+\s*\n\s*百分位(%)\s*\n\s*(\d+)',
+                text
+            )
+            if pct_match:
+                result[f'{dim}_pct'] = pct_match.group(1)
+    
+    # ===== 大五人格 (同A2) =====
+    big5_names = ['开放性', '宜人性', '责任心', '外倾性', '神经质']
+    if page_texts:
+        for pt in page_texts:
+            for dim in big5_names:
+                if dim not in result:
+                    b5_match = re.search(rf'您在[""「]{re.escape(dim)}[""」].*?得分是\s*(\d+(?:\.\d+)?)\s*分', pt)
+                    if b5_match:
+                        result[dim] = b5_match.group(1)
+    
+    # ===== 自驱力 (同B4) =====
+    all_scores = re.findall(r'我的得分[::](\d+\.?\d*)', text)
+    if len(all_scores) >= 3:
+        result['自主性'] = all_scores[0]
+        result['胜任感'] = all_scores[1]
+        result['归属感'] = all_scores[2]
+    elif len(all_scores) >= 1:
+        result['自主性'] = all_scores[0]
+    
+    # ===== 自我概念 (同B4) =====
+    self_concept_start = text.find('SELF-CONCEPT')
+    if self_concept_start >= 0:
+        before_section = text[:self_concept_start]
+        pct_matches = re.findall(r'(\d+)%', before_section)
+        
+        sc_dims = ['行为表现', '能力与学校', '躯体外貌', '情绪状态', '合群', '幸福与满足']
+        if len(pct_matches) >= 12:
+            high_pcts = pct_matches[-12:]
+            for i, dim in enumerate(sc_dims):
+                idx = i * 2 + 1
+                if idx < len(high_pcts):
+                    result[f'自我概念_{dim}'] = high_pcts[idx]
+        elif len(pct_matches) >= 6:
+            for i, dim in enumerate(sc_dims):
+                if i < len(pct_matches):
+                    result[f'自我概念_{dim}'] = pct_matches[-(6-i)]
+    
+    return result
+
+# ===== B5 人际关系指南针图子维度提取 =====
+# 校准: 每个象限的 fill_avg → score 线性映射系数
+# v2: 扫描起点r=150跳过中心色块; 因子由extract_b5_data传入per-student值
+_B5_QUAD_FACTORS = {
+    '母亲关系': 0.006974,
+    '父亲关系': 0.005834,
+    '师生关系': 0.006820,
+    '同伴关系': 0.005049,
+}
+_B5_SUB_DIMS = {
+    '母亲关系': ['控制压迫', '情感疏离', '价值冲突', '双重标准', '过度期待'],
+    '父亲关系': ['控制压迫', '情感疏离', '价值冲突', '双重标准', '过度期待'],
+    '师生关系': ['回避型', '敌对型', '高控制型', '关系疏离型'],
+    '同伴关系': ['回避型', '被排斥型', '攻击型', '边缘型', '特殊因素型'],
+}
+# 象限角度范围 (图像坐标系: 0°=右, 90°=下, 180°=左, 270°=上)
+_B5_QUAD_ANGLES = {
+    '母亲关系': (185, 265),
+    '父亲关系': (140, 178),
+    '师生关系': (275, 350),
+    '同伴关系': (2, 42),
+}
+_B5_CENTER = (857, 758)
+
+def _measure_fill_at_angle(img, cx, cy, angle_deg):
+    """沿指定角度测量填充范围(像素半径)。
+    从r=150开始扫描以跳过中心色块(避免环线阻断),
+    25px间隙容忍用于跨越填充分区内的环线。"""
+    angle = math.radians(angle_deg)
+    w, h = img.size
+    max_r = int(min(w-cx, cx, cy, h-cy)) - 2
+    fill_end = None
+    gap = 0
+    for r in range(150, max_r, 1):
+        x = int(cx + r * math.cos(angle))
+        y = int(cy + r * math.sin(angle))
+        if not (0 <= x < w and 0 <= y < h):
+            break
+        px = img.getpixel((x, y))
+        is_white = all(c > 230 for c in px[:3])
+        is_gray = all(155 < c < 195 for c in px[:3])
+        is_black = all(c < 20 for c in px[:3])
+        is_colored = not (is_white or is_gray or is_black)
+        if is_colored:
+            fill_end = r
+            gap = 0
+        elif fill_end is not None:
+            gap += 1
+            if gap > 25:
+                break
+    return fill_end
+
+def _extract_b5_compass_scores(doc, quad_factors=None):
+    """从B5 PDF文档提取人际关系指南针19个子维度分数。
+    返回 { '人际_母亲关系_控制压迫': score, ... }"""
+    result = {}
+    page = doc[8]  # 第9页
+    imgs = page.get_images()
+    if len(imgs) < 4:
+        return result
+    base = doc.extract_image(imgs[3][0])
+    img = Image.open(io.BytesIO(base['image']))
+    cx, cy = _B5_CENTER
+    
+    # 从页面文本提取per-student factor(如果未提供)
+    if quad_factors is None:
+        text = page.get_text()
+        lines = text.split('\n')
+        text_scores = {}
+        for rel in ['母亲关系', '父亲关系', '师生关系', '同伴关系']:
+            for i, line in enumerate(lines):
+                if line.strip() == rel:
+                    for j in range(i+1, min(i+5, len(lines))):
+                        try:
+                            text_scores[rel] = float(lines[j].strip())
+                            break
+                        except ValueError:
+                            pass
+                    break
+        # 计算quad平均fill和per-student factor
+        quad_factors = {}
+        for qname, (a1, a2) in _B5_QUAD_ANGLES.items():
+            fills = []
+            for a in range(a1, a2+1):
+                fe = _measure_fill_at_angle(img, cx, cy, a)
+                if fe:
+                    fills.append(fe)
+            avg = sum(fills)/len(fills) if fills else 1
+            txt = text_scores.get(qname, 3.0)
+            quad_factors[qname] = txt / avg if avg > 0 else _B5_QUAD_FACTORS[qname]
+    
+    for quad_name, (a_start, a_end) in _B5_QUAD_ANGLES.items():
+        dims = _B5_SUB_DIMS[quad_name]
+        n = len(dims)
+        step = (a_end - a_start) / (n + 1)
+        factor = quad_factors.get(quad_name, _B5_QUAD_FACTORS[quad_name])
+        
+        for i, dim_name in enumerate(dims):
+            angle = a_start + (i + 1) * step
+            fills = []
+            for offset in [-2, 0, 2]:
+                fe = _measure_fill_at_angle(img, cx, cy, angle + offset)
+                if fe is not None:
+                    fills.append(fe)
+            avg_fill = sum(fills) / len(fills) if fills else 0
+            score = min(5.0, max(1.0, round(avg_fill * factor, 1)))
+            result[f'人际_{quad_name}_{dim_name}'] = score
+    return result
+
+def extract_b5_data(text, page_texts=None, pdf_path=None):
+    """提取B5报告数据 - 青春期挑战(情绪调节+学业压力+人际关系+社交+睡眠+运动+网络依赖)"""
+    result = {}
+    result.update(extract_score_and_percentile(text))
+    
+    # ===== 情绪调节策略 (2维度) =====
+    er_match = re.search(r'认知重评.*?Cognitive Reappraisal\s*(\d+(?:\.\d+)?)\s*分', text, re.DOTALL)
+    if not er_match:
+        er_match = re.search(r'认知重评\s*(\d+(?:\.\d+)?)\s*分', text)
+    if er_match:
+        result['认知重评'] = er_match.group(1)
+    
+    es_match = re.search(r'表达抑制.*?Expressive Suppression\s*(\d+(?:\.\d+)?)\s*分', text, re.DOTALL)
+    if not es_match:
+        es_match = re.search(r'表达抑制\s*(\d+(?:\.\d+)?)\s*分', text)
+    if es_match:
+        result['表达抑制'] = es_match.group(1)
+    
+    # ===== 学业压力源 (5维度) =====
+    stress_section = re.search(r'学业压力源[\s\S]*?(?=人际关系问题)', text)
+    if not stress_section:
+        stress_section = re.search(r'学业压力[\s\S]*?(?=社交能力|睡眠)', text)
+    stress_text = stress_section.group() if stress_section else text
+    stress_dims = ['学业负担', '家庭期望', '师生关系', '自我期望', '同伴竞争']
+    for dim in stress_dims:
+        m = re.search(re.escape(dim) + r'\s*\n\s*(\d+(?:\.\d+)?)', stress_text)
+        if m:
+            result[f'学业压力_{dim}'] = m.group(1)
+    
+    # ===== 人际关系问题 (4维度) =====
+    rel_section = re.search(r'人际关系问题[\s\S]*?(?=社交能力)', text)
+    rel_text = rel_section.group() if rel_section else text
+    relation_dims = ['母亲关系', '父亲关系', '师生关系', '同伴关系']
+    # 数据区格式: "维度名\n描述句。\n分数" 需跳过intro段中的同名词
+    for dim in relation_dims:
+        m = re.search(re.escape(dim) + r'\n[^\n]*。\n(\d+(?:\.\d+)?)', rel_text)
+        if m:
+            result[f'人际_{dim}'] = m.group(1)
+    
+    # ===== 社交能力 (5维度) =====
+    soc_section = re.search(r'社交能力[\s\S]*?(?=睡眠)', text)
+    soc_text = soc_section.group() if soc_section else text
+    soc_m = re.search(r'同龄人平均水平\s*\n\s*(\d+(?:\.\d+)?)\s*\n\s*(\d+(?:\.\d+)?)\s*\n\s*(\d+(?:\.\d+)?)\s*\n\s*(\d+(?:\.\d+)?)\s*\n\s*(\d+(?:\.\d+)?)', soc_text)
+    if soc_m:
+        soc_dims = ['主动交往', '情感支持', '情感表达', '表达影响', '冲突解决']
+        for i, dim in enumerate(soc_dims):
+            result[f'社交_{dim}'] = soc_m.group(i+1)
+    
+    # ===== 睡眠数据 =====
+    sleep_hours = re.search(r'睡眠时长[^。]*?(\d+(?:\.\d+)?)\s*小时', text)
+    if sleep_hours:
+        result['睡眠_时长'] = sleep_hours.group(1)
+    
+    sleep_eff = re.search(r'睡眠效率[^。]*?(\d+(?:\.\d+)?)%', text)
+    if sleep_eff:
+        result['睡眠_效率'] = sleep_eff.group(1)
+    
+    sleep_lat = re.search(r'入睡时间[^。]*?(\d+)-(\d+)分钟', text)
+    if sleep_lat:
+        result['入睡时间_min'] = sleep_lat.group(1)
+    
+    # 睡眠定性维度:取维度名后第三行(值行)
+    sleep_qual = {
+        '主观睡眠质量': '睡眠_主观质量',
+        '日间功能障碍': '睡眠_日间功能',
+        '催眠药物': '睡眠_催眠药物',
+    }
+    for kw, col in sleep_qual.items():
+        m = re.search(re.escape(kw) + r'\n[^\n]*\n[^\n]*\n([^\n]+)', text)
+        if m:
+            result[col] = m.group(1).strip()
+    
+    sleep_disorder = re.search(r'睡眠障碍[^。]*?(\S+)', text)
+    if sleep_disorder:
+        result['睡眠_障碍'] = sleep_disorder.group(1).strip()
+    
+    # ===== 运动数据 =====
+    ex_names = ['久坐', '步行', '中等强度', '高强度']
+    for ex_name in ex_names:
+        m = re.search(re.escape(ex_name) + r'\n(\d+)\n天/周\n(.+)', text)
+        if m:
+            result[f'运动_{ex_name}_频率'] = m.group(1)
+            result[f'运动_{ex_name}_时长'] = m.group(2).strip()
+    
+    # ===== 网络依赖 =====
+    internet_total = re.search(r'(\d+)分\s*\n\s*强迫使用', text)
+    if internet_total:
+        result['网络依赖_总分'] = internet_total.group(1)
+    
+    # 用上方的分数匹配子维度(分数在维度名之上)
+    # 格式: "44分\n强迫使用\n...43分\n时间管理问题\n...35分\n戒断反应"
+    internet_sub = re.findall(r'(\d+)分\s*\n\s*(强迫使用|时间管理问题|戒断反应)', text)
+    for score, dim_name in internet_sub:
+        col_map = {'强迫使用': '网络依赖_强迫使用', '时间管理问题': '网络依赖_时间管理', '戒断反应': '网络依赖_戒断反应'}
+        if dim_name in col_map:
+            result[col_map[dim_name]] = score
+    
+    # ===== 人际关系指南针19个子维度(第9页图像) =====
+    if pdf_path and os.path.exists(pdf_path):
+        try:
+            doc = fitz.open(pdf_path)
+            if len(doc) > 8:
+                compass_scores = _extract_b5_compass_scores(doc)
+                result.update(compass_scores)
+            doc.close()
+        except Exception:
+            pass  # 图像提取失败不影响已有数据
+    
+    return result
+
+def determine_report_type(filename):
+    """根据文件名判断报告类型(后备方案,内容指纹优先)"""
+    # 已被 type_detector.detect_type() 替代
+    # 保留作为纯后备(当文本层损坏导致 detect_type 回退时调用 fallback_by_filename)
+    from type_detector import fallback_by_filename
+    return fallback_by_filename(filename)
+
+def extract_date_from_filename(filename):
+    """从文件名中提取测评日期 YYYYMMDD"""
+    # 匹配 8位连续数字日期 (如 20250320)
+    m = re.search(r'(\d{8})', filename)
+    if m:
+        return m.group(1)
+    # 匹配 YYYY-MM-DD 或 YYYY_MM_DD 格式(文件名中常见带连字符)
+    m = re.search(r'(\d{4})[-_](\d{2})[-_](\d{2})', filename)
+    if m:
+        return m.group(1) + m.group(2) + m.group(3)
+    return ''
+
+def extract_all_data(pdf_path, filename):
+    """提取所有类型报告的数据"""
+    text = extract_text_from_pdf(pdf_path)
+    page_texts = extract_text_from_pdf(pdf_path, return_pages=True)
+    
+    name = extract_name_from_filename(filename)
+    birthday = extract_birthday_from_pdf(text, page_texts)
+    report_type, _ = detect_type(text, filename)
+    
+    # 基础信息
+    row = {
+        'filename': filename,
+        '姓名': name,
+        '生日': birthday,
+        '报告类型': report_type,
+        '测评日期': extract_date_from_filename(filename),
+    }
+    
+    # 根据类型提取数据
+    if report_type == 'A1':
+        data = extract_a1_data(text, page_texts)
+    elif report_type == 'A2':
+        data = extract_a2_data(text, page_texts)
+    elif report_type == 'B2':
+        data = extract_b2_data(text, page_texts)
+    elif report_type == 'B3':
+        data = extract_b3_data(text, page_texts)
+    elif report_type == 'B4':
+        data = extract_b4_data(text, page_texts, pdf_path)
+    elif report_type == 'B5':
+        data = extract_b5_data(text, page_texts, pdf_path)
+    elif report_type == 'B6':
+        data = extract_b6_data(text, page_texts)
+    elif report_type == 'C1':
+        data = extract_c1_data(text, page_texts)
+    else:
+        data = {}
+    
+    row.update(data)
+    return row
+
+def main():
+    # 自动检测项目根目录 (脚本在 scripts/ 下)
+    script_dir = os.path.dirname(os.path.abspath(__file__))
+    project_dir = os.path.dirname(os.path.dirname(script_dir))  # 项目根目录(上溯两级:scripts/reports/ → scripts/ → 根)
+    reports_dir = os.path.join(project_dir, "报告")
+    output_file = os.path.join(project_dir, "素材库", "测评数据_优化提取.csv")
+    
+    # 获取所有PDF文件
+    pdf_files = []
+    for f in os.listdir(reports_dir):
+        if f.endswith('.pdf') and '案例' not in f and '示例' not in f and 'eStatement' not in f:
+            pdf_files.append((os.path.join(reports_dir, f), f))
+    
+    print(f"Found {len(pdf_files)} PDF files")
+    
+    all_results = []
+    type_counts = {}
+    
+    for i, (pdf_path, filename) in enumerate(pdf_files):
+        if i % 100 == 0:
+            print(f"Processing {i}/{len(pdf_files)}...")
+        
+        try:
+            row = extract_all_data(pdf_path, filename)
+            rt = row.get('报告类型', 'Unknown')
+            type_counts[rt] = type_counts.get(rt, 0) + 1
+            all_results.append(row)
+        except Exception as e:
+            print(f"Error processing {filename}: {e}")
+    
+    print(f"\n=== Report Type Counts ===")
+    for rt, count in sorted(type_counts.items()):
+        print(f"{rt}: {count}")
+    
+    # 写入CSV
+    if all_results:
+        all_keys = set()
+        for row in all_results:
+            all_keys.update(row.keys())
+        
+        columns = ['filename', '姓名', '生日', '报告类型', '测评日期', '总分', '百分位']
+        for k in sorted(all_keys):
+            if k not in columns:
+                columns.append(k)
+        
+        with open(output_file, 'w', encoding='utf-8-sig', newline='') as f:
+            writer = csv.DictWriter(f, fieldnames=columns)
+            writer.writeheader()
+            writer.writerows(all_results)
+        
+        print(f"\nWritten {len(all_results)} records to {output_file}")
+
+if __name__ == "__main__":
+    main()

+ 153 - 0
docs/参考资料/dan_reports/extract_c1_names.py

@@ -0,0 +1,153 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+从 PDF 中深度提取姓名,专门处理 C1 校园版文件
+尝试方法:
+1. 从 PDF 文本内容提取(第一页前 20 行)
+2. 从 PDF 元数据提取
+3. 从 PDF 表格/结构化文本提取
+"""
+
+import os
+import re
+import pdfplumber
+from pathlib import Path
+
+def extract_name_from_pdf_deep(pdf_path):
+    """深度提取 PDF 中的姓名"""
+    try:
+        with pdfplumber.open(pdf_path) as pdf:
+            if not pdf.pages:
+                return None
+            
+            # 方法 1: 从第一页文本提取
+            text = pdf.pages[0].extract_text() or ""
+            lines = text.split('\n')
+            
+            # 在前 20 行查找姓名
+            for i, line in enumerate(lines[:20]):
+                line = line.strip()
+                if not line or len(line) < 2 or len(line) > 15:
+                    continue
+                
+                # 模式 1: 纯中文姓名 (2-4 个汉字)
+                if re.match(r'^[\u4e00-\u9fa5]{2,4}$', line):
+                    # 排除常见非姓名词汇
+                    exclude_words = ['报告', '测评', '学生', '姓名', '日期', '学校', '年级', '班级']
+                    if line not in exclude_words:
+                        return line
+                
+                # 模式 2: "姓名:XXX" 或 "姓名:XXX"
+                match = re.search(r'姓名 [::]\s*([\u4e00-\u9fa5]{2,4})', line)
+                if match:
+                    return match.group(1)
+                
+                # 模式 3: "学生:XXX"
+                match = re.search(r'学生 [::]\s*([\u4e00-\u9fa5]{2,4})', line)
+                if match:
+                    return match.group(1)
+                
+                # 模式 4: 英文名 (3-8 个字母)
+                match = re.search(r'\b([A-Z][a-z]{2,8})\b', line)
+                if match:
+                    name = match.group(1)
+                    exclude_names = ['Total', 'Score', 'Date', 'ID', 'Page', 'Report']
+                    if name not in exclude_names and name not in line.upper():
+                        return name
+            
+            # 方法 2: 尝试从 PDF 元数据提取
+            try:
+                metadata = pdf.metadata
+                if metadata:
+                    # 检查 Author, Creator, Subject 等字段
+                    for key in ['Author', 'Subject', 'Title', 'Creator']:
+                        if key in metadata and metadata[key]:
+                            val = str(metadata[key])
+                            # 尝试匹配中文姓名
+                            match = re.search(r'([\u4e00-\u9fa5]{2,4})', val)
+                            if match:
+                                return match.group(1)
+            except:
+                pass
+            
+            # 方法 3: 尝试解析表格或结构化数据
+            for page in pdf.pages[:2]:
+                tables = page.extract_tables()
+                for table in tables:
+                    for row in table[:5]:  # 前 5 行
+                        if row:
+                            for cell in row:
+                                if cell:
+                                    cell = str(cell).strip()
+                                    if re.match(r'^[\u4e00-\u9fa5]{2,4}$', cell):
+                                        exclude_words = ['报告', '测评', '学生', '姓名']
+                                        if cell not in exclude_words:
+                                            return cell
+            
+            return None
+            
+    except Exception as e:
+        return None
+
+def main():
+    # 自动计算项目根目录 (scripts/reports/ → 项目根)
+    report_dir = Path(__file__).parent.parent.parent / "报告"
+    
+    # 获取所有 C1_时间戳.pdf 格式的文件
+    c1_files = []
+    for f in report_dir.glob("C1_*.pdf"):
+        # 匹配 C1_纯数字.pdf (缺少姓名的)
+        if re.match(r'^C1_\d{16,}\.pdf$', f.name):
+            c1_files.append(f)
+    
+    print(f"找到 {len(c1_files)} 个缺少姓名的 C1 文件")
+    
+    renamed = 0
+    failed = 0
+    not_found = 0
+    
+    for file_path in c1_files:
+        filename = file_path.name
+        # 提取时间戳
+        timestamp_match = re.search(r'(\d{16,})', filename)
+        timestamp = timestamp_match.group(1) if timestamp_match else None
+        
+        if not timestamp:
+            print(f"[WARN] 无法提取时间戳:{filename}")
+            failed += 1
+            continue
+        
+        # 尝试从 PDF 提取姓名
+        name = extract_name_from_pdf_deep(str(file_path))
+        
+        if name:
+            new_filename = f"C1_{name}_{timestamp}.pdf"
+            new_path = file_path.parent / new_filename
+            
+            # 检查是否已存在
+            if new_path.exists() and new_path.resolve() != file_path.resolve():
+                base, ext = os.path.splitext(new_filename)
+                counter = 1
+                while new_path.exists():
+                    new_filename = f"{base}_{counter}{ext}"
+                    new_path = file_path.parent / new_filename
+                    counter += 1
+            
+            try:
+                file_path.rename(new_path)
+                print(f"[OK] {filename} -> {new_filename}")
+                renamed += 1
+            except Exception as e:
+                print(f"[FAIL] {filename}: {e}")
+                failed += 1
+        else:
+            print(f"[SKIP] 无法提取姓名:{filename}")
+            not_found += 1
+    
+    print(f"\n完成:")
+    print(f"  重命名:{renamed}")
+    print(f"  未找到姓名:{not_found}")
+    print(f"  失败:{failed}")
+
+if __name__ == "__main__":
+    main()

+ 308 - 0
docs/参考资料/dan_reports/merge_all_types.py

@@ -0,0 +1,308 @@
+# -*- coding: utf-8 -*-
+"""
+学生报告合并脚本 - 按姓名+1月内窗口分组
+- 同一学生30天内测评的记录合并为一次测评
+- 同类型取最新,删除过期文件
+- 不同类型合并成一条记录(丰富学生画像)
+"""
+import csv
+from collections import defaultdict
+import re
+from datetime import datetime, timedelta
+import os
+import subprocess
+
+script_dir = os.path.dirname(os.path.abspath(__file__))
+project_dir = os.path.dirname(os.path.dirname(script_dir))  # 上溯两级到项目根
+csv_path = os.path.join(project_dir, "素材库", "测评数据_优化提取.csv")
+output_path = os.path.join(project_dir, "素材库", "学生汇总数据.csv")
+
+# 读取CSV
+with open(csv_path, 'r', encoding='utf-8') as f:
+    reader = csv.DictReader(f)
+    data = list(reader)
+
+print(f'原始记录数: {len(data)}')
+
+# 过滤掉Unknown类型
+data = [r for r in data if r.get('报告类型') != 'Unknown']
+print(f'过滤Unknown后记录数: {len(data)}')
+
+def date_to_ord(d):
+    """YYYYMMDD → 儒略日数,非法返回 0"""
+    if not d or len(d) != 8:
+        return 0
+    try:
+        return datetime.strptime(d, '%Y%m%d').toordinal()
+    except:
+        return 0
+
+def normalize_date(d):
+    """统一日期格式为YYYY-MM-DD"""
+    if not d:
+        return ''
+    d = d.strip().replace('-', '').replace('/', '')
+    if len(d) == 8:
+        return f'{d[:4]}-{d[4:6]}-{d[6:8]}'
+    return d
+
+def extract_ts(filename):
+    """从文件名提取最大数字时间戳"""
+    digits = re.findall(r'\d+', filename)
+    return max(int(d) for d in digits) if digits else 0
+
+# ===== 第1步:按姓名分组 =====
+name_groups = defaultdict(list)
+for r in data:
+    name = r.get('姓名', '').strip()
+    if name:
+        r['_date'] = r.get('测评日期', '').strip()
+        name_groups[name].append(r)
+
+print(f'学生人数: {len(name_groups)}')
+
+# ===== 第2步:30天窗口聚类 =====
+clusters = []  # 每个元素 (cluster_date, 学生名, 记录列表)
+files_to_delete = []
+
+for name, records in name_groups.items():
+    # 按日期排序
+    sorted_recs = sorted(records, key=lambda x: x['_date'])
+    
+    # 窗口聚类:从第一个记录开始,30天内归入同组
+    current_cluster = []
+    for r in sorted_recs:
+        if not current_cluster:
+            current_cluster.append(r)
+        else:
+            first_date = current_cluster[0]['_date']
+            r_date = r['_date']
+            if first_date and r_date and (date_to_ord(r_date) - date_to_ord(first_date)) <= 30:
+                current_cluster.append(r)
+            else:
+                clusters.append((current_cluster[0]['_date'], name, current_cluster))
+                current_cluster = [r]
+    if current_cluster:
+        clusters.append((current_cluster[0]['_date'], name, current_cluster))
+
+print(f'聚类后组数: {len(clusters)}')
+
+# ===== 第3步:处理每个聚类 =====
+merged_students = []
+
+for cluster_date, name, recs_in_cluster in clusters:
+    # 按时间戳排序(倒序,最新在前)
+    recs_sorted = sorted(recs_in_cluster, key=lambda r: extract_ts(r.get('filename', '')), reverse=True)
+    
+    # 同类型去重:保留最新,记录要删除的文件
+    type_records = defaultdict(list)
+    for r in recs_sorted:
+        rt = r.get('报告类型', '未知')
+        type_records[rt].append(r)
+    
+    deduped = []
+    for rt, recs_list in type_records.items():
+        if len(recs_list) > 1:
+            deduped.append(recs_list[0])  # 保留最新的(已按ts倒序)
+            for old in recs_list[1:]:
+                fname = old.get('filename', '')
+                if fname:
+                    files_to_delete.append(fname)
+        else:
+            deduped.append(recs_list[0])
+    
+    # 构建合并行
+    merged = {
+        '姓名': name,
+        '测评日期': normalize_date(cluster_date) if cluster_date else '',
+        '生日': '',
+        '报告类型汇总': '',
+    }
+    
+    # 收集所有类型 & 生日
+    all_types = set()
+    for r in deduped:
+        rt = r.get('报告类型', '未知')
+        all_types.add(rt)
+        if not merged['生日'] and r.get('生日'):
+            merged['生日'] = r.get('生日')
+    merged['报告类型汇总'] = '+'.join(sorted(all_types))
+    
+    # 为每种报告类型创建总分/百分位字段
+    for rt in ['A1', 'A2', 'B2', 'B3', 'B4', 'B5', 'B6', 'C1']:
+        merged[f'{rt}_总分'] = ''
+        merged[f'{rt}_百分位'] = ''
+    
+    # 按类型归档(已去重,每种最多1条)
+    type_data = {}
+    for r in deduped:
+        rt = r.get('报告类型', '未知')
+        type_data[rt] = r
+    
+    # 核心认知维度 - 优先从A1取, 其次B4, 最后C1
+    cognitive_dims = ['感知觉', '注意力', '记忆力', '推理能力', '空间能力', '加工速度']
+    for type_priority in ['A1', 'B4', 'C1']:
+        if type_priority in type_data:
+            latest = type_data[type_priority]
+            for dim in cognitive_dims:
+                pct_key = f'{dim}_pct'
+                score_key = f'{dim}_score'
+                if pct_key not in merged and latest.get(pct_key):
+                    merged[pct_key] = latest[pct_key]
+                if score_key not in merged and latest.get(score_key):
+                    merged[score_key] = latest[score_key]
+    
+    # 大五人格从C1获取(若无A2)
+    big5_dims = ['开放性', '宜人性', '责任心', '外倾性', '神经质']
+    if 'A2' not in type_data and 'C1' in type_data:
+        c1_data = type_data['C1']
+        for dim in big5_dims:
+            if c1_data.get(dim) and dim not in merged:
+                merged[dim] = c1_data[dim]
+    
+    for rt, rec in type_data.items():
+        if rec.get('总分'):
+            merged[f'{rt}_总分'] = rec.get('总分', '')
+        if rec.get('百分位'):
+            merged[f'{rt}_百分位'] = rec.get('百分位', '')
+        
+        if rt == 'A2':
+            for dim in ['开放性', '宜人性', '外倾性', '神经质', '责任心',
+                       '情绪调节', '抗挫折', '内驱力', '目标管理', '人际交往',
+                       '自卑-自信', '抑郁-安详', '焦虑-安详', '无力感-掌控感',
+                       '情绪总分',
+                       '与父亲信任', '与父亲沟通', '与父亲亲近',
+                       '与母亲信任', '与母亲沟通', '与母亲亲近',
+                       '与同伴信任', '与同伴沟通', '与同伴亲近',
+                       'BMI', '身高', '体重', '睡眠_小时', '饮食_小时']:
+                if rec.get(dim):
+                    merged[dim] = rec.get(dim, '')
+        
+        elif rt == 'B3':
+            for dim in ['抑制控制_pct', '工作记忆_pct', '认知灵活性_pct',
+                       '深层动机', '表面动机', '自我效能感',
+                       '深层方法与策略', '表面方法与策略', '学习自我调节']:
+                if rec.get(dim):
+                    merged[dim] = rec.get(dim, '')
+        
+        elif rt == 'B4':
+            for dim in ['自我概念_行为表现', '自我概念_能力与学校', '自我概念_躯体外貌', 
+                        '自我概念_情绪状态', '自我概念_合群', '自我概念_幸福与满足',
+                        '自主性', '胜任感', '归属感',
+                        '成长型思维']:
+                if rec.get(dim):
+                    merged[dim] = rec.get(dim, '')
+        
+        elif rt == 'B5':
+            for dim in ['认知重评', '表达抑制',
+                       '睡眠_时长', '睡眠_效率', '入睡时间_min', '睡眠_障碍',
+                       '睡眠_主观质量', '睡眠_日间功能', '睡眠_催眠药物',
+                       '网络依赖_总分', '网络依赖_强迫使用', '网络依赖_时间管理', '网络依赖_戒断反应',
+                       '学业压力_学业负担', '学业压力_家庭期望', '学业压力_师生关系',
+                       '学业压力_自我期望', '学业压力_同伴竞争',
+                       '人际_母亲关系', '人际_父亲关系', '人际_师生关系', '人际_同伴关系',
+                       '社交_主动交往', '社交_情感支持', '社交_情感表达', '社交_表达影响', '社交_冲突解决',
+                       '运动_久坐_频率', '运动_久坐_时长',
+                       '运动_步行_频率', '运动_步行_时长',
+                       '运动_中等强度_频率', '运动_中等强度_时长',
+                       '运动_高强度_频率', '运动_高强度_时长']:
+                if rec.get(dim):
+                    merged[dim] = rec.get(dim, '')
+        
+        elif rt == 'B6':
+            for dim in ['兴趣_艺术型', '兴趣_社会型', '兴趣_事业型', '兴趣_常规型', '兴趣_现实型', '兴趣_研究型',
+                       '能力_内省', '能力_空间', '能力_音乐', '能力_人际关系', '能力_自然', '能力_身体运动',
+                       '能力_语言', '能力_逻辑数学', '职业价值观']:
+                if rec.get(dim):
+                    merged[dim] = rec.get(dim, '')
+    
+    merged_students.append(merged)
+
+
+# ===== 第4步:删除过期文件(同类型30天内重复的较早版本) =====
+deleted_count = 0
+for fname in files_to_delete:
+    full = os.path.join(project_dir, '报告', fname)
+    if os.path.exists(full):
+        os.remove(full)
+        subprocess.run(['svn', 'delete', '--non-interactive', full],
+                      capture_output=True, cwd=project_dir)
+        deleted_count += 1
+
+if deleted_count:
+    print(f'删除过期文件: {deleted_count}')
+else:
+    print('无过期文件需删除')
+
+
+# ===== 第5步:定义列顺序并输出 =====
+columns = ['姓名', '测评日期', '生日', '报告类型汇总']
+
+for rt in ['A1', 'A2', 'B2', 'B3', 'B4', 'B5', 'B6', 'C1']:
+    columns.extend([f'{rt}_总分', f'{rt}_百分位'])
+
+cognitive_dims = ['感知觉', '注意力', '记忆力', '推理能力', '空间能力', '加工速度']
+for dim in cognitive_dims:
+    columns.append(f'{dim}_pct')
+for dim in cognitive_dims:
+    columns.append(f'{dim}_score')
+
+# A2 特有
+columns.extend(['开放性', '宜人性', '外倾性', '神经质', '责任心',
+               '情绪调节', '抗挫折', '内驱力', '目标管理', '人际交往',
+               '自卑-自信', '抑郁-安详', '焦虑-安详', '无力感-掌控感',
+               '情绪总分',
+               '与父亲信任', '与父亲沟通', '与父亲亲近',
+               '与母亲信任', '与母亲沟通', '与母亲亲近',
+               '与同伴信任', '与同伴沟通', '与同伴亲近',
+               'BMI', '身高', '体重', '睡眠_小时', '饮食_小时'])
+
+# B3 特有
+columns.extend(['抑制控制_pct', '工作记忆_pct', '认知灵活性_pct',
+               '深层动机', '表面动机', '自我效能感',
+               '深层方法与策略', '表面方法与策略', '学习自我调节'])
+
+# B4 特有
+columns.extend(['自我概念_行为表现', '自我概念_能力与学校', '自我概念_躯体外貌', 
+                '自我概念_情绪状态', '自我概念_合群', '自我概念_幸福与满足',
+                '自主性', '胜任感', '归属感',
+                '成长型思维'])
+
+# B5 特有
+columns.extend(['认知重评', '表达抑制',
+               '睡眠_时长', '睡眠_效率', '入睡时间_min', '睡眠_障碍',
+               '睡眠_主观质量', '睡眠_日间功能', '睡眠_催眠药物',
+               '网络依赖_总分', '网络依赖_强迫使用', '网络依赖_时间管理', '网络依赖_戒断反应',
+               '学业压力_学业负担', '学业压力_家庭期望', '学业压力_师生关系',
+               '学业压力_自我期望', '学业压力_同伴竞争',
+               '人际_母亲关系', '人际_父亲关系', '人际_师生关系', '人际_同伴关系',
+               '社交_主动交往', '社交_情感支持', '社交_情感表达', '社交_表达影响', '社交_冲突解决',
+               '运动_久坐_频率', '运动_久坐_时长',
+               '运动_步行_频率', '运动_步行_时长',
+               '运动_中等强度_频率', '运动_中等强度_时长',
+               '运动_高强度_频率', '运动_高强度_时长'])
+
+# B6 特有
+columns.extend(['兴趣_艺术型', '兴趣_社会型', '兴趣_事业型', '兴趣_常规型', '兴趣_现实型', '兴趣_研究型',
+               '能力_内省', '能力_空间', '能力_音乐', '能力_人际关系', '能力_自然', '能力_身体运动',
+               '能力_语言', '能力_逻辑数学', '职业价值观'])
+
+with open(output_path, 'w', encoding='utf-8-sig', newline='') as f:
+    writer = csv.DictWriter(f, fieldnames=columns)
+    writer.writeheader()
+    writer.writerows(merged_students)
+
+print(f'\n合并后记录数: {len(merged_students)}')
+print(f'输出文件: {output_path}')
+
+# 统计
+type_stats = defaultdict(int)
+for s in merged_students:
+    types = s.get('报告类型汇总', '').split('+')
+    for t in types:
+        if t:
+            type_stats[t] += 1
+
+print(f'\n统计:')
+for t, c in sorted(type_stats.items()):
+    print(f'{t}: {c}')

+ 99 - 0
docs/参考资料/dan_reports/process_all_pdfs.py

@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+"""
+Process remaining PDFs - analyze content to identify type and add proper prefix
+"""
+import os
+import re
+import fitz
+from type_detector import detect_type
+
+# Known patterns that already have prefix
+ALREADY_PREFIXED = re.compile(r'^(A1_|A2_|B[0-9]_|[A-Z][0-9]\s)')
+
+def identify_report_type(pdf_path):
+    """Identify report type by reading PDF content (fingerprint-based)"""
+    try:
+        doc = fitz.open(pdf_path)
+        text = ""
+        for page in doc[:5]:
+            text += page.get_text()
+        doc.close()
+        
+        report_type, scores = detect_type(text, pdf_path)
+        return report_type
+    except Exception as e:
+        print(f"Error reading {pdf_path}: {e}")
+        return 'Error'
+
+def process_files():
+    # 自动计算项目根目录
+    script_dir = os.path.dirname(os.path.abspath(__file__))
+    project_dir = os.path.dirname(os.path.dirname(script_dir))
+    reports_dir = os.path.join(project_dir, "报告")
+    output_dir = os.path.join(project_dir, "报告")
+    
+    # Get all PDF files
+    all_files = [f for f in os.listdir(reports_dir) if f.endswith('.pdf')]
+    
+    # Filter to unprocessed files (no proper prefix)
+    unprocessed = []
+    for f in all_files:
+        if not ALREADY_PREFIXED.match(f):
+            unprocessed.append(f)
+    
+    print(f"Total files: {len(all_files)}")
+    print(f"Already processed (has prefix): {len(all_files) - len(unprocessed)}")
+    print(f"Need to process: {len(unprocessed)}")
+    
+    # Process in batches
+    results = {'A1': [], 'A2': [], 'B2': [], 'B3': [], 'B4': [], 'B6': [], 'Unknown': [], 'Error': []}
+    
+    for i, filename in enumerate(unprocessed):
+        if i % 50 == 0:
+            print(f"Processing {i}/{len(unprocessed)}...")
+        
+        pdf_path = os.path.join(reports_dir, filename)
+        report_type = identify_report_type(pdf_path)
+        results[report_type].append(filename)
+        
+        # Extract name from filename for renaming
+        name_match = re.search(r'[_-](\w+)[_-]\d{13,}', filename)
+        if name_match:
+            name = name_match.group(1)
+        else:
+            # Try alternative pattern
+            parts = re.split(r'[_\-]', filename)
+            name = parts[-2] if len(parts) >= 2 else 'unknown'
+        
+        # Create new filename with proper prefix
+        ext = '.pdf'
+        # Get name part (before timestamp)
+        name_match = re.search(r'(.+?)_\d{13,}', filename)
+        if name_match:
+            base_name = name_match.group(1).strip()
+            new_filename = f"{report_type}_{base_name}{ext}"
+        else:
+            new_filename = f"{report_type}_{filename}"
+        
+        # Rename if not already correct
+        if not filename.startswith(report_type + '_'):
+            try:
+                new_path = os.path.join(reports_dir, new_filename)
+                # Check if target exists
+                if not os.path.exists(new_path):
+                    os.rename(pdf_path, new_path)
+                    print(f"Renamed: {filename} -> {new_filename}")
+                else:
+                    print(f"Skipped (exists): {filename}")
+            except Exception as e:
+                print(f"Error renaming {filename}: {e}")
+    
+    # Print summary
+    print("\n=== Summary ===")
+    for t, files in results.items():
+        print(f"{t}: {len(files)}")
+    
+    return results
+
+if __name__ == "__main__":
+    process_files()

+ 35 - 0
docs/参考资料/dan_reports/read_pdf.py

@@ -0,0 +1,35 @@
+import pdfplumber
+import sys
+import os
+
+def read_pdf(pdf_path):
+    text = ""
+    try:
+        with pdfplumber.open(pdf_path) as pdf:
+            for page in pdf.pages:
+                text += page.extract_text() or ""
+                text += "\n\n"
+        return text
+    except Exception as e:
+        return f"Error: {e}"
+
+if __name__ == "__main__":
+    if len(sys.argv) < 2:
+        print("Usage: python read_pdf.py <pdf_file>")
+        sys.exit(1)
+    
+    pdf_file = sys.argv[1]
+    if not os.path.exists(pdf_file):
+        print(f"File not found: {pdf_file}")
+        sys.exit(1)
+    
+    content = read_pdf(pdf_file)
+    # 保存到临时文件,避免编码问题
+    import tempfile
+    import os
+    temp_file = os.path.join(tempfile.gettempdir(), 'pdf_content.txt')
+    with open(temp_file, 'w', encoding='utf-8') as f:
+        f.write(content)
+    print(f"Content saved to: {temp_file}")
+    # 只打印关键信息
+    print(content[:500] if content else "No content extracted")

+ 225 - 0
docs/参考资料/dan_reports/type_detector.py

@@ -0,0 +1,225 @@
+# -*- coding: utf-8 -*-
+"""
+报告类型指纹检测模块
+
+基于 PDF 文本内容的加权指纹评分系统,用于识别报告类型。
+被 process_all_pdfs.py 和 extract_all_types.py 共享使用。
+
+用法:
+    from type_detector import detect_type
+    report_type, scores = detect_type(text, filename)
+
+指纹按唯一性从高到低排列。排除规则用于处理有冲突特征的情况(如 B4 含"校园版"则扣分)。
+阈值保护防止对文本层损坏的 PDF 误判,安全回退到文件名前缀匹配。
+"""
+
+# 类型指纹注册表:每个条目 (文本特征, 权重)
+# 多个指纹之和为该类型的总分
+FINGERPRINTS = {
+    'B5': [
+        ('青春期挑战', 25),     # 独占标题
+        ('学业压力源', 20),     # 独占章节
+        ('网络依赖', 18),       # 独占章节
+        ('社交能力', 8),        # 辅助特征
+    ],
+    'B4': [
+        ('GROWTH MINDSET', 30),          # 英文独占,提取最稳定
+        ('成长型思维', 25),              # 中文独占
+        ('SELF-CONCEPT REPORT', 10),     # 与B2共享,权重较低
+        ('核心认知能力', 8),             # B4标识,区别于A1/C1
+        ('认知能力测评报告', 5),         # 共享(B4含认知模块,低权重以免抢A1)
+    ],
+    'B6': [
+        ('CAREER', 25),                  # 独占英文标题
+        ('职业发展', 22),                # 独占中文标题
+        ('Holland', 20),                 # 霍兰德职业代码
+        ('多元智能', 10),                # 辅助特征
+    ],
+    'B2': [
+        ('CHILD BEHAVIOR', 25),          # 独占:儿童行为测评报告
+        ('Conduct problems', 20),        # 独占:品行问题英文名
+        ('PARENTAL EDUCATION', 20),      # 独占:父母教养测评报告
+        ('FAMILY ENVIRONMENT', 18),      # 独占:家庭教育环境测评报告
+        ('家庭教养', 12),                # 辅助特征
+    ],
+    'A2': [
+        ('大五人格', 22),                # 独占标题
+        ('核心素养', 20),                # 独占标题
+        ('EMOTION', 12),                 # 辅助特征(情绪量表区域)
+        ('Big Five', 10),               # 英文辅助
+        ('综合发展', 8),                 # A2报告常见标题
+    ],
+    'B3': [
+        ('MOTIVATION', 22),              # 独占英文标题
+        ('学习动机', 20),                # 独占中文标题
+        ('深层动机', 12),                # 辅助特征
+    ],
+    'A1': [
+        ('认知能力测评报告', 15),        # 共享标题(A1/C1/B4都有)
+        ('COGNITIVE ABILITY REPORT', 15),# 共享英文标题
+    ],
+    'C1': [
+        ('校园版', 35),                  # 独占标识(提高权重以胜出共享指纹)
+        ('认知能力测评报告', 5),         # 共享,低权重
+        ('COGNITIVE ABILITY REPORT', 5), # 共享,低权重
+    ],
+}
+
+# 排除规则:冲突文本 → 从特定类型扣分
+# 当某类型有不应含有的特征时,用负权重拉低其总分
+EXCLUSIONS = {
+    'A1': [('GROWTH MINDSET', -20)],        # A1 不含成长型思维(轻扣分,避免抵消文件名加成)
+    'C1': [('GROWTH MINDSET', -50)],        # C1 不含成长型思维
+    'B4': [('校园版', -50), ('PARENTAL EDUCATION', -40)],  # B4 非校园版,无教养内容
+    'A2': [('Conduct problems', -40)],      # A2 无儿童行为
+}
+
+# 阈值:低于此分数时回退到文件名匹配
+THRESHOLD = 10
+
+
+def detect_type(text, filename=''):
+    """基于PDF文本内容和文件名检测报告类型(混合评分)。
+    
+    先用内容指纹评分,再叠加文件名信号作为强补充。
+    当文件名明确指示类型时,该类型获得+50加成(解决C1/A1缺文本指纹的问题)。
+    
+    Args:
+        text: PDF 全文文本
+        filename: PDF 文件名(用于回退和混合评分)
+    
+    Returns:
+        (report_type: str, scores: dict) — 类型和得分详情
+    """
+    if not text or not text.strip():
+        return fallback_by_filename(filename), {}
+    
+    # 初始化分数
+    scores = {t: 0 for t in FINGERPRINTS}
+    
+    # 1. 正向评分:每种类型匹配其指纹
+    for report_type, patterns in FINGERPRINTS.items():
+        for pattern, weight in patterns:
+            if pattern in text:
+                scores[report_type] += weight
+    
+    # 2. 排除扣分:冲突特征拉低特定类型分数
+    for report_type, rules in EXCLUSIONS.items():
+        for pattern, penalty in rules:
+            if pattern in text:
+                scores[report_type] += penalty  # penalty 为负值
+    
+    # 3. 文件名信号加成:当文件名明确指示该类型时,大幅加分
+    #    解决C1/A1等类型缺文本层唯一指纹的问题
+    content_scores = scores.copy()
+    fb_type = fallback_by_filename(filename)
+    if fb_type in scores:
+        scores[fb_type] += 50  # 文件名信号:强于大多数内容指纹
+    
+    # 4. 取最高分
+    top_type = max(scores, key=scores.get)
+    top_score = content_scores[top_type]
+    
+    # 5. 阈值保护:纯内容分数过低则回退到文件名匹配
+    if top_score < THRESHOLD:
+        return fallback_by_filename(filename), scores
+    
+    return top_type, scores
+
+
+def fallback_by_filename(filename):
+    """根据文件名前缀判断报告类型(后备方案)。
+    
+    当内容指纹得分低于阈值时使用此方法。
+    保持与旧版 determine_report_type() 相同的逻辑。
+    """
+    name = filename.upper()
+    # B5 在 A2 之前检查(A2_B5 前缀文件应归为 B5)
+    if 'B5' in name:
+        return 'B5'
+    if 'A1' in name:
+        return 'A1'
+    if 'A2' in name:
+        return 'A2'
+    if 'B2' in name:
+        return 'B2'
+    if 'B3' in name:
+        return 'B3'
+    if 'B4' in name or '综合素质' in name or '成长型思维' in name or '多元认知' in name:
+        return 'B4'
+    if 'B6' in name:
+        return 'B6'
+    if 'C1' in name:
+        return 'C1'
+    return 'Unknown'
+
+
+def debug_detect(text, filename=''):
+    """调试版本,返回详细匹配信息。
+    
+    Returns:
+        (report_type, scores, matches)
+        matches: [(type, pattern, weight), ...] 每项匹配
+    """
+    if not text or not text.strip():
+        return fallback_by_filename(filename), {}, []
+    
+    scores = {t: 0 for t in FINGERPRINTS}
+    matches = []
+    
+    for report_type, patterns in FINGERPRINTS.items():
+        for pattern, weight in patterns:
+            if pattern in text:
+                scores[report_type] += weight
+                matches.append((report_type, pattern, weight))
+    
+    for report_type, rules in EXCLUSIONS.items():
+        for pattern, penalty in rules:
+            if pattern in text:
+                scores[report_type] += penalty
+                matches.append((report_type, f'EXCLUDE:{pattern}', penalty))
+    
+    # 文件名信号
+    content_scores = scores.copy()
+    fb_type = fallback_by_filename(filename)
+    if fb_type in scores:
+        scores[fb_type] += 50
+        matches.append((fb_type, 'FILENAME_BONUS', 50))
+    
+    top_type = max(scores, key=scores.get)
+    top_score = content_scores[top_type]
+    
+    if top_score < THRESHOLD:
+        fb = fallback_by_filename(filename)
+        return fb, scores, matches
+    
+    return top_type, scores, matches
+
+
+if __name__ == '__main__':
+    # 简单自测
+    test_texts = [
+        ('认知能力测评报告', 'A1'),                     # 纯认知报告 = A1
+        ('核心素养与大五人格EMOTION', 'A2'),           # A2 独占指纹
+        ('CHILD BEHAVIOR Conduct problems PARENTING', 'B2'),
+        ('MOTIVATION学习动机深层动机', 'B3'),
+        ('GROWTH MINDSET成长型思维\n认知能力测评报告', 'B4'),  # B4独占 > 共享
+        ('青春期挑战学业压力源网络依赖', 'B5'),
+        ('CAREER职业发展Holland多元智能', 'B6'),
+        ('认知能力测评报告\nCOGNITIVE ABILITY REPORT\n校园版', 'C1'),  # 校园版 > A1
+    ]
+    
+    print('=== Type Detector Self-Test ===')
+    for text, expected in test_texts:
+        result, scores = detect_type(text)
+        ok = 'OK' if result == expected else 'FAIL'
+        print(f'  {ok:4s} {result:4s} (expected {expected}) scores={scores}')
+    
+    # 边界测试
+    print('\n=== Edge Cases ===')
+    print(f'  empty: {detect_type("")[0]} (expected Unknown)')
+    print(f'  only campus: {detect_type("校园版")[0]} (expected C1)')
+    print(f'  A2_B5: {detect_type("青春期挑战", "A2_B5_张欣怡.pdf")[0]} (expected B5)')
+    print(f'  threshold: {detect_type("普通文本")[0]} (expected Unknown)')
+    print(f'  cognitive+校园版: {detect_type("认知能力测评报告COGNITIVE ABILITY REPORT校园版")[0]} (expected C1)')
+    print(f'  B4+校园版: {detect_type("GROWTH MINDSET成长型思维校园版")[0]} (expected Unknown, 校园版扣分后B4<阈值→回退)')

+ 74 - 0
docs/参考资料/dan_reports/update_materials.py

@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+"""
+自动化更新脚本 - 素材库全量更新流水线
+
+功能: 从原始PDF报告更新素材库的三个核心数据文件
+流程:
+  1. 分类重命名报告 (process_all_pdfs.py)
+  2. 全类型数据提取 (extract_all_types.py)
+  3. 全类型学生合并 (merge_all_types.py)
+  4. 删除重复A1报告 (delete_duplicate_a1.py)
+
+用法:
+  python scripts/reports/update_materials.py          # 从 scripts 目录运行
+  cd 项目根目录 && python scripts/reports/update_materials.py  # 从根目录运行
+"""
+import os
+import sys
+import subprocess
+
+# 脚本所在目录 = scripts/reports/
+SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
+PROJECT_DIR = os.path.dirname(os.path.dirname(SCRIPTS_DIR))
+
+
+def run_script(script_name, label):
+    """运行 scripts/reports/ 中的脚本"""
+    script_path = os.path.join(SCRIPTS_DIR, script_name)
+    print(f"\n{'=' * 50}")
+    print(f"[{label}] 运行: {script_name}")
+    print(f"{'=' * 50}")
+
+    result = subprocess.run(
+        [sys.executable, script_path],
+        cwd=PROJECT_DIR,
+        capture_output=False
+    )
+
+    if result.returncode != 0:
+        print(f"  [FAIL] [{label}] 失败 (exit code={result.returncode})")
+        return False
+    print(f"  [OK] [{label}] 完成")
+    return True
+
+
+def main():
+    print("=" * 60)
+    print("  素材库全量更新流水线")
+    print("=" * 60)
+    print(f"  项目根目录: {PROJECT_DIR}")
+    print(f"  脚本目录:   {SCRIPTS_DIR}")
+
+    steps = [
+        ("process_all_pdfs.py",       "1/4 报告分类重命名"),
+        ("extract_all_types.py",      "2/4 全类型数据提取"),
+        ("merge_all_types.py",        "3/4 全类型学生合并"),
+        ("delete_duplicate_a1.py",    "4/4 清理重复A1报告"),
+    ]
+
+    for script, label in steps:
+        if not run_script(script, label):
+            print(f"\n  ❌ 流水线在 [{label}] 中断")
+            sys.exit(1)
+
+    print(f"\n{'=' * 60}")
+    print("  [OK] 素材库更新完成!")
+    print(f"{'=' * 60}")
+    print("\n生成的文件:")
+    print("  - 素材库/测评数据_优化提取.csv")
+    print("  - 素材库/学生汇总数据.csv")
+    print("\n提示: 案例库.md 需要手动根据新增数据更新案例")
+
+
+if __name__ == "__main__":
+    main()