Selaa lähdekoodia

feat: 肠道菌群报告详情页 + 上传日期评分优化 + 身页面重构 + 首页角色分流

User 2 kuukautta sitten
vanhempi
sitoutus
b30fa9b5e3
30 muutettua tiedostoa jossa 809 lisäystä ja 240 poistoa
  1. 3 3
      cfc-backend/.classpath
  2. 115 58
      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. 88 65
      cfc-frontend/components/DimensionActivities.vue
  9. 2 10
      cfc-frontend/components/DimensionArticles.vue
  10. 52 36
      cfc-frontend/components/DimensionProducts.vue
  11. 1 7
      cfc-frontend/pages.json
  12. 6 32
      cfc-frontend/pages/body/index.vue
  13. 383 0
      cfc-frontend/pages/health/gut-flora-detail.vue
  14. 27 0
      cfc-frontend/pages/health/gut-index.vue
  15. 10 0
      cfc-frontend/pages/health/report-confirm.vue
  16. 7 3
      cfc-frontend/pages/health/report-list.vue
  17. 1 12
      cfc-frontend/pages/mind/index.vue
  18. 1 1
      cfc-frontend/pages/profile/index.vue
  19. 2 11
      cfc-frontend/pages/wisdom/index.vue
  20. BIN
      docs/参考资料/dan_reports/A1 儿童核心认知发展_李长龙_2025102513015067836.pdf
  21. BIN
      docs/参考资料/dan_reports/A2 核心素养_张馨月_202402221120441849.pdf
  22. BIN
      docs/参考资料/dan_reports/A2_B5青春期挑战_肖媛媛.pdf
  23. 96 0
      docs/参考资料/dan_reports/AGENTS.md
  24. BIN
      docs/参考资料/dan_reports/B2 儿童自我与家庭教养_李长龙_2025102514271465449.pdf
  25. BIN
      docs/参考资料/dan_reports/B3 核心学习能力_张馨月_2026022417360882898.pdf
  26. BIN
      docs/参考资料/dan_reports/B4 核心认知能力和成长型思维_张馨月_2026022416334518042.pdf
  27. BIN
      docs/参考资料/dan_reports/B6 职业发展_张馨月_2026022415182958757.pdf
  28. BIN
      docs/参考资料/dan_reports/B6_生涯规划(高中版)_习常静.pdf
  29. BIN
      docs/参考资料/dan_reports/C1校园标准版(1-3年级)_胡家诚_2025121616362391951.pdf
  30. BIN
      docs/参考资料/dan_reports/C1校园标准版(4-12年级)_陈风然_2025121310362662392.pdf

+ 3 - 3
cfc-backend/.classpath

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

+ 115 - 58
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -5245,64 +5245,9 @@ try {
         runMigration80();
         runMigration81();
         runMigration82();
-    }
-
-    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 字段
         ensureColumn("products", "recommendation_tags", "VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain");
