Jelajahi Sumber

feat(cfc-frontend): emotion checkin add video capture + enhanced AI simulation

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 3 bulan lalu
induk
melakukan
baa9b9e25b
1 mengubah file dengan 257 tambahan dan 50 penghapusan
  1. 257 50
      cfc-frontend/pages/mind-detail/emotion-checkin.vue

+ 257 - 50
cfc-frontend/pages/mind-detail/emotion-checkin.vue

@@ -98,25 +98,37 @@
       <text class="custom-tag-trigger" v-if="!showCustomTagInput" @click="showCustomTagInput = true">+ 自定义标签</text>
     </view>
 
-    <!-- 拍照 -->
+    <!-- 拍照/录像 -->
     <view class="section">
-      <text class="section-title">拍一张照片记录心情</text>
-      <view class="photo-area" @click="takePhoto">
-        <image v-if="photoPath" :src="photoPath" mode="aspectFill" class="photo-preview" />
-        <view v-else class="photo-placeholder">
-          <text class="photo-icon">&#x1F4F7;</text>
-          <text class="photo-hint">点击拍照</text>
+      <text class="section-title">拍一张照片或一段视频记录心情</text>
+      <view class="capture-mode-bar">
+        <text
+          :class="['mode-btn', captureMode === 'photo' ? 'mode-active' : '']"
+          @click="switchCaptureMode('photo')">📷 拍照</text>
+        <text
+          :class="['mode-btn', captureMode === 'video' ? 'mode-active' : '']"
+          @click="switchCaptureMode('video')">🎬 录像</text>
+      </view>
+      <view class="photo-area" @click="takeMedia">
+        <!-- 照片预览 -->
+        <image v-if="captureMode === 'photo' && photoPath" :src="photoPath" mode="aspectFill" class="photo-preview" />
+        <!-- 视频预览 -->
+        <video v-if="captureMode === 'video' && videoPath" :src="videoPath" class="video-preview" objectFit="cover" autoplay muted loop />
+        <!-- 占位 -->
+        <view v-else-if="!photoPath && !videoPath" class="photo-placeholder">
+          <text class="photo-icon">{{ captureMode === 'photo' ? '\u{1F4F7}' : '\u{1F3AC}' }}</text>
+          <text class="photo-hint">{{ captureMode === 'photo' ? '点击拍照' : '点击录像' }}</text>
         </view>
       </view>
-      <text class="photo-note">照片仅对自己可见</text>
+      <text class="photo-note">{{ captureMode === 'photo' ? '照片' : '视频' }}仅对自己可见</text>
     </view>
 
     <!-- AI 情绪识别 -->
-    <view class="section" v-if="photoPath">
+    <view class="section" v-if="photoPath || videoPath">
       <text class="section-title">\uD83E\uDD16 AI 情绪识别</text>
       <view class="ai-recognize-btn" v-if="!aiResult && !aiAnalyzing" @click="analyzeEmotion">
         <text class="ai-btn-icon">\uD83D\uDD0D</text>
-        <text class="ai-btn-text">识别照片中的情绪</text>
+        <text class="ai-btn-text">{{ captureMode === 'video' ? '识别视频中的情绪' : '识别照片中的情绪' }}</text>
       </view>
       <view class="ai-analyzing" v-if="aiAnalyzing">
         <text class="ai-loading-text">AI 分析中...</text>
