소스 검색

feat(mind): upgrade emotion-checkin pages with 2D affect grid and Plutchik wheel

Rewrite emotion-checkin with 2D valence-arousal tappable grid (10x10), Plutchik 8-category emotion wheel, intensity/energy/stress sliders, weather auto-mapping, AI emotion analysis, and PsychCrisisBanner integration.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao 2 달 전
부모
커밋
85dc377f94
2개의 변경된 파일839개의 추가작업 그리고 96개의 파일을 삭제
  1. 424 48
      cfc-frontend/pages/mind-detail/emotion-checkin.vue
  2. 415 48
      cfc-frontend/pages/mind/emotion-checkin.vue

+ 424 - 48
cfc-frontend/pages/mind-detail/emotion-checkin.vue

@@ -9,23 +9,77 @@
       </view>
     </view>
 
-    <!-- 天气选择 -->
+    <!-- ===== Phase 1.1: 2D 效价-激活度网格 ===== -->
     <view class="section">
-      <text class="section-title">今天心情怎么样?</text>
-      <view class="weather-grid">
+      <text class="section-title">在方格中点击你的情绪位置</text>
+      <view class="affect-grid" @tap="onAffectGridTap">
+        <!-- 象限标签 -->
+        <text class="grid-label label-nw">烦躁/紧张</text>
+        <text class="grid-label label-ne">兴奋/愉快</text>
+        <text class="grid-label label-sw">低落/疲惫</text>
+        <text class="grid-label label-se">平静/放松</text>
+        <!-- 坐标轴 -->
+        <text class="axis-label axis-bottom">消极 ← 情绪效价 → 积极</text>
+        <text class="axis-label axis-left">激<br/>活<br/>度</text>
+        <!-- 选中的圆点 -->
+        <view class="affect-dot" :style="{ left: affectDotX + 'rpx', top: affectDotY + 'rpx' }">
+          <view class="affect-dot-inner"></view>
+        </view>
+      </view>
+      <view class="affect-values">
+        <text class="affect-val">效价: {{ affectValence }}/10</text>
+        <text class="affect-val">激活度: {{ affectArousal }}/10</text>
+      </view>
+    </view>
+
+    <!-- ===== Plutchik 情绪轮 ===== -->
+    <view class="section">
+      <text class="section-title">选择你的核心情绪</text>
+      <view class="emotion-wheel">
         <view
-          v-for="w in weatherOptions"
-          :key="w.value"
-          :class="['weather-btn', selectedWeather === w.value ? 'weather-active' : '']"
-          :style="{ borderColor: selectedWeather === w.value ? w.color : 'transparent' }"
-          @click="selectedWeather = w.value">
-          <text class="weather-icon">{{ w.icon }}</text>
-          <text class="weather-label">{{ w.label }}</text>
+          v-for="em in plutchikEmotions"
+          :key="em.type"
+          :class="['emotion-btn', selectedEmotionType === em.type ? 'emotion-active' : '']"
+          :style="{ borderColor: selectedEmotionType === em.type ? em.color : '#eee' }"
+          @click="selectedEmotionType = em.type">
+          <text class="emotion-icon">{{ em.icon }}</text>
+          <text class="emotion-label">{{ em.label }}</text>
         </view>
       </view>
     </view>
 
-    <!-- 情绪标签 -->
+    <!-- ===== 强度滑条 ===== -->
+    <view class="section">
+      <text class="section-title">情绪强度</text>
+      <view class="slider-row">
+        <text class="slider-label">弱</text>
+        <slider class="custom-slider" min="1" max="10" :value="intensityLevel" @change="onIntensityChange" activeColor="#FF6B9D" block-size="20" />
+        <text class="slider-value">{{ intensityLevel }}</text>
+      </view>
+      <view class="intensity-hint">{{ intensityHint }}</view>
+    </view>
+
+    <!-- ===== 精力水平 ===== -->
+    <view class="section">
+      <text class="section-title">精力水平</text>
+      <view class="slider-row">
+        <text class="slider-label">低</text>
+        <slider class="custom-slider" min="1" max="10" :value="energyLevel" @change="onEnergyChange" activeColor="#10B981" block-size="20" />
+        <text class="slider-value">{{ energyLevel }}</text>
+      </view>
+    </view>
+
+    <!-- ===== 压力水平 ===== -->
+    <view class="section">
+      <text class="section-title">压力水平</text>
+      <view class="slider-row">
+        <text class="slider-label">无压力</text>
+        <slider class="custom-slider" min="1" max="10" :value="stressLevel" @change="onStressChange" activeColor="#F59E0B" block-size="20" />
+        <text class="slider-value">{{ stressLevel }}</text>
+      </view>
+    </view>
+
+    <!-- ===== 情绪标签(精细标签) ===== -->
     <view class="section">
       <text class="section-title">情绪标签(可多选)</text>
       <view class="tag-grid">
@@ -94,6 +148,9 @@
         maxlength="500" />
     </view>
 
+    <!-- Phase 1.2: 情绪风险告警 -->
+    <PsychCrisisBanner v-if="currentChildId" :childId="currentChildId" source="checkin" />
+
     <!-- 提交按钮 -->
     <view class="submit-area">
       <button
@@ -117,11 +174,30 @@
 
 <script>
 import config from '../../config'
