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

feat: 完善7维健康模型 - Tier2 VOICE/PHOTO支持, canvas雷达图, 数据源表迁移

Xiaogang Liao 1 месяц назад
Родитель
Сommit
0f71e443ac

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -6416,4 +6416,48 @@ private void runMigration100() {
 	private void runMigration101() {
 		ensureColumn("product_skus", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'");
 	}
+
+	private void migration102() {
+		// 迁移102: 创建 health_dimension_scores 表(7维健康模型)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_dimension_scores (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+				"dimension VARCHAR(20) NOT NULL COMMENT '维度编码', " +
+				"score INT NOT NULL COMMENT '评分(0-100)', " +
+				"percentile INT COMMENT '百分位', " +
+				"data_source VARCHAR(20) COMMENT '数据来源', " +
+				"tier INT DEFAULT 3 COMMENT '数据可信度层级', " +
+				"raw_data TEXT COMMENT '原始数据JSON', " +
+				"assess_date DATE COMMENT '评估日期', " +
+				"expire_date DATE COMMENT '过期日期', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_member_dim (member_id, dimension), " +
+				"INDEX idx_assess_date (assess_date)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分'");
+			log.info("已创建health_dimension_scores表");
+		} catch (Exception e) {
+			log.warn("创建health_dimension_scores表可能已存在: {}", e.getMessage());
+		}
+
+		// 迁移102b: 创建 health_data_sources 表(多媒体数据源记录)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_data_sources (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+				"source_type VARCHAR(20) NOT NULL COMMENT '数据源类型: VOICE/PHOTO/REPORT/MANUAL', " +
+				"dimension_codes VARCHAR(100) COMMENT '关联维度编码(逗号分隔)', " +
+				"parsed_result TEXT COMMENT '解析结果JSON', " +
+				"file_url VARCHAR(500) COMMENT '原始文件URL', " +
+				"confidence DECIMAL(5,4) COMMENT 'AI解析置信度(0-1)', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_member_source (member_id, source_type), " +
+				"INDEX idx_member_id (member_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录'");
+			log.info("已创建health_data_sources表");
+		} catch (Exception e) {
+			log.warn("创建health_data_sources表可能已存在: {}", e.getMessage());
+		}
+	}
 }

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -185,7 +185,11 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
         score.setDimension(vo.getDimension());
         score.setScore(vo.getScore() != null ? vo.getScore().intValue() : 0);
         score.setDataSource(vo.getSourceType() != null ? vo.getSourceType() : "MANUAL");