@@ -5377,6 +5322,27 @@ try {
         } 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() {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" +
@@ -5413,4 +5379,95 @@ try {
             log.warn("创建ai_user_facts表失败: {}", 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 {
+            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) {
+            log.warn("回填activities.dimension_weights失败: {}", e.getMessage());
+        }
+
+        try {
+            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) {
+            log.warn("回填products.dimension_weights失败: {}", e.getMessage());
+        }
+
+        try {
+            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) {
+            log.warn("回填articles.dimension_weights失败: {}", e.getMessage());
+        }
+    }
+
+    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 status;
     private String domain;
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
     private String memberEligible;
     private String rejectReason;
     private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
@@ -79,6 +81,7 @@ public class ProductDTO {
         d.vendorName = p.getVendorName();
         d.status = p.getStatus();
         d.domain = p.getDomain();
+        d.dimensionWeights = p.getDimensionWeights();
         d.memberEligible = p.getMemberEligible();
         d.rejectReason = p.getRejectReason();
         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 String relatedDimensions;
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
     private String visibility;
     private String visibleTo;
     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 String memberEligible;
     private String domain;
+    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
+    private String dimensionWeights;
     private String externalSource;
     private String externalId;
     private String externalData;
@@ -62,6 +64,7 @@ public class Product implements Serializable {
     public String getStatus() { return status; }
     public String getMemberEligible() { return memberEligible; }
     public String getDomain() { return domain; }
+    public String getDimensionWeights() { return dimensionWeights; }
     public Long getVendorId() { return vendorId; }
     public String getVendorName() { return vendorName; }
     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")
                 .orderByAsc(Activity::getStartTime);
         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) {

+ 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());
         }
         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()) {
             wrapper.like(Product::getName, query.getKeyword());

+ 88 - 65
cfc-frontend/components/DimensionActivities.vue

@@ -4,30 +4,48 @@
       <text class="section-title">🔥 推荐活动</text>
       <text class="section-more" @click="$emit('moreActivities')">更多 ›</text>
     </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>
+          <text class="article-title">暂无推荐活动</text>
+          <text class="article-summary">精彩活动即将上线,敬请期待</text>
+          <view class="activity-bottom-placeholder">
+            <text class="article-date">--</text>
+          </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>
-          <view class="activity-bottom">
-            <text class="activity-status" :class="'status-' + (act.status || 'upcoming')">{{ statusText(act.status) }}</text>
+          <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>
 </template>
 
 <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 {
   props: {
     dimensionCode: { type: String, required: true },
@@ -36,6 +54,7 @@ export default {
   },
   data: function() {
     return {
+      gradientColors: activityGradientColors
     }
   },
   computed: {
@@ -47,6 +66,10 @@ export default {
     statusText: function(status) {
       var map = { upcoming: '即将开始', ongoing: '进行中', ended: '已结束', cancelled: '已取消' }
       return map[status] || '即将开始'
+    },
+    formatPriceWithSymbol: function(price) {
+      if (price == null) return '免费'
+      return '¥' + (Number(price) / 100).toFixed(0)
     }
   }
 }
@@ -74,78 +97,78 @@ export default {
 .activity-list {
   display: flex;
   flex-direction: column;
+  gap: 16rpx;
 }
 .activity-card {
-  display: flex;
-  flex-direction: row;
   background: #fff;
   border-radius: 16rpx;
-  overflow: hidden;
+  padding: 24rpx;
   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-card:active {
+  opacity: 0.8;
 }
-.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-status {
-  font-size: 22rpx;
-  padding: 4rpx 12rpx;
+.activity-color-bar {
+  display: inline-block;
+  padding: 4rpx 14rpx;
   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 {
-  font-size: 24rpx;
+  font-size: 22rpx;
   color: #F97316;
   font-weight: 500;
 }
 .fee-free {
   color: #22C55E;
 }
-.empty-state {
+.activity-bottom-placeholder {
   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;
   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>

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

@@ -23,7 +23,7 @@
         </view>
       </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] }">
           <text class="article-category-text">{{ article.category || '推荐文章' }}</text>
         </view>
@@ -41,6 +41,7 @@
           <text class="article-date" v-if="article.date">{{ article.date }}</text>
         </view>
       </view>
+    </template>
     </view>
   </view>
 </template>
@@ -154,13 +155,4 @@ export default {
   color: #bbb;
   margin-left: auto;
 }
-.empty-state {
-  display: flex;
-  justify-content: center;
-  padding: 40rpx 0;
-}
-.empty-tip {
-  font-size: 24rpx;
-  color: #999;
-}
 </style>

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

@@ -4,17 +4,25 @@
       <text class="section-title">🛍️ 推荐商品</text>
       <text class="section-more" @click="$emit('moreProducts')">更多 ›</text>
     </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>
 </template>
@@ -26,16 +34,16 @@ export default {
     products: { type: Array, default: function() { return [] } },
     isLoggedIn: { type: Boolean, default: false }
   },
-  data: function() {
-    return {
-    }
-  },
   computed: {
     displayProducts: function() {
       return this.products
     }
   },
   methods: {
+    formatPriceWithSymbol: function(price) {
+      if (price == null) return '免费'
+      return '¥' + (Number(price) / 100).toFixed(0)
+    }
   }
 }
 </script>
@@ -61,36 +69,53 @@ export default {
 }
 .product-list {
   display: flex;
+  flex-direction: row;
   flex-wrap: wrap;
+  gap: 16rpx;
 }
 .product-card {
-  width: calc(50% - 10rpx);
+  width: calc(50% - 8rpx);
   background: #fff;
   border-radius: 16rpx;
-  overflow: hidden;
+  padding: 16rpx;
   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 {
   width: 100%;
-  height: 240rpx;
+  height: 200rpx;
+  border-radius: 12rpx;
   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 {
   font-size: 26rpx;
   color: #333;
-  padding: 12rpx 16rpx 0;
+  font-weight: 500;
+  display: block;
+  margin: 8rpx 0 6rpx;
+  min-height: 36rpx;
 }
 .product-price {
-  font-size: 28rpx;
+  font-size: 24rpx;
   color: #F97316;
-  font-weight: bold;
-  padding: 8rpx 16rpx 16rpx;
-  display: block;
+  font-weight: 600;
 }
 .line-clamp-2 {
   overflow: hidden;
@@ -99,13 +124,4 @@ export default {
   -webkit-line-clamp: 2;
   -webkit-box-orient: vertical;
 }
-.empty-state {
-  display: flex;
-  justify-content: center;
-  padding: 40rpx 0;
-}
-.empty-tip {
-  font-size: 24rpx;
-  color: #999;
-}
 </style>

+ 1 - 7
cfc-frontend/pages.json

@@ -1,11 +1,5 @@
 {
   "pages": [
-    {
-      "path": "pages/wealth/index",
-      "style": {
-        "navigationBarTitleText": "富"
-      }
-    },
     {
       "path": "pages/profile/index",
       "style": {
@@ -955,7 +949,7 @@
           }
         },
         {
-          "path": "gut-flora-detail",
+          "path": "gut-flora-detail-expired",
           "style": {
             "navigationBarTitleText": "菌群报告详情"
           }

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

@@ -37,16 +37,6 @@
       :intimacyMap="intimacyMapForGraph"
       @memberTap="goMemberDetail" />
 
-    <!-- 登录后:用户快捷入口 -->
-    <UserQuickEntry
-      v-if="isLoggedIn"
-      :userName="userName"
-      roleName="家长"
-      :currentChildName="activeChildName"
-      :children="children"
-      @childChanged="onChildChanged"
-      @scrollTo="scrollToSection" />
-
     <!-- 健康打卡卡片 -->
     <HealthCheckinCard
       v-if="isLoggedIn && activeChildId"
@@ -184,22 +174,6 @@
       @articleClick="goArticleDetail"
       @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 LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -255,7 +228,7 @@ const healthRequest = function(url, data) {
 }
 
 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() {
     return {
       isLoggedIn: false,
@@ -283,7 +256,7 @@ export default {
         { icon: '\u{1F634}', label: '作息', needLogin: true, page: '/pages/body/sleep-record' },
         { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '/pages/health/meditation-index' },
         { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: '/pages/health/report-upload?reportType=tongue' }
-      }
+      ]
   },
   computed: {
     reportTypeLabel: function() {
@@ -388,12 +361,13 @@ export default {
       this.loadHealthDimensions()
     },
     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
       }
       var self = this
-      getDimensionOverview({ memberId: this.activeChildId }).then(function(res) {
+      getDimensionOverview({ memberId: memberId }).then(function(res) {
         if (res && res.data) {
           self.dimensionData = res.data
           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>
 
 <script>
+import { getLatestHealthReport } from '../../utils/api.js'
 export default {
   data() {
     return {}
@@ -102,6 +103,27 @@ export default {
       uni.navigateTo({
         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);
 }
 
+.action-btn.primary {
+  background-color: #5B9BD5;
+  box-shadow: 0 4rpx 12rpx rgba(91, 155, 213, 0.3);
+}
+
 .action-btn-text {
   font-size: 28rpx;
   color: #FFFFFF;

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

@@ -44,6 +44,10 @@
           <text class="field-label">综合评分</text>
           <input class="field-input" type="number" v-model="payload.summary.overallScore" />
         </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'">
           <text class="field-label">报告摘要</text>
           <textarea class="field-textarea" v-model="payload.summary.summary" maxlength="500" />
@@ -308,6 +312,12 @@ export default {
     this.extractedGender = options.extractedGender || ''
     this.extractedAge = options.extractedAge || ''
     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.matchConfidence = options.matchConfidence ? parseFloat(options.matchConfidence) : null
     this.matchLabel = decodeURIComponent(options.matchLabel || '')

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

@@ -18,7 +18,7 @@
         class="report-card"
         v-for="item in reports"
         :key="item.id"
-        @click="goToDetail(item.id)"
+        @click="goToDetail(item.id, item.reportType)"
       >
         <view class="report-card-left">
           <view class="report-type-badge" :class="'badge-' + (item.reportType === 'gut_flora' ? 'gut' : item.reportType === 'tongue' ? 'tongue' : 'exam')">
@@ -77,8 +77,12 @@ export default {
     goBack() {
       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() {
       uni.navigateTo({ url: '/pages/health/report-upload' })

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

@@ -42,16 +42,6 @@
       :compatibilityMap="compatibilityMapForGraph"
       @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-badge">
@@ -316,7 +306,6 @@ import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -329,7 +318,7 @@ import { getVisibleSections, getFeaturedArticles, getEmiReport, getFamilyEnergyS
 import config from '../../config.js'
 
 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() {
     return {
       isLoggedIn: false,

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

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

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

@@ -52,15 +52,7 @@
       :intimacyMap="intimacyMapForGraph"
       @memberTap="goMemberDetail" />
 
-    <!-- 6. 用户快捷入口 -->
-    <UserQuickEntry
-      v-if="isLoggedIn"
-      :userName="userName"
-      roleName="家长"
-      :currentChildName="activeChildName"
-      :children="children"
-      @childChanged="onChildChanged"
-      @scrollTo="scrollToSection" />
+
 
     <!-- 7. 功能入口 -->
     <view class="func-section" v-if="sectionVisible('func_entries')">
@@ -171,7 +163,6 @@ import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
-import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -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'
 
 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() {
     return {
       isLoggedIn: false,

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