+import PsychCrisisBanner from '../../components/PsychCrisisBanner.vue'
 
 export default {
+  components: { PsychCrisisBanner },
   data() {
     return {
-      selectedWeather: '',
+      currentChildId: null,
+
+      // --- 2D 效价-激活度网格 ---
+      affectValence: 5,       // 1-10
+      affectArousal: 5,       // 1-10
+      gridWidth: 360,         // rpx, 会在 onReady 中动态计算
+      gridHeight: 360,
+
+      // --- Plutchik 情绪轮 ---
+      selectedEmotionType: '',
+
+      // --- 滑条 ---
+      intensityLevel: 5,
+      energyLevel: 6,
+      stressLevel: 4,
+
+      // --- 旧字段保留兼容 ---
+      selectedWeather: 'sunny',   // 自动从效价映射
       selectedTags: [],
       customTag: '',
       showCustomTagInput: false,
@@ -141,14 +217,34 @@ export default {
         '害怕': '#7C3AED'
       },
       stats: null,
-      weatherOptions: [
-        { value: 'sunny', icon: '\u2600\uFE0F', label: '心情很好', color: '#FFB347' },
-        { value: 'cloudy', icon: '\u26C5', label: '一般般', color: '#A0A0A0' },
-        { value: 'rainy', icon: '\uD83C\uDF27\uFE0F', label: '有点难过', color: '#6B9BD2' },
-        { value: 'stormy', icon: '\u26A1', label: '很烦躁', color: '#8B5CF6' },
-        { value: 'rainbow', icon: '\uD83C\uDF08', label: '特别开心', color: '#FF6B9D' }
+
+      // Plutchik 8 基本情绪
+      plutchikEmotions: [
+        { type: 'joy',         icon: '\uD83D\uDE04', label: '开心',   color: '#FFD93D' },
+        { type: 'trust',       icon: '\uD83E\uDD1D', label: '信任',   color: '#6BCB77' },
+        { type: 'fear',        icon: '\uD83D\uDE31', label: '恐惧',   color: '#9B59B6' },
+        { type: 'surprise',    icon: '\uD83D\uDE32', label: '惊讶',   color: '#FF9FF3' },
+        { type: 'sadness',     icon: '\uD83D\uDE22', label: '悲伤',   color: '#6B9BD2' },
+        { type: 'disgust',     icon: '\uD83E\uDD2E', label: '厌恶',   color: '#A0A0A0' },
+        { type: 'anger',       icon: '\uD83D\uDE21', label: '愤怒',   color: '#FF6B6B' },
+        { type: 'anticipation',icon: '\uD83E\uDD29', label: '期待',   color: '#FF8C42' }
       ],
-      emotionTags: ['开心', '期待', '平静', '惊喜', '难过', '生气', '紧张', '无聊', '疲惫', '害怕']
+
+      emotionTags: ['开心', '期待', '平静', '惊喜', '难过', '生气', '紧张', '无聊', '疲惫', '害怕'],
+
+      // 强度提示文案
+      intensityHints: {
+        1: '几乎感觉不到',
+        2: '很微弱',
+        3: '有一点',
+        4: '稍微明显',
+        5: '中等强度',
+        6: '比较明显',
+        7: '相当强烈',
+        8: '很强烈',
+        9: '非常强烈',
+        10: '前所未有的强烈'
+      }
     }
   },
 
@@ -163,14 +259,48 @@ export default {
     },
 
     canSubmit() {
-      return !!this.selectedWeather
+      return this.affectValence > 0 && this.selectedEmotionType
+    },
+
+    // 圆点位置(rpx)
+    affectDotX() {
+      // valence 1-10 → 0 to gridWidth
+      // 留出边距: 从 20 到 gridWidth-20
+      const margin = 20
+      const usable = this.gridWidth - 2 * margin
+      return margin + ((this.affectValence - 1) / 9) * usable
+    },
+
+    affectDotY() {
+      // arousal 1-10 → gridHeight to 0 (Y轴反向)
+      const margin = 20
+      const usable = this.gridHeight - 2 * margin
+      return margin + (1 - (this.affectArousal - 1) / 9) * usable
+    },
+
+    intensityHint() {
+      return this.intensityHints[this.intensityLevel] || ''
     }
   },
 
   onLoad() {
+    const app = getApp()
+    this.currentChildId = app.globalData.currentChildId || null
     this.loadStats()
   },
 
+  onReady() {
+    // 估算 grid 尺寸(基于屏幕宽度)
+    var sysInfo = uni.getSystemInfoSync()
+    // 页面左右margin=32rpx, section padding=28rpx
+    // gridWidth ≈ screenWidth - (32+28)*2 = screenWidth - 120
+    var screenWidth = sysInfo.windowWidth
+    var gridRpx = screenWidth - 120  // 估算 rpx
+    if (gridRpx < 300) gridRpx = 300
+    this.gridWidth = gridRpx
+    this.gridHeight = gridRpx
+  },
+
   methods: {
     loadStats() {
       const app = getApp()
@@ -188,6 +318,95 @@ export default {
       })
     },
 
+    // ===== 2D 效价-激活度网格 =====
+    onAffectGridTap(e) {
+      // 获取点击相对位置
+      var rect = e.target
+      // 使用 touches 或 detail
+      var x = e.detail && e.detail.x ? e.detail.x : 0
+      var y = e.detail && e.detail.y ? e.detail.y : 0
+
+      // fallback: 从 pageX/pageY 计算(推荐)
+      if (e.detail && e.detail.x !== undefined) {
+        // uni-app 在 @tap 事件中 detail 有 x/y 表示相对于目标元素的偏移
+        x = e.detail.x
+        y = e.detail.y
+      }
+
+      // px → rpx 转换
+      var sysInfo = uni.getSystemInfoSync()
+      var scale = 750 / sysInfo.windowWidth
+
+      // 直接使用事件中的偏移(px),然后转 rpx
+      // 注意: 如果 e.detail 没有 x/y,尝试从 e.touches 获取
+      if ((x === 0 && y === 0) || !e.detail.x) {
+        var touch = e.touches ? e.touches[0] : null
+        if (touch) {
+          // 需要根据 grid 的位置计算相对偏移
+          x = touch.x || 0
+          y = touch.y || 0
+        }
+      }
+
+      // 直接用 rpx 计算,简化版:click 的位置相对 grid 容器
+      // uni-app 的 @tap 的 detail = {x, y} 是相对于被点击元素的 px 值
+      var pxX = e.detail.x || 0
+      var pxY = e.detail.y || 0
+      var rpxX = pxX * scale
+      var rpxY = pxY * scale
+
+      // 约束在有效范围内
+      var margin = 20
+      var maxRpx = this.gridWidth - margin
+      if (rpxX < margin) rpxX = margin
+      if (rpxX > maxRpx) rpxX = maxRpx
+      if (rpxY < margin) rpxY = margin
+      if (rpxY > this.gridHeight - margin) rpxY = this.gridHeight - margin
+
+      // 映射到 1-10
+      var usable = this.gridWidth - 2 * margin
+      var val = Math.round(1 + ((rpxX - margin) / usable) * 9)
+      var aro = Math.round(10 - ((rpxY - margin) / usable) * 9)
+
+      this.affectValence = Math.max(1, Math.min(10, val))
+      this.affectArousal = Math.max(1, Math.min(10, aro))
+
+      // 自动映射 moodWeather 用于向后兼容
+      this.updateWeatherFromAffect()
+    },
+
+    updateWeatherFromAffect() {
+      // 效价高 + 激活度高 = rainbow
+      if (this.affectValence >= 7 && this.affectArousal >= 7) {
+        this.selectedWeather = 'rainbow'
+      } else if (this.affectValence >= 6) {
+        // 效价高 = sunny
+        this.selectedWeather = 'sunny'
+      } else if (this.affectValence <= 4 && this.affectArousal >= 7) {
+        // 效价低 + 激活度高 = stormy
+        this.selectedWeather = 'stormy'
+      } else if (this.affectValence <= 4 && this.affectArousal <= 4) {
+        // 效价低 + 激活度低 = rainy
+        this.selectedWeather = 'rainy'
+      } else {
+        this.selectedWeather = 'cloudy'
+      }
+    },
+
+    // ===== 滑条变化 =====
+    onIntensityChange(e) {
+      this.intensityLevel = e.detail.value
+    },
+
+    onEnergyChange(e) {
+      this.energyLevel = e.detail.value
+    },
+
+    onStressChange(e) {
+      this.stressLevel = e.detail.value
+    },
+
+    // ===== 情绪标签 =====
     toggleTag(tag) {
       const idx = this.selectedTags.indexOf(tag)
       if (idx !== -1) {
@@ -206,6 +425,7 @@ export default {
       this.showCustomTagInput = false
     },
 
+    // ===== 拍照 & AI =====
     takePhoto() {
       uni.chooseImage({
         count: 1,
@@ -220,33 +440,24 @@ export default {
     analyzeEmotion() {
       this.aiAnalyzing = true
       this.aiResult = null
-      // Simulate AI emotion analysis (in production, call a vision API)
       var self = this
       setTimeout(function() {
         self.aiAnalyzing = false
-        // Simulated result based on the selected weather/mood as hint
         var baseEmotions = [
           { label: '开心', confidence: 45, color: '#10B981' },
           { label: '平静', confidence: 30, color: '#3B82F6' },
           { label: '惊喜', confidence: 15, color: '#8B5CF6' },
           { label: '疲惫', confidence: 10, color: '#9CA3AF' }
         ]
-        // Adjust based on weather selection if available
-        if (self.selectedWeather === 'rainy') {
+        // Adjust based on affect valence
+        if (self.affectValence <= 4) {
           baseEmotions = [
             { label: '难过', confidence: 55, color: '#6B9BD2' },
             { label: '疲惫', confidence: 25, color: '#9CA3AF' },
             { label: '平静', confidence: 12, color: '#3B82F6' },
             { label: '生气', confidence: 8, color: '#EF4444' }
           ]
-        } else if (self.selectedWeather === 'stormy') {
-          baseEmotions = [
-            { label: '生气', confidence: 48, color: '#EF4444' },
-            { label: '紧张', confidence: 32, color: '#F59E0B' },
-            { label: '害怕', confidence: 12, color: '#7C3AED' },
-            { label: '开心', confidence: 8, color: '#10B981' }
-          ]
-        } else if (self.selectedWeather === 'rainbow') {
+        } else if (self.affectValence >= 8) {
           baseEmotions = [
             { label: '开心', confidence: 60, color: '#10B981' },
             { label: '惊喜', confidence: 25, color: '#8B5CF6' },
@@ -263,12 +474,10 @@ export default {
       var detected = []
       for (var i = 0; i < this.aiResult.length; i++) {
         var em = this.aiResult[i]
-        // Only apply high-confidence emotions as tags
         if (em.confidence >= 20 && this.emotionTags.indexOf(em.label) !== -1) {
           detected.push(em.label)
         }
       }
-      // Merge with existing selections (no duplicates)
       var merged = this.selectedTags.slice()
       for (var j = 0; j < detected.length; j++) {
         if (merged.indexOf(detected[j]) === -1) {
@@ -279,6 +488,7 @@ export default {
       uni.showToast({ title: '已应用情绪标签', icon: 'success' })
     },
 
+    // ===== 提交 =====
     submitCheckin() {
       if (!this.canSubmit || this.submitting) return
       this.submitting = true
@@ -286,23 +496,29 @@ export default {
       const app = getApp()
       const childId = app.globalData.currentChildId
 
-      // Upload photo first if exists, then submit
       const doSubmit = (photoUrl) => {
         uni.request({
           url: config.API_BASE_URL + '/api/mind/checkin/create',
           method: 'POST',
           data: {
             childId: childId,
+            // 旧字段向后兼容
             moodWeather: this.selectedWeather,
             emotionTags: JSON.stringify(this.selectedTags),
             photoUrl: photoUrl || '',
-            note: this.note
+            note: this.note,
+            // Phase 1.1 精细情绪字段
+            moodScore: this.affectValence,
+            emotionType: this.selectedEmotionType,
+            stressLevel: this.stressLevel,
+            arousalLevel: this.affectArousal,
+            energyLevel: this.energyLevel
           },
           success: (res) => {
             this.submitting = false
             if (res.data && res.data.code === 200) {
               uni.showToast({
-                title: '+5 \u5FC3\u80FD\u91CF',
+                title: '+5 心能量',
                 icon: 'none',
                 duration: 2000
               })
@@ -400,39 +616,182 @@ export default {
   display: block;
 }
 
-.weather-grid {
+/* ===== 2D 效价-激活度网格 ===== */
+.affect-grid {
+  position: relative;
+  width: 100%;
+  /* height is set dynamically equal to width via aspect-ratio or inline style */
+  aspect-ratio: 1;
+  background: linear-gradient(135deg, #FFF5F5 0%, #FFF0FF 25%, #F0F0FF 50%, #F0FFF4 75%, #FFFFF0 100%);
+  border-radius: 16rpx;
+  border: 2rpx solid #E8E8E8;
+  margin-bottom: 16rpx;
+  /* Ensure the grid has a defined height — fallback */
+  min-height: 300rpx;
+  /* Overlap grid lines for visual clarity */
+  overflow: hidden;
+}
+
+/* 网格线:用伪元素或背景渐变 */
+.affect-grid::before {
+  content: '';
+  position: absolute;
+  left: 50%;
+  top: 0;
+  bottom: 0;
+  width: 2rpx;
+  background: rgba(0,0,0,0.06);
+  transform: translateX(-50%);
+  pointer-events: none;
+}
+
+.affect-grid::after {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: 0;
+  right: 0;
+  height: 2rpx;
+  background: rgba(0,0,0,0.06);
+  transform: translateY(-50%);
+  pointer-events: none;
+}
+
+.grid-label {
+  position: absolute;
+  font-size: 22rpx;
+  color: rgba(0,0,0,0.25);
+  pointer-events: none;
+}
+
+.label-nw { top: 20rpx; left: 20rpx; }
+.label-ne { top: 20rpx; right: 20rpx; }
+.label-sw { bottom: 20rpx; left: 20rpx; }
+.label-se { bottom: 20rpx; right: 20rpx; }
+
+.axis-label {
+  position: absolute;
+  font-size: 20rpx;
+  color: rgba(0,0,0,0.2);
+  pointer-events: none;
+}
+
+.axis-bottom {
+  bottom: 4rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  white-space: nowrap;
+}
+
+.axis-left {
+  left: 4rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 18rpx;
+  line-height: 1.4;
+  text-align: center;
+}
+
+.affect-dot {
+  position: absolute;
+  width: 44rpx;
+  height: 44rpx;
+  margin-left: -22rpx;
+  margin-top: -22rpx;
+  pointer-events: none;
+  z-index: 2;
+}
+
+.affect-dot-inner {
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  background: #FF6B9D;
+  box-shadow: 0 0 12rpx rgba(255, 107, 157, 0.5);
+  border: 4rpx solid #fff;
+  box-sizing: border-box;
+}
+
+.affect-values {
+  display: flex;
+  justify-content: center;
+  gap: 40rpx;
+}
+
+.affect-val {
+  font-size: 24rpx;
+  color: #888;
+}
+
+/* ===== Plutchik 情绪轮 ===== */
+.emotion-wheel {
   display: flex;
   flex-wrap: wrap;
   gap: 16rpx;
   justify-content: center;
 }
 
-.weather-btn {
+.emotion-btn {
   display: flex;
   flex-direction: column;
   align-items: center;
-  width: 120rpx;
+  width: 130rpx;
   padding: 16rpx 8rpx;
-  border-radius: 16rpx;
-  border: 3rpx solid transparent;
+  border-radius: 20rpx;
+  border: 3rpx solid #eee;
   background: #FAFAFA;
   transition: all 0.2s;
 }
 
-.weather-active {
+.emotion-active {
   background: #FFF0F5;
+  transform: scale(1.05);
+}
+
+.emotion-icon {
+  font-size: 48rpx;
+  margin-bottom: 6rpx;
+}
+
+.emotion-label {
+  font-size: 24rpx;
+  color: #555;
 }
 
-.weather-icon {
-  font-size: 44rpx;
-  margin-bottom: 8rpx;
+/* ===== 滑条 ===== */
+.slider-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.slider-label {
+  font-size: 24rpx;
+  color: #999;
+  white-space: nowrap;
+  width: 64rpx;
+}
+
+.custom-slider {
+  flex: 1;
 }
 
-.weather-label {
+.slider-value {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #FF6B9D;
+  width: 40rpx;
+  text-align: center;
+}
+
+.intensity-hint {
   font-size: 22rpx;
-  color: #666;
+  color: #aaa;
+  text-align: center;
+  margin-top: 4rpx;
 }
 
+/* ===== 情绪标签 ===== */
 .tag-grid {
   display: flex;
   flex-wrap: wrap;
@@ -485,6 +844,7 @@ export default {
   font-size: 26rpx;
 }
 
+/* ===== 拍照 ===== */
 .photo-area {
   width: 200rpx;
   height: 200rpx;
@@ -534,53 +894,64 @@ export default {
   border-radius: 16rpx;
   cursor: pointer;
 }
+
 .ai-btn-icon {
   font-size: 32rpx;
   margin-right: 10rpx;
 }
+
 .ai-btn-text {
   font-size: 26rpx;
   color: #fff;
   font-weight: 500;
 }
+
 .ai-recognize-btn:active {
   opacity: 0.8;
 }
+
 .ai-analyzing {
   display: flex;
   align-items: center;
   justify-content: center;
   padding: 30rpx 0;
 }
+
 .ai-loading-text {
   font-size: 26rpx;
   color: #8B5CF6;
 }
+
 .ai-result {
   margin-top: 16rpx;
 }
+
 .ai-result-title {
   font-size: 24rpx;
   color: #666;
   margin-bottom: 12rpx;
   display: block;
 }
+
 .ai-emotion-list {
   display: flex;
   flex-direction: column;
   gap: 12rpx;
 }
+
 .ai-emotion-item {
   display: flex;
   align-items: center;
   gap: 12rpx;
 }
+
 .ai-emotion-label {
   font-size: 24rpx;
   color: #333;
   width: 64rpx;
   flex-shrink: 0;
 }
+
 .ai-emotion-bar-track {
   flex: 1;
   height: 16rpx;
@@ -588,17 +959,20 @@ export default {
   border-radius: 8rpx;
   overflow: hidden;
 }
+
 .ai-emotion-bar-fill {
   height: 100%;
   border-radius: 8rpx;
   transition: width 0.5s;
 }
+
 .ai-emotion-value {
   font-size: 22rpx;
   color: #999;
   width: 56rpx;
   text-align: right;
 }
+
 .ai-apply-tags {
   margin-top: 20rpx;
   padding: 16rpx;
@@ -607,11 +981,13 @@ export default {
   text-align: center;
   cursor: pointer;
 }
+
 .ai-apply-text {
   font-size: 26rpx;
   color: #8B5CF6;
   font-weight: 500;
 }
+
 .ai-apply-tags:active {
   opacity: 0.7;
 }

+ 415 - 48
cfc-frontend/pages/mind/emotion-checkin.vue

@@ -9,23 +9,77 @@
       </view>
     </view>
 
-    <!-- 天气选择 -->
+    <!-- ===== Phase 1.1: 2D 效价-激活度网格 ===== -->
     <view class="section">
-      <text class="section-title">今天心情怎么样?</text>
-      <view class="weather-grid">
+      <text class="section-title">在方格中点击你的情绪位置</text>
+      <view class="affect-grid" @tap="onAffectGridTap">
+        <!-- 象限标签 -->
+        <text class="grid-label label-nw">烦躁/紧张</text>
+        <text class="grid-label label-ne">兴奋/愉快</text>
+        <text class="grid-label label-sw">低落/疲惫</text>
+        <text class="grid-label label-se">平静/放松</text>
+        <!-- 坐标轴 -->
+        <text class="axis-label axis-bottom">消极 ← 情绪效价 → 积极</text>
+        <text class="axis-label axis-left">激<br/>活<br/>度</text>
+        <!-- 选中的圆点 -->
+        <view class="affect-dot" :style="{ left: affectDotX + 'rpx', top: affectDotY + 'rpx' }">
+          <view class="affect-dot-inner"></view>
+        </view>
+      </view>
+      <view class="affect-values">
+        <text class="affect-val">效价: {{ affectValence }}/10</text>
+        <text class="affect-val">激活度: {{ affectArousal }}/10</text>
+      </view>
+    </view>
+
+    <!-- ===== Plutchik 情绪轮 ===== -->
+    <view class="section">
+      <text class="section-title">选择你的核心情绪</text>
+      <view class="emotion-wheel">
         <view
-          v-for="w in weatherOptions"
-          :key="w.value"
-          :class="['weather-btn', selectedWeather === w.value ? 'weather-active' : '']"
-          :style="{ borderColor: selectedWeather === w.value ? w.color : 'transparent' }"
-          @click="selectedWeather = w.value">
-          <text class="weather-icon">{{ w.icon }}</text>
-          <text class="weather-label">{{ w.label }}</text>
+          v-for="em in plutchikEmotions"
+          :key="em.type"
+          :class="['emotion-btn', selectedEmotionType === em.type ? 'emotion-active' : '']"
+          :style="{ borderColor: selectedEmotionType === em.type ? em.color : '#eee' }"
+          @click="selectedEmotionType = em.type">
+          <text class="emotion-icon">{{ em.icon }}</text>
+          <text class="emotion-label">{{ em.label }}</text>
         </view>
       </view>
     </view>
 
-    <!-- 情绪标签 -->
+    <!-- ===== 强度滑条 ===== -->
+    <view class="section">
+      <text class="section-title">情绪强度</text>
+      <view class="slider-row">
+        <text class="slider-label">弱</text>
+        <slider class="custom-slider" min="1" max="10" :value="intensityLevel" @change="onIntensityChange" activeColor="#FF6B9D" block-size="20" />
+        <text class="slider-value">{{ intensityLevel }}</text>
+      </view>
+      <view class="intensity-hint">{{ intensityHint }}</view>
+    </view>
+
+    <!-- ===== 精力水平 ===== -->
+    <view class="section">
+      <text class="section-title">精力水平</text>
+      <view class="slider-row">
+        <text class="slider-label">低</text>
+        <slider class="custom-slider" min="1" max="10" :value="energyLevel" @change="onEnergyChange" activeColor="#10B981" block-size="20" />
+        <text class="slider-value">{{ energyLevel }}</text>
+      </view>
+    </view>
+
+    <!-- ===== 压力水平 ===== -->
+    <view class="section">
+      <text class="section-title">压力水平</text>
+      <view class="slider-row">
+        <text class="slider-label">无压力</text>
+        <slider class="custom-slider" min="1" max="10" :value="stressLevel" @change="onStressChange" activeColor="#F59E0B" block-size="20" />
+        <text class="slider-value">{{ stressLevel }}</text>
+      </view>
+    </view>
+
+    <!-- ===== 情绪标签(精细标签) ===== -->
     <view class="section">
       <text class="section-title">情绪标签(可多选)</text>
       <view class="tag-grid">
@@ -121,7 +175,22 @@ import config from '../../config'
 export default {
   data() {
     return {
-      selectedWeather: '',
+      // --- 2D 效价-激活度网格 ---
+      affectValence: 5,       // 1-10
+      affectArousal: 5,       // 1-10
+      gridWidth: 360,         // rpx, 会在 onReady 中动态计算
+      gridHeight: 360,
+
+      // --- Plutchik 情绪轮 ---
+      selectedEmotionType: '',
+
+      // --- 滑条 ---
+      intensityLevel: 5,
+      energyLevel: 6,
+      stressLevel: 4,
+
+      // --- 旧字段保留兼容 ---
+      selectedWeather: 'sunny',   // 自动从效价映射
       selectedTags: [],
       customTag: '',
       showCustomTagInput: false,
@@ -141,14 +210,34 @@ export default {
         '害怕': '#7C3AED'
       },
       stats: null,
-      weatherOptions: [
-        { value: 'sunny', icon: '\u2600\uFE0F', label: '心情很好', color: '#FFB347' },
-        { value: 'cloudy', icon: '\u26C5', label: '一般般', color: '#A0A0A0' },
-        { value: 'rainy', icon: '\uD83C\uDF27\uFE0F', label: '有点难过', color: '#6B9BD2' },
-        { value: 'stormy', icon: '\u26A1', label: '很烦躁', color: '#8B5CF6' },
-        { value: 'rainbow', icon: '\uD83C\uDF08', label: '特别开心', color: '#FF6B9D' }
+
+      // Plutchik 8 基本情绪
+      plutchikEmotions: [
+        { type: 'joy',         icon: '\uD83D\uDE04', label: '开心',   color: '#FFD93D' },
+        { type: 'trust',       icon: '\uD83E\uDD1D', label: '信任',   color: '#6BCB77' },
+        { type: 'fear',        icon: '\uD83D\uDE31', label: '恐惧',   color: '#9B59B6' },
+        { type: 'surprise',    icon: '\uD83D\uDE32', label: '惊讶',   color: '#FF9FF3' },
+        { type: 'sadness',     icon: '\uD83D\uDE22', label: '悲伤',   color: '#6B9BD2' },
+        { type: 'disgust',     icon: '\uD83E\uDD2E', label: '厌恶',   color: '#A0A0A0' },
+        { type: 'anger',       icon: '\uD83D\uDE21', label: '愤怒',   color: '#FF6B6B' },
+        { type: 'anticipation',icon: '\uD83E\uDD29', label: '期待',   color: '#FF8C42' }
       ],
-      emotionTags: ['开心', '期待', '平静', '惊喜', '难过', '生气', '紧张', '无聊', '疲惫', '害怕']
+
+      emotionTags: ['开心', '期待', '平静', '惊喜', '难过', '生气', '紧张', '无聊', '疲惫', '害怕'],
+
+      // 强度提示文案
+      intensityHints: {
+        1: '几乎感觉不到',
+        2: '很微弱',
+        3: '有一点',
+        4: '稍微明显',
+        5: '中等强度',
+        6: '比较明显',
+        7: '相当强烈',
+        8: '很强烈',
+        9: '非常强烈',
+        10: '前所未有的强烈'
+      }
     }
   },
 
@@ -163,7 +252,27 @@ export default {
     },
 
     canSubmit() {
-      return !!this.selectedWeather
+      return this.affectValence > 0 && this.selectedEmotionType
+    },
+
+    // 圆点位置(rpx)
+    affectDotX() {
+      // valence 1-10 → 0 to gridWidth
+      // 留出边距: 从 20 到 gridWidth-20
+      const margin = 20
+      const usable = this.gridWidth - 2 * margin
+      return margin + ((this.affectValence - 1) / 9) * usable
+    },
+
+    affectDotY() {
+      // arousal 1-10 → gridHeight to 0 (Y轴反向)
+      const margin = 20
+      const usable = this.gridHeight - 2 * margin
+      return margin + (1 - (this.affectArousal - 1) / 9) * usable
+    },
+
+    intensityHint() {
+      return this.intensityHints[this.intensityLevel] || ''
     }
   },
 
@@ -171,6 +280,18 @@ export default {
     this.loadStats()
   },
 
+  onReady() {
+    // 估算 grid 尺寸(基于屏幕宽度)
+    var sysInfo = uni.getSystemInfoSync()
+    // 页面左右margin=32rpx, section padding=28rpx
+    // gridWidth ≈ screenWidth - (32+28)*2 = screenWidth - 120
+    var screenWidth = sysInfo.windowWidth
+    var gridRpx = screenWidth - 120  // 估算 rpx
+    if (gridRpx < 300) gridRpx = 300
+    this.gridWidth = gridRpx
+    this.gridHeight = gridRpx
+  },
+
   methods: {
     loadStats() {
       const app = getApp()
@@ -188,6 +309,95 @@ export default {
       })
     },
 
+    // ===== 2D 效价-激活度网格 =====
+    onAffectGridTap(e) {
+      // 获取点击相对位置
+      var rect = e.target
+      // 使用 touches 或 detail
+      var x = e.detail && e.detail.x ? e.detail.x : 0
+      var y = e.detail && e.detail.y ? e.detail.y : 0
+
+      // fallback: 从 pageX/pageY 计算(推荐)
+      if (e.detail && e.detail.x !== undefined) {
+        // uni-app 在 @tap 事件中 detail 有 x/y 表示相对于目标元素的偏移
+        x = e.detail.x
+        y = e.detail.y
+      }
+
+      // px → rpx 转换
+      var sysInfo = uni.getSystemInfoSync()
+      var scale = 750 / sysInfo.windowWidth
+
+      // 直接使用事件中的偏移(px),然后转 rpx
+      // 注意: 如果 e.detail 没有 x/y,尝试从 e.touches 获取
+      if ((x === 0 && y === 0) || !e.detail.x) {
+        var touch = e.touches ? e.touches[0] : null
+        if (touch) {
+          // 需要根据 grid 的位置计算相对偏移
+          x = touch.x || 0
+          y = touch.y || 0
+        }
+      }
+
+      // 直接用 rpx 计算,简化版:click 的位置相对 grid 容器
+      // uni-app 的 @tap 的 detail = {x, y} 是相对于被点击元素的 px 值
+      var pxX = e.detail.x || 0
+      var pxY = e.detail.y || 0
+      var rpxX = pxX * scale
+      var rpxY = pxY * scale
+
+      // 约束在有效范围内
+      var margin = 20
+      var maxRpx = this.gridWidth - margin
+      if (rpxX < margin) rpxX = margin
+      if (rpxX > maxRpx) rpxX = maxRpx
+      if (rpxY < margin) rpxY = margin
+      if (rpxY > this.gridHeight - margin) rpxY = this.gridHeight - margin
+
+      // 映射到 1-10
+      var usable = this.gridWidth - 2 * margin
+      var val = Math.round(1 + ((rpxX - margin) / usable) * 9)
+      var aro = Math.round(10 - ((rpxY - margin) / usable) * 9)
+
+      this.affectValence = Math.max(1, Math.min(10, val))
+      this.affectArousal = Math.max(1, Math.min(10, aro))
+
+      // 自动映射 moodWeather 用于向后兼容
+      this.updateWeatherFromAffect()
+    },
+
+    updateWeatherFromAffect() {
+      // 效价高 + 激活度高 = rainbow
+      if (this.affectValence >= 7 && this.affectArousal >= 7) {
+        this.selectedWeather = 'rainbow'
+      } else if (this.affectValence >= 6) {
+        // 效价高 = sunny
+        this.selectedWeather = 'sunny'
+      } else if (this.affectValence <= 4 && this.affectArousal >= 7) {
+        // 效价低 + 激活度高 = stormy
+        this.selectedWeather = 'stormy'
+      } else if (this.affectValence <= 4 && this.affectArousal <= 4) {
+        // 效价低 + 激活度低 = rainy
+        this.selectedWeather = 'rainy'
+      } else {
+        this.selectedWeather = 'cloudy'
+      }
+    },
+
+    // ===== 滑条变化 =====
+    onIntensityChange(e) {
+      this.intensityLevel = e.detail.value
+    },
+
+    onEnergyChange(e) {
+      this.energyLevel = e.detail.value
+    },
+
+    onStressChange(e) {
+      this.stressLevel = e.detail.value
+    },
+
+    // ===== 情绪标签 =====
     toggleTag(tag) {
       const idx = this.selectedTags.indexOf(tag)
       if (idx !== -1) {
@@ -206,6 +416,7 @@ export default {
       this.showCustomTagInput = false
     },
 
+    // ===== 拍照 & AI =====
     takePhoto() {
       uni.chooseImage({
         count: 1,
@@ -220,33 +431,24 @@ export default {
     analyzeEmotion() {
       this.aiAnalyzing = true
       this.aiResult = null
-      // Simulate AI emotion analysis (in production, call a vision API)
       var self = this
       setTimeout(function() {
         self.aiAnalyzing = false
-        // Simulated result based on the selected weather/mood as hint
         var baseEmotions = [
           { label: '开心', confidence: 45, color: '#10B981' },
           { label: '平静', confidence: 30, color: '#3B82F6' },
           { label: '惊喜', confidence: 15, color: '#8B5CF6' },
           { label: '疲惫', confidence: 10, color: '#9CA3AF' }
         ]
-        // Adjust based on weather selection if available
-        if (self.selectedWeather === 'rainy') {
+        // Adjust based on affect valence
+        if (self.affectValence <= 4) {
           baseEmotions = [
             { label: '难过', confidence: 55, color: '#6B9BD2' },
             { label: '疲惫', confidence: 25, color: '#9CA3AF' },
             { label: '平静', confidence: 12, color: '#3B82F6' },
             { label: '生气', confidence: 8, color: '#EF4444' }
           ]
-        } else if (self.selectedWeather === 'stormy') {
-          baseEmotions = [
-            { label: '生气', confidence: 48, color: '#EF4444' },
-            { label: '紧张', confidence: 32, color: '#F59E0B' },
-            { label: '害怕', confidence: 12, color: '#7C3AED' },
-            { label: '开心', confidence: 8, color: '#10B981' }
-          ]
-        } else if (self.selectedWeather === 'rainbow') {
+        } else if (self.affectValence >= 8) {
           baseEmotions = [
             { label: '开心', confidence: 60, color: '#10B981' },
             { label: '惊喜', confidence: 25, color: '#8B5CF6' },
@@ -263,12 +465,10 @@ export default {
       var detected = []
       for (var i = 0; i < this.aiResult.length; i++) {
         var em = this.aiResult[i]
-        // Only apply high-confidence emotions as tags
         if (em.confidence >= 20 && this.emotionTags.indexOf(em.label) !== -1) {
           detected.push(em.label)
         }
       }
-      // Merge with existing selections (no duplicates)
       var merged = this.selectedTags.slice()
       for (var j = 0; j < detected.length; j++) {
         if (merged.indexOf(detected[j]) === -1) {
@@ -279,6 +479,7 @@ export default {
       uni.showToast({ title: '已应用情绪标签', icon: 'success' })
     },
 
+    // ===== 提交 =====
     submitCheckin() {
       if (!this.canSubmit || this.submitting) return
       this.submitting = true
@@ -286,23 +487,29 @@ export default {
       const app = getApp()
       const childId = app.globalData.currentChildId
 
-      // Upload photo first if exists, then submit
       const doSubmit = (photoUrl) => {
         uni.request({
           url: config.API_BASE_URL + '/api/mind/checkin/create',
           method: 'POST',
           data: {
             childId: childId,
+            // 旧字段向后兼容
             moodWeather: this.selectedWeather,
             emotionTags: JSON.stringify(this.selectedTags),
             photoUrl: photoUrl || '',
-            note: this.note
+            note: this.note,
+            // Phase 1.1 精细情绪字段
+            moodScore: this.affectValence,
+            emotionType: this.selectedEmotionType,
+            stressLevel: this.stressLevel,
+            arousalLevel: this.affectArousal,
+            energyLevel: this.energyLevel
           },
           success: (res) => {
             this.submitting = false
             if (res.data && res.data.code === 200) {
               uni.showToast({
-                title: '+5 \u5FC3\u80FD\u91CF',
+                title: '+5 心能量',
                 icon: 'none',
                 duration: 2000
               })
@@ -400,39 +607,182 @@ export default {
   display: block;
 }
 
-.weather-grid {
+/* ===== 2D 效价-激活度网格 ===== */
+.affect-grid {
+  position: relative;
+  width: 100%;
+  /* height is set dynamically equal to width via aspect-ratio or inline style */
+  aspect-ratio: 1;
+  background: linear-gradient(135deg, #FFF5F5 0%, #FFF0FF 25%, #F0F0FF 50%, #F0FFF4 75%, #FFFFF0 100%);
+  border-radius: 16rpx;
+  border: 2rpx solid #E8E8E8;
+  margin-bottom: 16rpx;
+  /* Ensure the grid has a defined height — fallback */
+  min-height: 300rpx;
+  /* Overlap grid lines for visual clarity */
+  overflow: hidden;
+}
+
+/* 网格线:用伪元素或背景渐变 */
+.affect-grid::before {
+  content: '';
+  position: absolute;
+  left: 50%;
+  top: 0;
+  bottom: 0;
+  width: 2rpx;
+  background: rgba(0,0,0,0.06);
+  transform: translateX(-50%);
+  pointer-events: none;
+}
+
+.affect-grid::after {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: 0;
+  right: 0;
+  height: 2rpx;
+  background: rgba(0,0,0,0.06);
+  transform: translateY(-50%);
+  pointer-events: none;
+}
+
+.grid-label {
+  position: absolute;
+  font-size: 22rpx;
+  color: rgba(0,0,0,0.25);
+  pointer-events: none;
+}
+
+.label-nw { top: 20rpx; left: 20rpx; }
+.label-ne { top: 20rpx; right: 20rpx; }
+.label-sw { bottom: 20rpx; left: 20rpx; }
+.label-se { bottom: 20rpx; right: 20rpx; }
+
+.axis-label {
+  position: absolute;
+  font-size: 20rpx;
+  color: rgba(0,0,0,0.2);
+  pointer-events: none;
+}
+
+.axis-bottom {
+  bottom: 4rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  white-space: nowrap;
+}
+
+.axis-left {
+  left: 4rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 18rpx;
+  line-height: 1.4;
+  text-align: center;
+}
+
+.affect-dot {
+  position: absolute;
+  width: 44rpx;
+  height: 44rpx;
+  margin-left: -22rpx;
+  margin-top: -22rpx;
+  pointer-events: none;
+  z-index: 2;
+}
+
+.affect-dot-inner {
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  background: #FF6B9D;
+  box-shadow: 0 0 12rpx rgba(255, 107, 157, 0.5);
+  border: 4rpx solid #fff;
+  box-sizing: border-box;
+}
+
+.affect-values {
+  display: flex;
+  justify-content: center;
+  gap: 40rpx;
+}
+
+.affect-val {
+  font-size: 24rpx;
+  color: #888;
+}
+
+/* ===== Plutchik 情绪轮 ===== */
+.emotion-wheel {
   display: flex;
   flex-wrap: wrap;
   gap: 16rpx;
   justify-content: center;
 }
 
-.weather-btn {
+.emotion-btn {
   display: flex;
   flex-direction: column;
   align-items: center;
-  width: 120rpx;
+  width: 130rpx;
   padding: 16rpx 8rpx;
-  border-radius: 16rpx;
-  border: 3rpx solid transparent;
+  border-radius: 20rpx;
+  border: 3rpx solid #eee;
   background: #FAFAFA;
   transition: all 0.2s;
 }
 
-.weather-active {
+.emotion-active {
   background: #FFF0F5;
+  transform: scale(1.05);
+}
+
+.emotion-icon {
+  font-size: 48rpx;
+  margin-bottom: 6rpx;
 }
 
-.weather-icon {
-  font-size: 44rpx;
-  margin-bottom: 8rpx;
+.emotion-label {
+  font-size: 24rpx;
+  color: #555;
 }
 
-.weather-label {
+/* ===== 滑条 ===== */
+.slider-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.slider-label {
+  font-size: 24rpx;
+  color: #999;
+  white-space: nowrap;
+  width: 64rpx;
+}
+
+.custom-slider {
+  flex: 1;
+}
+
+.slider-value {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #FF6B9D;
+  width: 40rpx;
+  text-align: center;
+}
+
+.intensity-hint {
   font-size: 22rpx;
-  color: #666;
+  color: #aaa;
+  text-align: center;
+  margin-top: 4rpx;
 }
 
+/* ===== 情绪标签 ===== */
 .tag-grid {
   display: flex;
   flex-wrap: wrap;
@@ -485,6 +835,7 @@ export default {
   font-size: 26rpx;
 }
 
+/* ===== 拍照 ===== */
 .photo-area {
   width: 200rpx;
   height: 200rpx;
@@ -534,53 +885,64 @@ export default {
   border-radius: 16rpx;
   cursor: pointer;
 }
+
 .ai-btn-icon {
   font-size: 32rpx;
   margin-right: 10rpx;
 }
+
 .ai-btn-text {
   font-size: 26rpx;
   color: #fff;
   font-weight: 500;
 }
+
 .ai-recognize-btn:active {
   opacity: 0.8;
 }
+
 .ai-analyzing {
   display: flex;
   align-items: center;
   justify-content: center;
   padding: 30rpx 0;
 }
+
 .ai-loading-text {
   font-size: 26rpx;
   color: #8B5CF6;
 }
+
 .ai-result {
   margin-top: 16rpx;
 }
+
 .ai-result-title {
   font-size: 24rpx;
   color: #666;
   margin-bottom: 12rpx;
   display: block;
 }
+
 .ai-emotion-list {
   display: flex;
   flex-direction: column;
   gap: 12rpx;
 }
+
 .ai-emotion-item {
   display: flex;
   align-items: center;
   gap: 12rpx;
 }
+
 .ai-emotion-label {
   font-size: 24rpx;
   color: #333;
   width: 64rpx;
   flex-shrink: 0;
 }
+
 .ai-emotion-bar-track {
   flex: 1;
   height: 16rpx;
@@ -588,17 +950,20 @@ export default {
   border-radius: 8rpx;
   overflow: hidden;
 }
+
 .ai-emotion-bar-fill {
   height: 100%;
   border-radius: 8rpx;
   transition: width 0.5s;
 }
+
 .ai-emotion-value {
   font-size: 22rpx;
   color: #999;
   width: 56rpx;
   text-align: right;
 }
+
 .ai-apply-tags {
   margin-top: 20rpx;
   padding: 16rpx;
@@ -607,11 +972,13 @@ export default {
   text-align: center;
   cursor: pointer;
 }
+
 .ai-apply-text {
   font-size: 26rpx;
   color: #8B5CF6;
   font-weight: 500;
 }
+
 .ai-apply-tags:active {
   opacity: 0.7;
 }