@@ -201,7 +213,9 @@ export default {
       selectedTags: [],
       customTag: '',
       showCustomTagInput: false,
+      captureMode: 'photo',
       photoPath: '',
+      videoPath: '',
       note: '',
       submitting: false,
       aiAnalyzing: false,
@@ -425,48 +439,212 @@ export default {
       this.showCustomTagInput = false
     },
 
-    // ===== 拍照 & AI =====
-    takePhoto() {
-      uni.chooseImage({
-        count: 1,
-        sourceType: ['camera', 'album'],
-        success: (res) => {
-          this.photoPath = res.tempFilePaths[0]
-          this.aiResult = null
-        }
-      })
+    // ===== 拍照/录像 & AI =====
+    switchCaptureMode(mode) {
+      this.captureMode = mode
+      this.photoPath = ''
+      this.videoPath = ''
+      this.aiResult = null
+    },
+
+    takeMedia() {
+      var self = this
+      if (this.captureMode === 'photo') {
+        uni.chooseImage({
+          count: 1,
+          sourceType: ['camera', 'album'],
+          success: function(res) {
+            self.photoPath = res.tempFilePaths[0]
+            self.videoPath = ''
+            self.aiResult = null
+          }
+        })
+      } else {
+        uni.chooseVideo({
+          sourceType: ['camera', 'album'],
+          maxDuration: 15,
+          success: function(res) {
+            self.videoPath = res.tempFilePath
+            self.photoPath = ''
+            self.aiResult = null
+          }
+        })
+      }
     },
 
+    // ===== 增强型 AI 模拟识别 =====
+    // 基于效价/激活度/时段/强度等多维参数,生成更自然的模拟结果
     analyzeEmotion() {
       this.aiAnalyzing = true
       this.aiResult = null
       var self = this
+
+      // 生成伪随机种子(不同照片/视频产生不同结果,模拟真实识别)
+      var seed = this.genSeed()
+
+      // 根据多维参数生成情绪结果
+      var results = this.genEmotionResults(seed)
+
+      // 处理时间随机变化(200-1600ms,让每次等待不一样)
+      var delay = 600 + (seed % 1000)
+
       setTimeout(function() {
+        self.aiResult = results
         self.aiAnalyzing = false
-        var baseEmotions = [
-          { label: '开心', confidence: 45, color: '#10B981' },
-          { label: '平静', confidence: 30, color: '#3B82F6' },
-          { label: '惊喜', confidence: 15, color: '#8B5CF6' },
-          { label: '疲惫', confidence: 10, color: '#9CA3AF' }
+      }, delay)
+    },
+
+    // 基于文件路径 + 时间生成伪随机种子
+    genSeed() {
+      var path = this.photoPath || this.videoPath || ''
+      var hash = 0
+      for (var i = 0; i < path.length; i++) {
+        hash = ((hash << 5) - hash) + path.charCodeAt(i)
+        hash = hash & hash
+      }
+      hash = Math.abs(hash)
+      return (hash + Date.now()) % 7919  // 质数取模
+    },
+
+    // 多维参数 → 情绪结果
+    genEmotionResults(seed) {
+      var valence = this.affectValence
+      var arousal = this.affectArousal
+
+      // 1. 从效价-激活度象限推导主要情绪分布
+      var emotionPool = []
+
+      if (valence >= 6 && arousal >= 6) {
+        // Q1: 高激活 + 积极 → 开心/兴奋/惊喜
+        emotionPool = [
+          { label: '开心', base: 38, color: '#10B981' },
+          { label: '兴奋', base: 22, color: '#8B5CF6' },
+          { label: '惊喜', base: 18, color: '#FF9FF3' },
+          { label: '满足', base: 12, color: '#3B82F6' },
+          { label: '期待', base: 10, color: '#F59E0B' }
+        ]
+      } else if (valence >= 6 && arousal < 6) {
+        // Q2: 低激活 + 积极 → 平静/满足/放松
+        emotionPool = [
+          { label: '平静', base: 32, color: '#3B82F6' },
+          { label: '满足', base: 25, color: '#10B981' },
+          { label: '放松', base: 20, color: '#6B9BD2' },
+          { label: '开心', base: 15, color: '#10B981' },
+          { label: '无聊', base: 8, color: '#9CA3AF' }
+        ]
+      } else if (valence < 6 && arousal >= 6) {
+        // Q3: 高激活 + 消极 → 生气/紧张/恐惧
+        emotionPool = [
+          { label: '紧张', base: 30, color: '#F59E0B' },
+          { label: '生气', base: 25, color: '#EF4444' },
+          { label: '烦躁', base: 20, color: '#FF6B6B' },
+          { label: '恐惧', base: 15, color: '#7C3AED' },
+          { label: '厌恶', base: 10, color: '#A0A0A0' }
+        ]
+      } else {
+        // Q4: 低激活 + 消极 → 难过/疲惫/低落
+        emotionPool = [
+          { label: '难过', base: 32, color: '#6B9BD2' },
+          { label: '疲惫', base: 28, color: '#9CA3AF' },
+          { label: '低落', base: 20, color: '#6B7280' },
+          { label: '平静', base: 12, color: '#3B82F6' },
+          { label: '无聊', base: 8, color: '#9CA3AF' }
         ]
-        // 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.affectValence >= 8) {
-          baseEmotions = [
-            { label: '开心', confidence: 60, color: '#10B981' },
-            { label: '惊喜', confidence: 25, color: '#8B5CF6' },
-            { label: '平静', confidence: 10, color: '#3B82F6' },
-            { label: '期待', confidence: 5, color: '#F59E0B' }
-          ]
+      }
+
+      // 2. 根据强度/压力/能量微调
+      // 高强度 → 强化 top1
+      if (this.intensityLevel >= 7) {
+        emotionPool[0].base += 10
+      } else if (this.intensityLevel <= 3) {
+        // 低强度 → 向平静偏移
+        for (var si = 0; si < emotionPool.length; si++) {
+          if (emotionPool[si].label === '平静' || emotionPool[si].label === '无聊') {
+            emotionPool[si].base += 8
+          }
+        }
+      }
+
+      // 高压力 → 添加紧张/烦躁倾向
+      if (this.stressLevel >= 7) {
+        var tensionIdx = -1
+        var angerIdx = -1
+        for (var ti = 0; ti < emotionPool.length; ti++) {
+          if (emotionPool[ti].label === '紧张') tensionIdx = ti
+          if (emotionPool[ti].label === '烦躁') angerIdx = ti
+        }
+        if (tensionIdx >= 0) emotionPool[tensionIdx].base += 12
+        if (angerIdx >= 0) emotionPool[angerIdx].base += 8
+      }
+
+      // 3. 根据 Plutchik 已选情绪提升权重(用户选择了说明有倾向)
+      if (this.selectedEmotionType) {
+        var typeToLabel = {
+          joy: '开心', trust: '信任', fear: '恐惧',
+          surprise: '惊喜', sadness: '难过', disgust: '厌恶',
+          anger: '生气', anticipation: '期待'
+        }
+        var matchedLabel = typeToLabel[this.selectedEmotionType]
+        for (var mi = 0; mi < emotionPool.length; mi++) {
+          if (emotionPool[mi].label === matchedLabel) {
+            emotionPool[mi].base += 15
+          }
+        }
+      }
+
+      // 4. 根据时段微调
+      var hour = new Date().getHours()
+      if (hour < 6) {
+        // 深夜:偏向疲惫/低落
+        this.adjustPoolForTime(emotionPool, '疲惫', 10)
+        this.adjustPoolForTime(emotionPool, '低落', 8)
+      } else if (hour < 10) {
+        // 早晨:偏向积极
+        this.adjustPoolForTime(emotionPool, '开心', 8)
+        this.adjustPoolForTime(emotionPool, '期待', 6)
+      } else if (hour >= 22) {
+        // 夜晚:偏向平静/疲惫
+        this.adjustPoolForTime(emotionPool, '平静', 8)
+        this.adjustPoolForTime(emotionPool, '疲惫', 6)
+      }
+
+      // 5. 引入伪随机噪声(让每次结果有细微差异)
+      for (var ni = 0; ni < emotionPool.length; ni++) {
+        var noise = (seed * (ni + 1) * 7) % 11 - 5  // -5 ~ +5
+        emotionPool[ni].base += noise
+        if (emotionPool[ni].base < 3) emotionPool[ni].base = 3
+      }
+
+      // 6. 降序排列,取 top 4,归一化到 100%
+      emotionPool.sort(function(a, b) { return b.base - a.base })
+      var top4 = emotionPool.slice(0, 4)
+      var total = 0
+      for (var sumi = 0; sumi < top4.length; sumi++) {
+        total += top4[sumi].base
+      }
+      for (var normi = 0; normi < top4.length; normi++) {
+        top4[normi].confidence = Math.round((top4[normi].base / total) * 100)
+      }
+      // 补余数确保 =100
+      var sumConf = 0
+      for (var ci = 0; ci < top4.length; ci++) {
+        sumConf += top4[ci].confidence
+      }
+      var diff = 100 - sumConf
+      if (diff !== 0 && top4.length > 0) {
+        top4[0].confidence += diff
+      }
+
+      return top4
+    },
+
+    adjustPoolForTime(pool, label, delta) {
+      for (var i = 0; i < pool.length; i++) {
+        if (pool[i].label === label) {
+          pool[i].base += delta
+          return
         }
-        self.aiResult = baseEmotions
-      }, 1200)
+      }
     },
 
     applyAiTags() {
@@ -496,7 +674,7 @@ export default {
       const app = getApp()
       const childId = app.globalData.currentChildId
 
-      const doSubmit = (photoUrl) => {
+      const doSubmit = (mediaUrl) => {
         uni.request({
           url: config.API_BASE_URL + '/api/mind/checkin/create',
           method: 'POST',
@@ -505,7 +683,9 @@ export default {
             // 旧字段向后兼容
             moodWeather: this.selectedWeather,
             emotionTags: JSON.stringify(this.selectedTags),
-            photoUrl: photoUrl || '',
+            photoUrl: this.captureMode === 'photo' ? (mediaUrl || '') : '',
+            videoUrl: this.captureMode === 'video' ? (mediaUrl || '') : '',
+            mediaType: this.captureMode,
             note: this.note,
             // Phase 1.1 精细情绪字段
             moodScore: this.affectValence,
@@ -536,20 +716,21 @@ export default {
         })
       }
 
-      if (this.photoPath) {
+      var mediaPath = this.photoPath || this.videoPath
+      if (mediaPath) {
         uni.uploadFile({
           url: config.API_BASE_URL + '/api/upload',
-          filePath: this.photoPath,
+          filePath: mediaPath,
           name: 'file',
           success: (uploadRes) => {
-            let photoUrl = ''
+            let mediaUrl = ''
             try {
               const data = JSON.parse(uploadRes.data)
-              photoUrl = data.data || data.url || ''
+              mediaUrl = data.data || data.url || ''
             } catch (e) {
-              photoUrl = uploadRes.data
+              mediaUrl = uploadRes.data
             }
-            doSubmit(photoUrl)
+            doSubmit(mediaUrl)
           },
           fail: () => doSubmit('')
         })
@@ -884,6 +1065,32 @@ export default {
   margin-top: 12rpx;
 }
 
+/* ===== 拍照/录像模式切换 ===== */
+.capture-mode-bar {
+  display: flex;
+  gap: 20rpx;
+  margin-bottom: 20rpx;
+}
+.mode-btn {
+  padding: 10rpx 28rpx;
+  border-radius: 30rpx;
+  font-size: 26rpx;
+  color: #999;
+  background: #f5f5f5;
+  border: 2rpx solid #eee;
+}
+.mode-btn.mode-active {
+  color: #FF6B9D;
+  background: #FFF0F5;
+  border-color: #FF6B9D;
+  font-weight: 500;
+}
+.video-preview {
+  width: 100%;
+  height: 100%;
+  border-radius: 16rpx;
+}
+
 /* ===== AI 情绪识别 ===== */
 .ai-recognize-btn {
   display: flex;