-        score.setTier(3);
+        if ("VOICE".equals(vo.getSourceType()) || "PHOTO".equals(vo.getSourceType())) {
+            score.setTier(vo.getConfidence() != null && vo.getConfidence().doubleValue() >= 0.6 ? 2 : 3);
+        } else {
+            score.setTier(3);
+        }
         if (vo.getRecordDate() != null) {
             score.setAssessDate(java.sql.Date.valueOf(vo.getRecordDate()));
         } else {

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

@@ -3125,3 +3125,35 @@ CREATE TABLE IF NOT EXISTS promotion_binding (
     INDEX idx_promoter (promoter_family_id),
     INDEX idx_invitee (invitee_family_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广绑定关系';
+
+-- 健康维度评分表(7维健康模型)
+CREATE TABLE IF NOT EXISTS health_dimension_scores (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    dimension VARCHAR(20) NOT NULL COMMENT '维度编码: growth/sleep/vision/immunity/nutrition/gut/exercise',
+    score INT NOT NULL COMMENT '评分(0-100)',
+    percentile INT COMMENT '百分位',
+    data_source VARCHAR(20) COMMENT '数据来源: MANUAL/REPORT/PHOTO/VOICE/AVERAGE',
+    tier INT DEFAULT 3 COMMENT '数据可信度层级: 1=最高(报告) 2=中(AI分析) 3=基础(手动)',
+    raw_data TEXT COMMENT '原始数据JSON',
+    assess_date DATE COMMENT '评估日期',
+    expire_date DATE COMMENT '过期日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_member_dim (member_id, dimension),
+    INDEX idx_assess_date (assess_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分';
+
+-- 健康数据源记录表(VOICE/PHOTO等多媒体数据源)
+CREATE TABLE IF NOT EXISTS health_data_sources (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    source_type VARCHAR(20) NOT NULL COMMENT '数据源类型: VOICE/PHOTO/REPORT/MANUAL',
+    dimension_codes VARCHAR(100) COMMENT '关联维度编码(逗号分隔)',
+    parsed_result TEXT COMMENT '解析结果JSON',
+    file_url VARCHAR(500) COMMENT '原始文件URL',
+    confidence DECIMAL(5,4) COMMENT 'AI解析置信度(0-1)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_member_source (member_id, source_type),
+    INDEX idx_member_id (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录';

+ 93 - 98
cfc-frontend/pages/body-detail/health-dimensions.vue

@@ -15,25 +15,7 @@
     <view class="radar-section" v-if="healthData && healthData.dimensions">
       <view class="radar-title">七维健康雷达</view>
       <view class="radar-wrap">
-        <view class="radar-center">
-          <view class="radar-grid">
-            <view class="grid-ring ring-3"></view>
-            <view class="grid-ring ring-2"></view>
-            <view class="grid-ring ring-1"></view>
-          </view>
-          <view class="radar-spokes">
-            <view
-              class="spoke-item"
-              v-for="(dim, idx) in healthData.dimensions"
-              :key="dim.dimension"
-              :style="spokeStyles[idx]"
-            >
-              <view class="spoke-dot" :class="'level-' + dim.level"></view>
-              <view class="spoke-label">{{ dim.label }}</view>
-              <view class="spoke-score">{{ dim.score || '--' }}</view>
-            </view>
-          </view>
-        </view>
+        <canvas canvas-id="radarCanvas" class="radar-canvas"></canvas>
       </view>
     </view>
 
@@ -169,24 +151,7 @@ export default {
       ]
     }
   },
-  computed: {
-    spokeStyles: function() {
-      if (!this.healthData || !this.healthData.dimensions) return []
-      var cache = []
-      var dims = this.healthData.dimensions
-      for (var i = 0; i < dims.length; i++) {
-        var angle = i * 51.4 - 90
-        var radius = 85
-        var normalizedScore = (dims[i].score || 0) / 100
-        var translateY = -radius * normalizedScore
-        cache.push({
-          transform: 'rotate(' + angle + 'deg) translateY(' + translateY + 'px)',
-          '-webkit-transform': 'rotate(' + angle + 'deg) translateY(' + translateY + 'px)'
-        })
-      }
-      return cache
-    }
-  },
+  computed: {},
   onLoad: function(options) {
     var childId = options.childId || uni.getStorageSync('currentChildId')
     var childName = options.name || ''
@@ -210,12 +175,102 @@ export default {
         self.loading = false
         if (res.code === 200 && res.data) {
           self.healthData = res.data
+          self.$nextTick(function() {
+            self.drawRadarChart()
+          })
         }
       }).catch(function(e) {
         self.loading = false
         console.log('获取维度数据失败', e)
       })
     },
+    drawRadarChart: function() {
+      var dims = this.healthData && this.healthData.dimensions
+      if (!dims || dims.length === 0) return
+      var ctx = uni.createCanvasContext('radarCanvas', this)
+      var w = 440
+      var h = 440
+      var cx = w / 2
+      var cy = h / 2
+      var maxR = 150
+      var count = dims.length
+      var angleStep = (Math.PI * 2) / count
+
+      ctx.clearRect(0, 0, w, h)
+
+      for (var ri = 1; ri <= 3; ri++) {
+        var rr = (maxR / 3) * ri
+        ctx.beginPath()
+        for (var ai = 0; ai <= count; ai++) {
+          var a = ai * angleStep - Math.PI / 2
+          var px = cx + rr * Math.cos(a)
+          var py = cy + rr * Math.sin(a)
+          if (ai === 0) ctx.moveTo(px, py)
+          else ctx.lineTo(px, py)
+        }
+        ctx.closePath()
+        ctx.setStrokeStyle('#e0e0e0')
+        ctx.setLineWidth(1)
+        ctx.stroke()
+      }
+
+      for (var si = 0; si < count; si++) {
+        var sa = si * angleStep - Math.PI / 2
+        ctx.beginPath()
+        ctx.moveTo(cx, cy)
+        ctx.lineTo(cx + maxR * Math.cos(sa), cy + maxR * Math.sin(sa))
+        ctx.setStrokeStyle('#ddd')
+        ctx.setLineWidth(1)
+        ctx.stroke()
+      }
+
+      ctx.beginPath()
+      for (var pi = 0; pi < count; pi++) {
+        var normScore = (dims[pi].score || 0) / 100
+        var pa = pi * angleStep - Math.PI / 2
+        var pR = maxR * normScore
+        var px2 = cx + pR * Math.cos(pa)
+        var py2 = cy + pR * Math.sin(pa)
+        if (pi === 0) ctx.moveTo(px2, py2)
+        else ctx.lineTo(px2, py2)
+      }
+      ctx.closePath()
+      ctx.setFillStyle('rgba(255, 140, 66, 0.15)')
+      ctx.setStrokeStyle('#FF8C42')
+      ctx.setLineWidth(2)
+      ctx.fill()
+      ctx.stroke()
+
+      for (var di = 0; di < count; di++) {
+        var da = di * angleStep - Math.PI / 2
+        var dR = maxR * ((dims[di].score || 0) / 100)
+        var dx = cx + dR * Math.cos(da)
+        var dy = cy + dR * Math.sin(da)
+
+        ctx.beginPath()
+        ctx.arc(dx, dy, 4, 0, Math.PI * 2)
+        ctx.setFillStyle('#FF8C42')
+        ctx.fill()
+
+        var lR = maxR + 22
+        var lx = cx + lR * Math.cos(da)
+        var ly = cy + lR * Math.sin(da)
+        ctx.setFontSize(10)
+        ctx.setFillStyle('#333')
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('middle')
+        ctx.fillText(dims[di].label, lx, ly)
+
+        var sR = dR > 10 ? dR - 12 : dR + 12
+        var sx = cx + sR * Math.cos(da)
+        var sy = cy + sR * Math.sin(da)
+        ctx.setFontSize(9)
+        ctx.setFillStyle('#666')
+        ctx.fillText((dims[di].score || '--') + '', sx, sy)
+      }
+
+      ctx.draw()
+    },
     getLevelText: function(level) {
       var map = {
         'excellent': '优秀',
@@ -350,69 +405,9 @@ export default {
   display: flex;
   justify-content: center;
 }
-.radar-center {
+.radar-canvas {
   width: 440rpx;
   height: 440rpx;
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-.radar-grid {
-  position: absolute;
-  width: 100%;
-  height: 100%;
-}
-.grid-ring {
-  position: absolute;
-  border-radius: 50%;
-  border: 1px solid #ddd;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -50%);
-}
-.ring-1 { width: 33%; height: 33%; }
-.ring-2 { width: 66%; height: 66%; }
-.ring-3 { width: 100%; height: 100%; }
-.radar-spokes {
-  position: absolute;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-.spoke-item {
-  position: absolute;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  transform-origin: center center;
-  width: 80rpx;
-  margin-left: -40rpx;
-  top: 10rpx;
-}
-.spoke-dot {
-  width: 20rpx;
-  height: 20rpx;
-  border-radius: 50%;
-  background: #FF8C42;
-  margin-bottom: 6rpx;
-}
-.spoke-dot.level-excellent { background: #10B981; }
-.spoke-dot.level-good { background: #3B82F6; }
-.spoke-dot.level-fair { background: #F59E0B; }
-.spoke-dot.level-poor { background: #EF4444; }
-.spoke-dot.level-none { background: #ddd; }
-.spoke-label {
-  font-size: 22rpx;
-  color: #333;
-  white-space: nowrap;
-}
-.spoke-score {
-  font-size: 20rpx;
-  color: #666;
-  margin-top: 2rpx;
 }
 .dimension-list {
   padding: 20rpx 30rpx;