Procházet zdrojové kódy

chore: auto bump version and changelog [skip ci]

iwt před 2 měsíci
rodič
revize
c05bf53885

+ 24 - 0
cfc-frontend/pages.json

@@ -440,6 +440,30 @@
           "style": {
           "style": {
             "navigationBarTitleText": "饮食推荐"
             "navigationBarTitleText": "饮食推荐"
           }
           }
+        },
+        {
+          "path": "diet-checkin",
+          "style": {
+            "navigationBarTitleText": "饮食打卡"
+          }
+        },
+        {
+          "path": "exercise-checkin",
+          "style": {
+            "navigationBarTitleText": "运动打卡"
+          }
+        },
+        {
+          "path": "sleep-checkin",
+          "style": {
+            "navigationBarTitleText": "睡眠打卡"
+          }
+        },
+        {
+          "path": "emotion-checkin",
+          "style": {
+            "navigationBarTitleText": "心情打卡"
+          }
         }
         }
       ]
       ]
     },
     },

+ 37 - 128
cfc-frontend/pages/growth-main/index.vue

@@ -141,6 +141,30 @@
         <text class="reminder-text">⚠ 今日还有 {{ incompleteCount }} 项未记录</text>
         <text class="reminder-text">⚠ 今日还有 {{ incompleteCount }} 项未记录</text>
         <text class="reminder-close" @click="dismissReminder">✕</text>
         <text class="reminder-close" @click="dismissReminder">✕</text>
       </view>
       </view>
+      <!-- 里程碑(移至最上) -->
+      <view class="section">
+        <view class="milestone-card">
+          <text class="section-title-row">🏆 里程碑</text>
+          <view class="milestone-grid">
+            <view class="milestone-item">
+              <text class="milestone-icon">🔥</text>
+              <text class="milestone-value">{{ streakDays }}天</text>
+              <text class="milestone-label">连续打卡</text>
+            </view>
+            <view class="milestone-item">
+              <text class="milestone-icon">🎯</text>
+              <text class="milestone-value">{{ activeChallenges }}</text>
+              <text class="milestone-label">进行中挑战</text>
+            </view>
+            <view class="milestone-item">
+              <text class="milestone-icon">📈</text>
+              <text class="milestone-value">{{ completeness }}%</text>
+              <text class="milestone-label">档案完整度</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <!-- 今日成长 -->
       <view class="section">
       <view class="section">
         <view class="progress-card">
         <view class="progress-card">
           <view class="progress-header">
           <view class="progress-header">
@@ -170,8 +194,7 @@
           <text class="action-arrow">›</text>
           <text class="action-arrow">›</text>
         </view>
         </view>
       </view>
       </view>
-
-      <!-- 饮食推荐(恢复) -->
+      <!-- 饮食推荐 -->
       <view class="section">
       <view class="section">
         <view class="action-card" @click="goToFoods">
         <view class="action-card" @click="goToFoods">
           <text class="action-icon">🍽️</text>
           <text class="action-icon">🍽️</text>
@@ -182,68 +205,15 @@
           <text class="action-arrow">›</text>
           <text class="action-arrow">›</text>
         </view>
         </view>
       </view>
       </view>
-
-      <view class="section">
-        <view class="tracking-card">
-          <view class="tracking-tabs">
-            <view class="tracking-tab" :class="activeTab === tab.key ? 'tab-active' : ''" v-for="tab in trackingTabs" :key="tab.key" @click="switchTab(tab.key)">
-              <text class="tab-text" :class="activeTab === tab.key ? 'tab-active-text' : ''">{{ tab.label }}</text>
-            </view>
-          </view>
-          <view class="tracking-content">
-            <view class="tracking-empty" v-if="currentRecords.length === 0">
-              <text class="empty-icon">📊</text>
-              <text class="empty-text">还没有记录</text>
-              <text class="empty-hint">开始记录{{ activeTabLabel }}吧</text>
-            </view>
-            <view class="tracking-record" v-for="(rec, rIdx) in currentRecords" :key="getRecordKey(rec)">
-              <text class="record-date">{{ rec._date || rec._text }}</text>
-              <text class="record-content">{{ rec._content }}</text>
-            </view>
-            <view class="tracking-more" v-if="currentRecords.length > 0" @click="viewAllRecords">查看全部 ›</view>
-          </view>
-        </view>
-      </view>
-      <view class="section">
-        <view class="milestone-card">
-          <text class="section-title-row">🏆 里程碑</text>
-          <view class="milestone-grid">
-            <view class="milestone-item">
-              <text class="milestone-icon">🔥</text>
-              <text class="milestone-value">{{ streakDays }}天</text>
-              <text class="milestone-label">连续打卡</text>
-            </view>
-            <view class="milestone-item">
-              <text class="milestone-icon">🎯</text>
-              <text class="milestone-value">{{ activeChallenges }}</text>
-              <text class="milestone-label">进行中挑战</text>
-            </view>
-            <view class="milestone-item">
-              <text class="milestone-icon">📈</text>
-              <text class="milestone-value">{{ completeness }}%</text>
-              <text class="milestone-label">档案完整度</text>
-            </view>
-          </view>
-        </view>
-      </view>
+      <!-- 任务中心(替代打卡记录) -->
       <view class="section">
       <view class="section">
-        <view class="survey-card">
-          <text class="section-title-row">📋 成长记录</text>
-          <view class="survey-pending" v-if="pendingSurvey">
-            <text class="survey-alert">⚠ 本周还未完成调研</text>
-            <text class="survey-title-text">{{ pendingSurvey.title }}</text>
-            <text class="survey-btn" @click="startSurvey">[开始填写]</text>
-          </view>
-          <view class="survey-done" v-else-if="allDone"><text class="survey-done-text">✅ 所有调研已完成</text></view>
-          <view class="survey-history-list" v-if="surveyHistory.length > 0">
-            <text class="history-label">历史调研:</text>
-            <view class="history-item" v-for="(h, hIdx) in surveyHistory" :key="getHistoryKey(h)">
-              <text class="history-date">{{ h._date }}</text>
-              <text class="history-content">{{ h._content }}</text>
-            </view>
-            <text class="survey-more" @click="viewAllHistory">查看全部调研 ›</text>
+        <view class="action-card" @click="goToTasks">
+          <text class="action-icon">📋</text>
+          <view class="action-info">
+            <text class="action-title">任务中心</text>
+            <text class="action-desc">查看今日待办任务,完成挑战获取奖励</text>
           </view>
           </view>
-          <view class="survey-empty" v-else-if="!pendingSurvey"><text class="survey-empty-text">暂无待完成调研</text></view>
+          <text class="action-arrow">›</text>
         </view>
         </view>
       </view>
       </view>
       <DimensionActivities :isLoggedIn="true" @activityClick="goActivityDetail" @moreActivities="goMoreActivities" style="margin-bottom:60rpx" />
       <DimensionActivities :isLoggedIn="true" @activityClick="goActivityDetail" @moreActivities="goMoreActivities" style="margin-bottom:60rpx" />
@@ -276,19 +246,17 @@ import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
-import { healthCheckinList, getHealthMealList, getSurveyStatus, getSurveyHistory, getFoodCautionList, getGrowthRecommendations, getExerciseRecords, getSleepRecords, getEmotionCheckinList } from '../../utils/api.js'
+import { healthCheckinList, getHealthMealList, getFoodCautionList, getGrowthRecommendations, getExerciseRecords, getSleepRecords, getEmotionCheckinList } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
 export default {
 export default {
   components: { PageBanner, LoginGuideCard, DimensionActivities, DimensionProducts, ArticleBookshelf },
   components: { PageBanner, LoginGuideCard, DimensionActivities, DimensionProducts, ArticleBookshelf },
-  data() { return { memberId: '', todayDate: '', hasIncomplete: false, incompleteCount: 0, progressItems: [], activeTab: 'diet', trackingTabs: [{ key: 'diet', label: '饮食' }, { key: 'exercise', label: '运动' }, { key: 'sleep', label: '睡眠' }, { key: 'emotion', label: '心情' }], trackingData: { diet: [], exercise: [], sleep: [], emotion: [] }, streakDays: 0, activeChallenges: 0, completeness: 0, cautionCount: 0,
+  data() { return { memberId: '', todayDate: '', hasIncomplete: false, incompleteCount: 0, progressItems: [], streakDays: 0, activeChallenges: 0, completeness: 0, cautionCount: 0,
       healthArticles: [],
       healthArticles: [],
       growthArticles: [],
       growthArticles: [],
-      pendingSurvey: null, allDone: false, surveyHistory: [], isLoggedIn: false } },
+      isLoggedIn: false } },
   computed: {
   computed: {
     doneCount: function() { var c = 0; for (var i = 0; i < this.progressItems.length; i++) if (this.progressItems[i] && this.progressItems[i].done) c++; return c },
     doneCount: function() { var c = 0; for (var i = 0; i < this.progressItems.length; i++) if (this.progressItems[i] && this.progressItems[i].done) c++; return c },
     totalCount: function() { return this.progressItems.length },
     totalCount: function() { return this.progressItems.length },
-    activeTabLabel: function() { var m = { diet: '饮食', exercise: '运动', sleep: '睡眠', emotion: '心情' }; return m[this.activeTab] || '' },
-    currentRecords: function() { return this.trackingData[this.activeTab] || [] },
     recommendArticles: function() {
     recommendArticles: function() {
       var arr = []
       var arr = []
       if (this.healthArticles && this.healthArticles.length > 0) arr = arr.concat(this.healthArticles)
       if (this.healthArticles && this.healthArticles.length > 0) arr = arr.concat(this.healthArticles)
@@ -308,24 +276,16 @@ export default {
   methods: {
   methods: {
     pad: function(n) { return n < 10 ? '0' + n : '' + n },
     pad: function(n) { return n < 10 ? '0' + n : '' + n },
     getProgressKey: function(item) { return item && item.label ? item.label : 'p-' + Math.random() },
     getProgressKey: function(item) { return item && item.label ? item.label : 'p-' + Math.random() },
-    getRecordKey: function(rec) { return rec && (rec._id || rec.id) ? 'r' + (rec._id || rec.id) : (rec && rec._text ? rec._text + rec._content : 'r-' + Math.random()) },
-    getHistoryKey: function(h) { return h && h._date ? h._date + h._content : 'h-' + Math.random() },
     dismissReminder: function() { this.hasIncomplete = false },
     dismissReminder: function() { this.hasIncomplete = false },
-    switchTab: function(key) { this.activeTab = key; this.loadTrackingData(key) },
     handleProgressClick: function(item) {
     handleProgressClick: function(item) {
       if (!item || !item.key) return
       if (!item || !item.key) return
-      var m = { checkin: '/pages/health/daily-checkin', diet: '/pages/health/diet-index', exercise: '/pages/health/exercise-index', sleep: '/pages/health/sleep-index', emotion: '/pages/mind/index' }
+      var m = { diet: '/pages/growth/diet-checkin', exercise: '/pages/growth/exercise-checkin', sleep: '/pages/growth/sleep-checkin', emotion: '/pages/growth/emotion-checkin' }
       var url = m[item.key]
       var url = m[item.key]
-      if (item.key === 'survey' && this.pendingSurvey && this.pendingSurvey.templateId) {
-        url = '/pages/health/survey-questionnaire?templateId=' + this.pendingSurvey.templateId + '&templateTitle=' + encodeURIComponent(this.pendingSurvey.title) + '&templateDimension=' + (this.pendingSurvey.dimension || '')
-      }
       if (url) uni.navigateTo({ url: url })
       if (url) uni.navigateTo({ url: url })
     },
     },
-    startSurvey: function() { if (this.pendingSurvey && this.pendingSurvey.templateId) uni.navigateTo({ url: '/pages/health/survey-questionnaire?templateId=' + this.pendingSurvey.templateId + '&templateTitle=' + encodeURIComponent(this.pendingSurvey.title) + '&templateDimension=' + (this.pendingSurvey.dimension || '') }) },
-    viewAllRecords: function() { var m = { diet: '/pages/health/diet-index', exercise: '/pages/health/exercise-index', sleep: '/pages/health/sleep-index', emotion: '/pages/mind/index' }; if (m[this.activeTab]) uni.navigateTo({ url: m[this.activeTab] }) },
-    viewAllHistory: function() { uni.navigateTo({ url: '/pages/health/survey-history?memberId=' + this.memberId }) },
     goActivityDetail: function(act) { uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id }) },
     goActivityDetail: function(act) { uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id }) },
     goMoreActivities: function() { uni.navigateTo({ url: '/pages/activity/index' }) },
     goMoreActivities: function() { uni.navigateTo({ url: '/pages/activity/index' }) },
+    goToTasks: function() { uni.navigateTo({ url: '/pages/child/tasks' }) },
     handleGuestFeatureTap: function() { uni.showToast({ title: '登录后即可体验该功能', icon: 'none' }) },
     handleGuestFeatureTap: function() { uni.showToast({ title: '登录后即可体验该功能', icon: 'none' }) },
     goToCaution: function() { uni.navigateTo({ url: '/pages/growth/caution-detail' }) },
     goToCaution: function() { uni.navigateTo({ url: '/pages/growth/caution-detail' }) },
     goToFoods: function() { uni.navigateTo({ url: '/pages/growth/foods-detail' }) },
     goToFoods: function() { uni.navigateTo({ url: '/pages/growth/foods-detail' }) },
@@ -356,7 +316,6 @@ export default {
       try { await self.loadProgressItems() } catch(e) {}
       try { await self.loadProgressItems() } catch(e) {}
       try { await self.loadCautionData() } catch(e) {}
       try { await self.loadCautionData() } catch(e) {}
       try { await self.loadGrowthRecommendations() } catch(e) {}
       try { await self.loadGrowthRecommendations() } catch(e) {}
-      try { await self.loadSurveyData() } catch(e) {}
       try { await self.loadMilestones() } catch(e) {}
       try { await self.loadMilestones() } catch(e) {}
     },
     },
     async loadProgressItems() {
     async loadProgressItems() {
@@ -438,25 +397,6 @@ export default {
       for (var pi = 0; pi < items.length; pi++) if (items[pi] && !items[pi].done) self.incompleteCount++
       for (var pi = 0; pi < items.length; pi++) if (items[pi] && !items[pi].done) self.incompleteCount++
       self.hasIncomplete = self.incompleteCount > 0
       self.hasIncomplete = self.incompleteCount > 0
     },
     },
-    async loadSurveyData() {
-      var self = this
-      try {
-        var statusRes = await getSurveyStatus({})
-        if (statusRes.code === 200 && statusRes.data) {
-          var list = statusRes.data
-          self.pendingSurvey = null; self.allDone = true; var hasAny = false
-          for (var si = 0; si < list.length; si++) { var s = list[si]; if (!s) continue; hasAny = true; if (s.status === 'pending') { self.pendingSurvey = { templateId: s.templateId, title: s.title || '成长记录', dimension: s.dimension || '' }; self.allDone = false } }
-          if (!hasAny) { self.pendingSurvey = null; self.allDone = false }
-        }
-      } catch(e) {}
-      try {
-        var histRes = await getSurveyHistory({})
-        if (histRes.code === 200 && histRes.data) {
-          var hlist = histRes.data; self.surveyHistory = []
-          for (var hi = 0; hi < Math.min(hlist.length, 3); hi++) { var h = hlist[hi]; self.surveyHistory.push({ _date: h && h.submittedAt ? h.submittedAt.substring(0, 10) : '', _content: '已完成调研' }) }
-        }
-      } catch(e) {}
-    },
     async loadGrowthRecommendations() {
     async loadGrowthRecommendations() {
       var self = this
       var self = this
       try {
       try {
@@ -552,22 +492,6 @@ export default {
 .progress-footer{display:flex;justify-content:space-between;align-items:center;margin-top:16rpx;padding-top:16rpx;border-top:1rpx solid #f5f5f5}
 .progress-footer{display:flex;justify-content:space-between;align-items:center;margin-top:16rpx;padding-top:16rpx;border-top:1rpx solid #f5f5f5}
 .footer-text{font-size:24rpx;color:#666;font-weight:bold}
 .footer-text{font-size:24rpx;color:#666;font-weight:bold}
 .footer-streak{font-size:24rpx;color:#F97316;font-weight:bold}
 .footer-streak{font-size:24rpx;color:#F97316;font-weight:bold}
-.tracking-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
-.tracking-tabs{display:flex;margin-bottom:16rpx}
-.tracking-tab{flex:1;text-align:center;padding:10rpx 0;border-radius:20rpx;margin-right:8rpx;background:#FFF7ED}
-.tracking-tab:last-child{margin-right:0}
-.tab-active{background:#F97316}
-.tab-text{font-size:24rpx;color:#666}
-.tab-active-text{color:#fff;font-weight:bold}
-.tracking-content{min-height:200rpx}
-.tracking-empty{display:flex;flex-direction:column;align-items:center;padding:60rpx 0}
-.empty-icon{font-size:60rpx;margin-bottom:12rpx}
-.empty-text{font-size:26rpx;color:#999}
-.empty-hint{font-size:22rpx;color:#ccc;margin-top:8rpx}
-.tracking-record{display:flex;align-items:center;padding:10rpx 0;border-bottom:1rpx solid #f5f5f5}
-.record-date{font-size:22rpx;color:#999;width:120rpx;flex-shrink:0}
-.record-content{font-size:24rpx;color:#333;flex:1}
-.tracking-more{text-align:right;font-size:24rpx;color:#F97316;padding:12rpx 0 0 0}
 .milestone-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
 .milestone-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
 .section-title-row{font-size:26rpx;font-weight:bold;color:#333;margin-bottom:16rpx;display:block}
 .section-title-row{font-size:26rpx;font-weight:bold;color:#333;margin-bottom:16rpx;display:block}
 .milestone-grid{display:flex;justify-content:space-between}
 .milestone-grid{display:flex;justify-content:space-between}
@@ -575,21 +499,6 @@ export default {
 .milestone-icon{font-size:40rpx;margin-bottom:4rpx}
 .milestone-icon{font-size:40rpx;margin-bottom:4rpx}
 .milestone-value{font-size:28rpx;font-weight:bold;color:#F97316;margin-bottom:2rpx}
 .milestone-value{font-size:28rpx;font-weight:bold;color:#F97316;margin-bottom:2rpx}
 .milestone-label{font-size:22rpx;color:#999}
 .milestone-label{font-size:22rpx;color:#999}
-.survey-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
-.survey-pending{background:#FFF3E0;border-radius:12rpx;padding:16rpx;margin-bottom:12rpx}
-.survey-alert{font-size:24rpx;color:#E65100;font-weight:bold;display:block;margin-bottom:4rpx}
-.survey-title-text{font-size:24rpx;color:#333;display:block;margin-bottom:8rpx}
-.survey-btn{font-size:22rpx;color:#F97316;font-weight:bold}
-.survey-done{padding:8rpx 0}
-.survey-done-text{font-size:24rpx;color:#10B981}
-.survey-history-list{border-top:1rpx solid #f5f5f5;padding-top:12rpx}
-.history-label{font-size:22rpx;color:#999;display:block;margin-bottom:6rpx}
-.history-item{display:flex;align-items:center;padding:6rpx 0}
-.history-date{font-size:22rpx;color:#999;width:120rpx;flex-shrink:0}
-.history-content{font-size:24rpx;color:#666;flex:1}
-.survey-more{text-align:right;font-size:22rpx;color:#F97316;padding-top:8rpx}
-.survey-empty{padding:12rpx 0}
-.survey-empty-text{font-size:24rpx;color:#999}
 
 
 /* ===== 五维闭环体系样式 ===== */
 /* ===== 五维闭环体系样式 ===== */
 .wuxing-section { margin: 0 24rpx 16rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
 .wuxing-section { margin: 0 24rpx 16rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }

+ 352 - 0
cfc-frontend/pages/growth/diet-checkin.vue

@@ -0,0 +1,352 @@
+<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="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">记录饮食</text>
+          <view class="form-group">
+            <text class="form-label">餐次 *</text>
+            <view class="tag-row">
+              <view class="tag-btn" v-for="m in mealTypes" :key="m.value"
+                :class="form.mealType === m.value ? 'tag-active' : ''"
+                @tap="form.mealType = m.value">
+                <text>{{ m.label }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="form-group">
+            <text class="form-label">食物清单</text>
+            <textarea class="form-textarea" v-model="form.foodItems" placeholder="吃了什么?如:米饭1碗、青菜、鸡腿" maxlength="200"></textarea>
+          </view>
+          <view class="form-group">
+            <text class="form-label">备注(选填)</text>
+            <input class="form-input" v-model="form.remark" placeholder="今天的饮食感受..." />
+          </view>
+        </view>
+      </view>
+
+      <!-- 照片上传 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">📷 拍照记录</text>
+          <view class="photo-row">
+            <view class="photo-item" v-for="(img, idx) in form.photos" :key="'p' + idx" @tap="removePhoto(idx)">
+              <image :src="img" mode="aspectFill" class="photo-preview" />
+              <text class="photo-del">×</text>
+            </view>
+            <view class="photo-add" @tap="choosePhoto">
+              <text class="photo-add-icon">+</text>
+              <text class="photo-add-text">拍照</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 语音录入 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">🎙️ 语音打卡</text>
+          <view class="voice-row">
+            <view class="voice-wave" v-if="isRecording">
+              <text class="wave-bar active">◉</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-text">录音中 {{ recordSeconds }}s</text>
+            </view>
+            <text class="voice-duration" v-else-if="form.voicePath">{{ recordDurationText }}</text>
+            <view class="voice-btn" :class="{ 'voice-btn-recording': isRecording }" @tap="toggleRecord">
+              <text class="voice-btn-icon">{{ isRecording ? '⏹' : '🎙' }}</text>
+              <text class="voice-btn-text">{{ isRecording ? '结束' : '按住录音' }}</text>
+            </view>
+          </view>
+          <text class="voice-hint">长按录音,松开结束,可补充文字记录</text>
+        </view>
+      </view>
+
+      <!-- 提交按钮 -->
+      <view class="submit-wrap">
+        <view class="submit-btn" :class="{ 'submitting': submitting }" @tap="doCheckin">
+          <text>{{ submitting ? '提交中...' : '✓ 完成打卡' }}</text>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view class="card" v-if="history.length > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">今日记录</text>
+          <view class="history-list">
+            <view class="history-item" v-for="item in history" :key="item.id">
+              <view class="history-left">
+                <text class="history-type">{{ mealTypeLabel(item.mealType) }}</text>
+                <text class="history-content">{{ item.foodItems || item.notes || '-' }}</text>
+              </view>
+              <view class="history-right">
+                <text class="history-date">{{ formatDate(item.createdAt) }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '../../config'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  data() {
+    return {
+      memberId: null,
+      submitting: false,
+      isRecording: false,
+      recordSeconds: 0,
+      recordTimer: null,
+      recorderManager: null,
+      history: [],
+      mealTypes: [
+        { label: '早餐', value: 'breakfast' },
+        { label: '午餐', value: 'lunch' },
+        { label: '晚餐', value: 'dinner' },
+        { label: '加餐', value: 'snack' }
+      ],
+      form: {
+        mealType: '',
+        foodItems: '',
+        remark: '',
+        photos: [],
+        voicePath: ''
+      }
+    }
+  },
+  computed: {
+    recordDurationText: function() {
+      if (!this.form.voicePath) return ''
+      var s = this.recordSeconds
+      return '录音 ' + s + '秒'
+    }
+  },
+  onLoad: function() {
+    var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+    if (role !== 'child') {
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
+    }
+    this.recorderManager = uni.getRecorderManager()
+    this.recorderManager.onStart(() => {
+      this.isRecording = true
+      this.recordSeconds = 0
+      this.recordTimer = setInterval(() => {
+        this.recordSeconds++
+      }, 1000)
+    })
+    this.recorderManager.onStop((res) => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      this.form.voicePath = res.tempFilePath
+      this.form.voiceDuration = res.duration
+    })
+    this.recorderManager.onError((err) => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      uni.showToast({ title: '录音失败', icon: 'none' })
+    })
+    this.loadHistory()
+  },
+  onUnload: function() {
+    if (this.recordTimer) clearInterval(this.recordTimer)
+  },
+  methods: {
+    goBack: function() { uni.navigateBack() },
+    mealTypeLabel: function(type) {
+      var m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐' }
+      return m[type] || type || '饮食'
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
+      var m2 = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return m2 + '-' + day
+    },
+    loadHistory: function() {
+      var self = this
+      var token = uni.getStorageSync('token')
+      if (!token || !self.memberId) return
+      uni.request({
+        url: config.API_BASE_URL + '/api/health/meal/list',
+        method: 'POST',
+        data: { memberId: self.memberId },
+        header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        success: function(res) {
+          if (res.data && res.data.code === 200 && res.data.data) {
+            self.history = res.data.data.slice(0, 5)
+          }
+        }
+      })
+    },
+    choosePhoto: function() {
+      var self = this
+      uni.chooseImage({
+        count: 9 - self.form.photos.length,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: function(res) {
+          res.tempFilePaths.forEach(function(path) {
+            if (self.form.photos.length < 9) self.form.photos.push(path)
+          })
+        }
+      })
+    },
+    removePhoto: function(idx) {
+      this.form.photos.splice(idx, 1)
+    },
+    toggleRecord: function() {
+      if (this.isRecording) {
+        this.recorderManager.stop()
+      } else {
+        this.recorderManager.start({ duration: 60000, format: 'mp3' })
+      }
+    },
+    uploadFile: function(filePath) {
+      var self = this
+      return new Promise(function(resolve, reject) {
+        uni.uploadFile({
+          url: config.API_BASE_URL + '/api/media/upload',
+          filePath: filePath,
+          name: 'file',
+          header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+          success: function(res) {
+            try {
+              var data = JSON.parse(res.data)
+              resolve(data.data || data.url || '')
+            } catch (e) {
+              resolve(res.data || '')
+            }
+          },
+          fail: function() { reject() }
+        })
+      })
+    },
+    async doCheckin() {
+      var self = this
+      if (!self.form.mealType) { uni.showToast({ title: '请选择餐次', icon: 'none' }); return }
+      if (!self.memberId) { uni.showToast({ title: '孩子ID无效', icon: 'none' }); return }
+      self.submitting = true
+      try {
+        var photoUrls = []
+        if (self.form.photos && self.form.photos.length > 0) {
+          for (var i = 0; i < self.form.photos.length; i++) {
+            try { var url = await self.uploadFile(self.form.photos[i]) } catch(e) { url = '' }
+            if (url) photoUrls.push(url)
+          }
+        }
+        var voiceUrl = ''
+        if (self.form.voicePath) {
+          try { voiceUrl = await self.uploadFile(self.form.voicePath) } catch(e) { voiceUrl = '' }
+        }
+        var token = uni.getStorageSync('token')
+        uni.request({
+          url: config.API_BASE_URL + '/api/health/meal/create',
+          method: 'POST',
+          data: {
+            memberId: self.memberId,
+            mealType: self.form.mealType,
+            foodItems: self.form.foodItems || '',
+            remark: self.form.remark || '',
+            photoUrls: photoUrls.join(','),
+            voiceUrl: voiceUrl
+          },
+          header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+          success: function(res) {
+            self.submitting = false
+            if (res.data && res.data.code === 200) {
+              uni.showToast({ title: '打卡成功', icon: 'success' })
+              self.form.mealType = ''
+              self.form.foodItems = ''
+              self.form.remark = ''
+              self.form.photos = []
+              self.form.voicePath = ''
+              self.recordSeconds = 0
+              self.loadHistory()
+            } else {
+              uni.showToast({ title: (res.data && res.data.message) || '打卡失败', icon: 'none' })
+            }
+          },
+          fail: function() {
+            self.submitting = false
+            uni.showToast({ title: '网络错误', icon: 'none' })
+          }
+        })
+      } catch (e) {
+        self.submitting = false
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #FFF7ED; }
+.nav-bar { display: flex; align-items: center; height: 88rpx; padding-top: env(safe-area-inset-top); background: #fff; border-bottom: 1rpx solid #f0f0f0; position: relative; }
+.nav-back { position: absolute; left: 24rpx; top: 0; bottom: 0; display: flex; align-items: center; }
+.back-text { font-size: 28rpx; color: #F97316; }
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: bold; color: #333; }
+.content { height: calc(100vh - 88rpx); }
+.card { margin: 16rpx 24rpx; background: #fff; border-radius: 20rpx; overflow: hidden; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.card-accent { height: 6rpx; background: linear-gradient(90deg, #FF8C42, #F97316); }
+.card-body { padding: 24rpx; }
+.card-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; display: block; }
+.form-group { margin-bottom: 20rpx; }
+.form-label { font-size: 26rpx; color: #333; font-weight: 500; margin-bottom: 12rpx; display: block; }
+.tag-row { display: flex; flex-wrap: wrap; gap: 12rpx; }
+.tag-btn { padding: 10rpx 24rpx; border-radius: 30rpx; border: 2rpx solid #e0e0e0; background: #fafafa; }
+.tag-active { border-color: #F97316; background: #FFF3E0; }
+.tag-btn text { font-size: 24rpx; color: #666; }
+.tag-active text { font-size: 24rpx; color: #F97316; font-weight: bold; }
+.form-textarea { width: 100%; height: 120rpx; border: 1rpx solid #e0e0e0; border-radius: 12rpx; padding: 16rpx; font-size: 26rpx; background: #FAFAFA; box-sizing: border-box; }
+.form-input { width: 100%; border: 1rpx solid #e0e0e0; border-radius: 12rpx; padding: 16rpx; font-size: 26rpx; background: #FAFAFA; box-sizing: border-box; }
+.photo-row { display: flex; flex-wrap: wrap; gap: 16rpx; }
+.photo-item { position: relative; width: 160rpx; height: 160rpx; }
+.photo-preview { width: 100%; height: 100%; border-radius: 12rpx; object-fit: cover; }
+.photo-del { position: absolute; top: -8rpx; right: -8rpx; width: 36rpx; height: 36rpx; background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%; font-size: 24rpx; text-align: center; line-height: 36rpx; }
+.photo-add { width: 160rpx; height: 160rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; }
+.photo-add-icon { font-size: 48rpx; color: #ccc; }
+.photo-add-text { font-size: 22rpx; color: #999; margin-top: 8rpx; }
+.voice-row { display: flex; flex-direction: column; align-items: center; gap: 12rpx; padding: 16rpx 0; }
+.voice-wave { display: flex; align-items: center; gap: 8rpx; font-size: 24rpx; color: #F97316; }
+.wave-bar { font-size: 28rpx; color: #ccc; }
+.wave-bar.active { animation: wave 0.6s infinite; }
+@keyframes wave { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
+.voice-duration { font-size: 24rpx; color: #666; }
+.voice-btn { display: flex; flex-direction: column; align-items: center; gap: 8rpx; padding: 20rpx 48rpx; border-radius: 40rpx; background: #FFF3E0; border: 2rpx solid #FFB74D; }
+.voice-btn-recording { background: #FEE2E2; border-color: #EF4444; }
+.voice-btn-icon { font-size: 40rpx; }
+.voice-btn-text { font-size: 24rpx; color: #F97316; }
+.voice-btn-recording .voice-btn-text { color: #EF4444; }
+.voice-hint { font-size: 22rpx; color: #999; text-align: center; }
+.submit-wrap { padding: 24rpx 24rpx 40rpx; }
+.submit-btn { background: linear-gradient(135deg, #FF8C42, #F97316); color: #fff; text-align: center; padding: 24rpx 0; border-radius: 16rpx; font-size: 30rpx; font-weight: bold; }
+.submitting { opacity: 0.6; }
+.history-list { display: flex; flex-direction: column; gap: 16rpx; }
+.history-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.history-left { flex: 1; min-width: 0; }
+.history-type { font-size: 26rpx; font-weight: 500; color: #333; display: block; }
+.history-content { font-size: 22rpx; color: #888; display: block; margin-top: 4rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.history-right { text-align: right; margin-left: 16rpx; flex-shrink: 0; }
+.history-date { font-size: 22rpx; color: #999; }
+</style>

+ 362 - 0
cfc-frontend/pages/growth/emotion-checkin.vue

@@ -0,0 +1,362 @@
+<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="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">现在的心情</text>
+          <view class="emotion-grid">
+            <view class="emotion-item" v-for="em in emotions" :key="em.type"
+              :class="{ 'emotion-active': selectedEmotion === em.type }"
+              @tap="selectedEmotion = em.type">
+              <text class="emotion-icon">{{ em.icon }}</text>
+              <text class="emotion-label">{{ em.label }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 心情强度 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">心情强度</text>
+          <view class="slider-row">
+            <text class="slider-min">微弱</text>
+            <slider min="1" max="10" :value="intensityLevel" @change="function(e){intensityLevel=e.detail.value}" block-size="20" activeColor="#FF6B9D" />
+            <text class="slider-val">{{ intensityLevel }}</text>
+            <text class="slider-max">强烈</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 文字记录 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">✍️ 文字记录</text>
+          <textarea class="note-input" v-model="note" placeholder="今天发生了什么?说说你的感受..." maxlength="500" />
+        </view>
+      </view>
+
+      <!-- 照片上传 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">📷 拍照记录</text>
+          <view class="photo-row">
+            <view class="photo-item" v-for="(img, idx) in photos" :key="'p' + idx" @tap="removePhoto(idx)">
+              <image :src="img" mode="aspectFill" class="photo-preview" />
+              <text class="photo-del">×</text>
+            </view>
+            <view class="photo-add" @tap="choosePhoto">
+              <text class="photo-add-icon">+</text>
+              <text class="photo-add-text">拍照</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 语音录入 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">🎙️ 语音打卡</text>
+          <view class="voice-row">
+            <view class="voice-wave" v-if="isRecording">
+              <text class="wave-bar active">◉</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-text">录音中 {{ recordSeconds }}s</text>
+            </view>
+            <text class="voice-duration" v-else-if="voicePath">{{ recordDurationText }}</text>
+            <view class="voice-btn" :class="{ 'voice-btn-recording': isRecording }" @tap="toggleRecord">
+              <text class="voice-btn-icon">{{ isRecording ? '⏹' : '🎙' }}</text>
+              <text class="voice-btn-text">{{ isRecording ? '结束' : '按住录音' }}</text>
+            </view>
+          </view>
+          <text class="voice-hint">长按录音,松开结束</text>
+        </view>
+      </view>
+
+      <!-- 提交按钮 -->
+      <view class="submit-wrap">
+        <view class="submit-btn" :class="{ 'submitting': submitting }" @tap="doCheckin">
+          <text>{{ submitting ? '提交中...' : '✓ 记录心情' }}</text>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view class="card" v-if="history.length > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">近期心情</text>
+          <view class="history-list">
+            <view class="history-item" v-for="item in history" :key="item.id">
+              <view class="history-left">
+                <text class="history-type">{{ emotionIcon(item.emotionType) }} {{ emotionLabel(item.emotionType) }}</text>
+                <text class="history-content">{{ item.note || '-' }}</text>
+              </view>
+              <text class="history-date">{{ formatDate(item.createdAt) }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '../../config'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  data() {
+    return {
+      memberId: null,
+      submitting: false,
+      isRecording: false,
+      recordSeconds: 0,
+      recordTimer: null,
+      recorderManager: null,
+      history: [],
+      selectedEmotion: '',
+      intensityLevel: 5,
+      note: '',
+      photos: [],
+      voicePath: '',
+      emotions: [
+        { type: 'joy',     icon: '😊', label: '开心' },
+        { type: 'calm',    icon: '😌', label: '平静' },
+        { type: 'excited', icon: '🤩', label: '兴奋' },
+        { type: 'sad',     icon: '😢', label: '难过' },
+        { type: 'angry',   icon: '😤', label: '生气' },
+        { type: 'anxious', icon: '😰', label: '焦虑' },
+        { type: 'tired',   icon: '😴', label: '疲惫' },
+        { type: 'scared',  icon: '😨', label: '害怕' }
+      ]
+    }
+  },
+  computed: {
+    recordDurationText: function() {
+      if (!this.voicePath) return ''
+      return '录音 ' + this.recordSeconds + '秒'
+    }
+  },
+  onLoad: function() {
+    var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+    if (role !== 'child') {
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
+    }
+    this.recorderManager = uni.getRecorderManager()
+    this.recorderManager.onStart(() => {
+      this.isRecording = true
+      this.recordSeconds = 0
+      this.recordTimer = setInterval(() => { this.recordSeconds++ }, 1000)
+    })
+    this.recorderManager.onStop((res) => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      this.voicePath = res.tempFilePath
+      this.voiceDuration = res.duration
+    })
+    this.recorderManager.onError(() => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      uni.showToast({ title: '录音失败', icon: 'none' })
+    })
+    this.loadHistory()
+  },
+  onUnload: function() {
+    if (this.recordTimer) clearInterval(this.recordTimer)
+  },
+  methods: {
+    goBack: function() { uni.navigateBack() },
+    emotionLabel: function(type) {
+      var m = { joy: '开心', calm: '平静', excited: '兴奋', sad: '难过', angry: '生气', anxious: '焦虑', tired: '疲惫', scared: '害怕' }
+      return m[type] || type || ''
+    },
+    emotionIcon: function(type) {
+      var em = this.emotions.find(function(e) { return e.type === type })
+      return em ? em.icon : ''
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
+      var m2 = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return m2 + '-' + day
+    },
+    loadHistory: function() {
+      var self = this
+      var token = uni.getStorageSync('token')
+      if (!token || !self.memberId) return
+      uni.request({
+        url: config.API_BASE_URL + '/api/mind/checkin/list',
+        method: 'POST',
+        data: { memberId: self.memberId, page: 1, size: 5 },
+        header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        success: function(res) {
+          if (res.data && res.data.code === 200 && res.data.data) {
+            self.history = res.data.data
+          }
+        }
+      })
+    },
+    choosePhoto: function() {
+      var self = this
+      uni.chooseImage({
+        count: 9 - self.photos.length,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: function(res) {
+          res.tempFilePaths.forEach(function(path) {
+            if (self.photos.length < 9) self.photos.push(path)
+          })
+        }
+      })
+    },
+    removePhoto: function(idx) {
+      this.photos.splice(idx, 1)
+    },
+    toggleRecord: function() {
+      if (this.isRecording) {
+        this.recorderManager.stop()
+      } else {
+        this.recorderManager.start({ duration: 60000, format: 'mp3' })
+      }
+    },
+    uploadFile: function(filePath) {
+      var self = this
+      return new Promise(function(resolve, reject) {
+        uni.uploadFile({
+          url: config.API_BASE_URL + '/api/media/upload',
+          filePath: filePath,
+          name: 'file',
+          header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+          success: function(res) {
+            try { var data = JSON.parse(res.data); resolve(data.data || data.url || '') }
+            catch (e) { resolve(res.data || '') }
+          },
+          fail: function() { reject() }
+        })
+      })
+    },
+    async doCheckin() {
+      var self = this
+      if (!self.memberId) { uni.showToast({ title: '孩子ID无效', icon: 'none' }); return }
+      self.submitting = true
+      try {
+        var photoUrls = []
+        if (self.photos && self.photos.length > 0) {
+          for (var i = 0; i < self.photos.length; i++) {
+            try { var url = await self.uploadFile(self.photos[i]) } catch(e) { url = '' }
+            if (url) photoUrls.push(url)
+          }
+        }
+        var voiceUrl = ''
+        if (self.voicePath) {
+          try { voiceUrl = await self.uploadFile(self.voicePath) } catch(e) { voiceUrl = '' }
+        }
+        var token = uni.getStorageSync('token')
+        uni.request({
+          url: config.API_BASE_URL + '/api/mind/checkin/create',
+          method: 'POST',
+          data: {
+            memberId: self.memberId,
+            emotionType: self.selectedEmotion,
+            intensityLevel: self.intensityLevel,
+            note: self.note,
+            photoUrls: photoUrls.join(','),
+            voiceUrl: voiceUrl
+          },
+          header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+          success: function(res) {
+            self.submitting = false
+            if (res.data && res.data.code === 200) {
+              uni.showToast({ title: '+5 心能量', icon: 'none' })
+              self.selectedEmotion = ''
+              self.intensityLevel = 5
+              self.note = ''
+              self.photos = []
+              self.voicePath = ''
+              self.recordSeconds = 0
+              self.loadHistory()
+            } else {
+              uni.showToast({ title: (res.data && res.data.message) || '提交失败', icon: 'none' })
+            }
+          },
+          fail: function() {
+            self.submitting = false
+            uni.showToast({ title: '网络错误', icon: 'none' })
+          }
+        })
+      } catch (e) {
+        self.submitting = false
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #FFF7ED; }
+.nav-bar { display: flex; align-items: center; height: 88rpx; padding-top: env(safe-area-inset-top); background: #fff; border-bottom: 1rpx solid #f0f0f0; position: relative; }
+.nav-back { position: absolute; left: 24rpx; top: 0; bottom: 0; display: flex; align-items: center; }
+.back-text { font-size: 28rpx; color: #F97316; }
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: bold; color: #333; }
+.content { height: calc(100vh - 88rpx); }
+.card { margin: 16rpx 24rpx; background: #fff; border-radius: 20rpx; overflow: hidden; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.card-accent { height: 6rpx; background: linear-gradient(90deg, #FF6B9D, #FF8CB3); }
+.card-body { padding: 24rpx; }
+.card-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; display: block; }
+.emotion-grid { display: flex; flex-wrap: wrap; gap: 16rpx; justify-content: space-between; }
+.emotion-item { width: calc(25% - 12rpx); display: flex; flex-direction: column; align-items: center; padding: 16rpx 8rpx; border-radius: 16rpx; border: 2rpx solid #f0f0f0; }
+.emotion-active { border-color: #FF6B9D; background: #FFF0F5; }
+.emotion-icon { font-size: 44rpx; display: block; margin-bottom: 4rpx; }
+.emotion-label { font-size: 22rpx; color: #666; }
+.emotion-active .emotion-label { font-size: 22rpx; color: #FF6B9D; font-weight: bold; }
+.slider-row { display: flex; align-items: center; gap: 12rpx; }
+.slider-min { font-size: 22rpx; color: #999; flex-shrink: 0; }
+.slider-val { font-size: 24rpx; color: #FF6B9D; font-weight: bold; width: 40rpx; text-align: center; }
+.slider-max { font-size: 22rpx; color: #999; flex-shrink: 0; }
+.note-input { width: 100%; height: 160rpx; border: 1rpx solid #e0e0e0; border-radius: 12rpx; padding: 16rpx; font-size: 26rpx; background: #FAFAFA; box-sizing: border-box; }
+.photo-row { display: flex; flex-wrap: wrap; gap: 16rpx; }
+.photo-item { position: relative; width: 160rpx; height: 160rpx; }
+.photo-preview { width: 100%; height: 100%; border-radius: 12rpx; object-fit: cover; }
+.photo-del { position: absolute; top: -8rpx; right: -8rpx; width: 36rpx; height: 36rpx; background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%; font-size: 24rpx; text-align: center; line-height: 36rpx; }
+.photo-add { width: 160rpx; height: 160rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; }
+.photo-add-icon { font-size: 48rpx; color: #ccc; }
+.photo-add-text { font-size: 22rpx; color: #999; margin-top: 8rpx; }
+.voice-row { display: flex; flex-direction: column; align-items: center; gap: 12rpx; padding: 16rpx 0; }
+.voice-wave { display: flex; align-items: center; gap: 8rpx; font-size: 24rpx; color: #FF6B9D; }
+.wave-bar { font-size: 28rpx; color: #ccc; }
+.wave-bar.active { animation: wave 0.6s infinite; }
+@keyframes wave { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
+.voice-duration { font-size: 24rpx; color: #666; }
+.voice-btn { display: flex; flex-direction: column; align-items: center; gap: 8rpx; padding: 20rpx 48rpx; border-radius: 40rpx; background: #FFF0F5; border: 2rpx solid #FF6B9D; }
+.voice-btn-recording { background: #FEE2E2; border-color: #EF4444; }
+.voice-btn-icon { font-size: 40rpx; }
+.voice-btn-text { font-size: 24rpx; color: #FF6B9D; }
+.voice-btn-recording .voice-btn-text { color: #EF4444; }
+.voice-hint { font-size: 22rpx; color: #999; text-align: center; }
+.submit-wrap { padding: 24rpx 24rpx 40rpx; }
+.submit-btn { background: linear-gradient(135deg, #FF6B9D, #FF8CB3); color: #fff; text-align: center; padding: 24rpx 0; border-radius: 16rpx; font-size: 30rpx; font-weight: bold; }
+.submitting { opacity: 0.6; }
+.history-list { display: flex; flex-direction: column; gap: 16rpx; }
+.history-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.history-left { flex: 1; min-width: 0; }
+.history-type { font-size: 26rpx; font-weight: 500; color: #333; display: block; }
+.history-content { font-size: 22rpx; color: #888; display: block; margin-top: 4rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.history-date { font-size: 22rpx; color: #999; flex-shrink: 0; margin-left: 16rpx; }
+</style>

+ 370 - 0
cfc-frontend/pages/growth/exercise-checkin.vue

@@ -0,0 +1,370 @@
+<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="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">记录运动</text>
+          <view class="form-group">
+            <text class="form-label">运动类型 *</text>
+            <view class="tag-row">
+              <view class="tag-btn" v-for="t in exerciseTypes" :key="t.value"
+                :class="form.exerciseType === t.value ? 'tag-active' : ''"
+                @tap="form.exerciseType = t.value">
+                <text>{{ t.label }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="form-group">
+            <text class="form-label">时长(分钟)*</text>
+            <input class="form-input" type="number" v-model="form.durationMinutes" placeholder="运动了多久?" />
+          </view>
+          <view class="form-group">
+            <text class="form-label">强度</text>
+            <view class="tag-row">
+              <view class="tag-btn" v-for="s in intensityOptions" :key="s.value"
+                :class="form.intensity === s.value ? 'tag-active' : ''"
+                @tap="form.intensity = s.value">
+                <text>{{ s.label }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="form-group">
+            <text class="form-label">消耗卡路里(选填)</text>
+            <input class="form-input" type="number" v-model="form.caloriesBurned" placeholder="估算卡路里" />
+          </view>
+          <view class="form-group">
+            <text class="form-label">备注(选填)</text>
+            <input class="form-input" v-model="form.remark" placeholder="运动感受..." />
+          </view>
+        </view>
+      </view>
+
+      <!-- 照片上传 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">📷 拍照记录</text>
+          <view class="photo-row">
+            <view class="photo-item" v-for="(img, idx) in form.photos" :key="'p' + idx" @tap="removePhoto(idx)">
+              <image :src="img" mode="aspectFill" class="photo-preview" />
+              <text class="photo-del">×</text>
+            </view>
+            <view class="photo-add" @tap="choosePhoto">
+              <text class="photo-add-icon">+</text>
+              <text class="photo-add-text">拍照</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 语音录入 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">🎙️ 语音打卡</text>
+          <view class="voice-row">
+            <view class="voice-wave" v-if="isRecording">
+              <text class="wave-bar active">◉</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-text">录音中 {{ recordSeconds }}s</text>
+            </view>
+            <text class="voice-duration" v-else-if="form.voicePath">{{ recordDurationText }}</text>
+            <view class="voice-btn" :class="{ 'voice-btn-recording': isRecording }" @tap="toggleRecord">
+              <text class="voice-btn-icon">{{ isRecording ? '⏹' : '🎙' }}</text>
+              <text class="voice-btn-text">{{ isRecording ? '结束' : '按住录音' }}</text>
+            </view>
+          </view>
+          <text class="voice-hint">长按录音,松开结束</text>
+        </view>
+      </view>
+
+      <!-- 提交按钮 -->
+      <view class="submit-wrap">
+        <view class="submit-btn" :class="{ 'submitting': submitting }" @tap="doCheckin">
+          <text>{{ submitting ? '提交中...' : '✓ 完成打卡' }}</text>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view class="card" v-if="history.length > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">近期记录</text>
+          <view class="history-list">
+            <view class="history-item" v-for="item in history" :key="item.id">
+              <view class="history-left">
+                <text class="history-type">{{ exerciseTypeLabel(item.exerciseType) }}</text>
+                <text class="history-content">{{ item.durationMinutes }}分钟{{ item.caloriesBurned ? ' · ' + item.caloriesBurned + '千卡' : '' }}</text>
+              </view>
+              <text class="history-date">{{ formatDate(item.createdAt) }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '../../config'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  data() {
+    return {
+      memberId: null,
+      submitting: false,
+      isRecording: false,
+      recordSeconds: 0,
+      recordTimer: null,
+      recorderManager: null,
+      history: [],
+      exerciseTypes: [
+        { label: '跑步', value: 'running' },
+        { label: '游泳', value: 'swimming' },
+        { label: '跳绳', value: 'jump_rope' },
+        { label: '球类', value: 'ball' },
+        { label: '骑行', value: 'cycling' },
+        { label: '体操', value: 'gymnastics' },
+        { label: '其他', value: 'other' }
+      ],
+      intensityOptions: [
+        { label: '低', value: 'low' },
+        { label: '中', value: 'medium' },
+        { label: '高', value: 'high' }
+      ],
+      form: {
+        exerciseType: '',
+        durationMinutes: '',
+        intensity: 'medium',
+        caloriesBurned: '',
+        remark: '',
+        photos: [],
+        voicePath: ''
+      }
+    }
+  },
+  computed: {
+    recordDurationText: function() {
+      if (!this.form.voicePath) return ''
+      return '录音 ' + this.recordSeconds + '秒'
+    }
+  },
+  onLoad: function() {
+    var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+    if (role !== 'child') {
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
+    }
+    this.recorderManager = uni.getRecorderManager()
+    this.recorderManager.onStart(() => {
+      this.isRecording = true
+      this.recordSeconds = 0
+      this.recordTimer = setInterval(() => { this.recordSeconds++ }, 1000)
+    })
+    this.recorderManager.onStop((res) => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      this.form.voicePath = res.tempFilePath
+      this.form.voiceDuration = res.duration
+    })
+    this.recorderManager.onError(() => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      uni.showToast({ title: '录音失败', icon: 'none' })
+    })
+    this.loadHistory()
+  },
+  onUnload: function() {
+    if (this.recordTimer) clearInterval(this.recordTimer)
+  },
+  methods: {
+    goBack: function() { uni.navigateBack() },
+    exerciseTypeLabel: function(type) {
+      var m = { running: '跑步', swimming: '游泳', jump_rope: '跳绳', ball: '球类', cycling: '骑行', gymnastics: '体操', other: '其他' }
+      return m[type] || type || '运动'
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
+      var m2 = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return m2 + '-' + day
+    },
+    loadHistory: function() {
+      var self = this
+      var token = uni.getStorageSync('token')
+      if (!token || !self.memberId) return
+      uni.request({
+        url: config.API_BASE_URL + '/api/health/exercise/list',
+        method: 'POST',
+        data: { memberId: self.memberId },
+        header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        success: function(res) {
+          if (res.data && res.data.code === 200 && res.data.data) {
+            self.history = res.data.data.slice(0, 5)
+          }
+        }
+      })
+    },
+    choosePhoto: function() {
+      var self = this
+      uni.chooseImage({
+        count: 9 - self.form.photos.length,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: function(res) {
+          res.tempFilePaths.forEach(function(path) {
+            if (self.form.photos.length < 9) self.form.photos.push(path)
+          })
+        }
+      })
+    },
+    removePhoto: function(idx) {
+      this.form.photos.splice(idx, 1)
+    },
+    toggleRecord: function() {
+      if (this.isRecording) {
+        this.recorderManager.stop()
+      } else {
+        this.recorderManager.start({ duration: 60000, format: 'mp3' })
+      }
+    },
+    uploadFile: function(filePath) {
+      var self = this
+      return new Promise(function(resolve, reject) {
+        uni.uploadFile({
+          url: config.API_BASE_URL + '/api/media/upload',
+          filePath: filePath,
+          name: 'file',
+          header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+          success: function(res) {
+            try { var data = JSON.parse(res.data); resolve(data.data || data.url || '') }
+            catch (e) { resolve(res.data || '') }
+          },
+          fail: function() { reject() }
+        })
+      })
+    },
+    async doCheckin() {
+      var self = this
+      if (!self.form.exerciseType) { uni.showToast({ title: '请选择运动类型', icon: 'none' }); return }
+      if (!self.form.durationMinutes) { uni.showToast({ title: '请填写运动时长', icon: 'none' }); return }
+      if (!self.memberId) { uni.showToast({ title: '孩子ID无效', icon: 'none' }); return }
+      self.submitting = true
+      try {
+        var photoUrls = []
+        if (self.form.photos && self.form.photos.length > 0) {
+          for (var i = 0; i < self.form.photos.length; i++) {
+            try { var url = await self.uploadFile(self.form.photos[i]) } catch(e) { url = '' }
+            if (url) photoUrls.push(url)
+          }
+        }
+        var voiceUrl = ''
+        if (self.form.voicePath) {
+          try { voiceUrl = await self.uploadFile(self.form.voicePath) } catch(e) { voiceUrl = '' }
+        }
+        var token = uni.getStorageSync('token')
+        uni.request({
+          url: config.API_BASE_URL + '/api/health/exercise/create',
+          method: 'POST',
+          data: {
+            memberId: self.memberId,
+            exerciseType: self.form.exerciseType,
+            durationMinutes: parseInt(self.form.durationMinutes),
+            intensity: self.form.intensity,
+            caloriesBurned: self.form.caloriesBurned ? parseInt(self.form.caloriesBurned) : null,
+            remark: self.form.remark || '',
+            photoUrls: photoUrls.join(','),
+            voiceUrl: voiceUrl
+          },
+          header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+          success: function(res) {
+            self.submitting = false
+            if (res.data && res.data.code === 200) {
+              uni.showToast({ title: '打卡成功', icon: 'success' })
+              self.form.exerciseType = ''
+              self.form.durationMinutes = ''
+              self.form.intensity = 'medium'
+              self.form.caloriesBurned = ''
+              self.form.remark = ''
+              self.form.photos = []
+              self.form.voicePath = ''
+              self.recordSeconds = 0
+              self.loadHistory()
+            } else {
+              uni.showToast({ title: (res.data && res.data.message) || '打卡失败', icon: 'none' })
+            }
+          },
+          fail: function() {
+            self.submitting = false
+            uni.showToast({ title: '网络错误', icon: 'none' })
+          }
+        })
+      } catch (e) {
+        self.submitting = false
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #FFF7ED; }
+.nav-bar { display: flex; align-items: center; height: 88rpx; padding-top: env(safe-area-inset-top); background: #fff; border-bottom: 1rpx solid #f0f0f0; position: relative; }
+.nav-back { position: absolute; left: 24rpx; top: 0; bottom: 0; display: flex; align-items: center; }
+.back-text { font-size: 28rpx; color: #F97316; }
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: bold; color: #333; }
+.content { height: calc(100vh - 88rpx); }
+.card { margin: 16rpx 24rpx; background: #fff; border-radius: 20rpx; overflow: hidden; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.card-accent { height: 6rpx; background: linear-gradient(90deg, #FF8C42, #F97316); }
+.card-body { padding: 24rpx; }
+.card-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; display: block; }
+.form-group { margin-bottom: 20rpx; }
+.form-label { font-size: 26rpx; color: #333; font-weight: 500; margin-bottom: 12rpx; display: block; }
+.tag-row { display: flex; flex-wrap: wrap; gap: 12rpx; }
+.tag-btn { padding: 10rpx 24rpx; border-radius: 30rpx; border: 2rpx solid #e0e0e0; background: #fafafa; }
+.tag-active { border-color: #F97316; background: #FFF3E0; }
+.tag-btn text { font-size: 24rpx; color: #666; }
+.tag-active text { font-size: 24rpx; color: #F97316; font-weight: bold; }
+.form-input { width: 100%; border: 1rpx solid #e0e0e0; border-radius: 12rpx; padding: 16rpx; font-size: 26rpx; background: #FAFAFA; box-sizing: border-box; }
+.photo-row { display: flex; flex-wrap: wrap; gap: 16rpx; }
+.photo-item { position: relative; width: 160rpx; height: 160rpx; }
+.photo-preview { width: 100%; height: 100%; border-radius: 12rpx; object-fit: cover; }
+.photo-del { position: absolute; top: -8rpx; right: -8rpx; width: 36rpx; height: 36rpx; background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%; font-size: 24rpx; text-align: center; line-height: 36rpx; }
+.photo-add { width: 160rpx; height: 160rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; }
+.photo-add-icon { font-size: 48rpx; color: #ccc; }
+.photo-add-text { font-size: 22rpx; color: #999; margin-top: 8rpx; }
+.voice-row { display: flex; flex-direction: column; align-items: center; gap: 12rpx; padding: 16rpx 0; }
+.voice-wave { display: flex; align-items: center; gap: 8rpx; font-size: 24rpx; color: #F97316; }
+.wave-bar { font-size: 28rpx; color: #ccc; }
+.wave-bar.active { animation: wave 0.6s infinite; }
+@keyframes wave { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
+.voice-duration { font-size: 24rpx; color: #666; }
+.voice-btn { display: flex; flex-direction: column; align-items: center; gap: 8rpx; padding: 20rpx 48rpx; border-radius: 40rpx; background: #FFF3E0; border: 2rpx solid #FFB74D; }
+.voice-btn-recording { background: #FEE2E2; border-color: #EF4444; }
+.voice-btn-icon { font-size: 40rpx; }
+.voice-btn-text { font-size: 24rpx; color: #F97316; }
+.voice-btn-recording .voice-btn-text { color: #EF4444; }
+.voice-hint { font-size: 22rpx; color: #999; text-align: center; }
+.submit-wrap { padding: 24rpx 24rpx 40rpx; }
+.submit-btn { background: linear-gradient(135deg, #FF8C42, #F97316); color: #fff; text-align: center; padding: 24rpx 0; border-radius: 16rpx; font-size: 30rpx; font-weight: bold; }
+.submitting { opacity: 0.6; }
+.history-list { display: flex; flex-direction: column; gap: 16rpx; }
+.history-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.history-left { flex: 1; min-width: 0; }
+.history-type { font-size: 26rpx; font-weight: 500; color: #333; display: block; }
+.history-content { font-size: 22rpx; color: #888; display: block; margin-top: 4rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.history-date { font-size: 22rpx; color: #999; flex-shrink: 0; margin-left: 16rpx; }
+</style>

+ 348 - 0
cfc-frontend/pages/growth/sleep-checkin.vue

@@ -0,0 +1,348 @@
+<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="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">记录睡眠</text>
+          <view class="form-group">
+            <text class="form-label">入睡时间 *</text>
+            <picker mode="time" :value="form.sleepTime" @change="function(e){form.sleepTime=e.detail.value}">
+              <view class="picker-display">{{ form.sleepTime || '选择入睡时间' }}</view>
+            </picker>
+          </view>
+          <view class="form-group">
+            <text class="form-label">醒来时间 *</text>
+            <picker mode="time" :value="form.wakeTime" @change="function(e){form.wakeTime=e.detail.value}">
+              <view class="picker-display">{{ form.wakeTime || '选择醒来时间' }}</view>
+            </picker>
+          </view>
+          <view class="form-group">
+            <text class="form-label">睡眠质量(1-10)</text>
+            <view class="slider-row">
+              <text class="slider-min">差</text>
+              <slider min="1" max="10" :value="form.qualityScore" @change="function(e){form.qualityScore=e.detail.value}" block-size="20" activeColor="#FF8C42" />
+              <text class="slider-val">{{ form.qualityScore }}</text>
+              <text class="slider-max">好</text>
+            </view>
+          </view>
+          <view class="form-group">
+            <text class="form-label">备注(选填)</text>
+            <input class="form-input" v-model="form.remark" placeholder="睡眠感受..." />
+          </view>
+        </view>
+      </view>
+
+      <!-- 照片上传 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">📷 拍照记录</text>
+          <view class="photo-row">
+            <view class="photo-item" v-for="(img, idx) in form.photos" :key="'p' + idx" @tap="removePhoto(idx)">
+              <image :src="img" mode="aspectFill" class="photo-preview" />
+              <text class="photo-del">×</text>
+            </view>
+            <view class="photo-add" @tap="choosePhoto">
+              <text class="photo-add-icon">+</text>
+              <text class="photo-add-text">拍照</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 语音录入 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">🎙️ 语音打卡</text>
+          <view class="voice-row">
+            <view class="voice-wave" v-if="isRecording">
+              <text class="wave-bar active">◉</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-bar">○</text>
+              <text class="wave-text">录音中 {{ recordSeconds }}s</text>
+            </view>
+            <text class="voice-duration" v-else-if="form.voicePath">{{ recordDurationText }}</text>
+            <view class="voice-btn" :class="{ 'voice-btn-recording': isRecording }" @tap="toggleRecord">
+              <text class="voice-btn-icon">{{ isRecording ? '⏹' : '🎙' }}</text>
+              <text class="voice-btn-text">{{ isRecording ? '结束' : '按住录音' }}</text>
+            </view>
+          </view>
+          <text class="voice-hint">长按录音,松开结束</text>
+        </view>
+      </view>
+
+      <!-- 提交按钮 -->
+      <view class="submit-wrap">
+        <view class="submit-btn" :class="{ 'submitting': submitting }" @tap="doCheckin">
+          <text>{{ submitting ? '提交中...' : '✓ 完成打卡' }}</text>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view class="card" v-if="history.length > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">近期睡眠</text>
+          <view class="history-list">
+            <view class="history-item" v-for="item in history" :key="item.id">
+              <view class="history-left">
+                <text class="history-type">{{ item.sleepTime }} - {{ item.wakeTime }}</text>
+                <text class="history-content">{{ item.durationMinutes }}分钟 · 质量{{ item.qualityScore }}分</text>
+              </view>
+              <text class="history-date">{{ formatDate(item.createdAt) }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '../../config'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  data() {
+    return {
+      memberId: null,
+      submitting: false,
+      isRecording: false,
+      recordSeconds: 0,
+      recordTimer: null,
+      recorderManager: null,
+      history: [],
+      form: {
+        sleepTime: '21:00',
+        wakeTime: '07:00',
+        qualityScore: 7,
+        remark: '',
+        photos: [],
+        voicePath: ''
+      }
+    }
+  },
+  computed: {
+    recordDurationText: function() {
+      if (!this.form.voicePath) return ''
+      return '录音 ' + this.recordSeconds + '秒'
+    }
+  },
+  onLoad: function() {
+    var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+    if (role !== 'child') {
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
+    }
+    this.recorderManager = uni.getRecorderManager()
+    this.recorderManager.onStart(() => {
+      this.isRecording = true
+      this.recordSeconds = 0
+      this.recordTimer = setInterval(() => { this.recordSeconds++ }, 1000)
+    })
+    this.recorderManager.onStop((res) => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      this.form.voicePath = res.tempFilePath
+      this.form.voiceDuration = res.duration
+    })
+    this.recorderManager.onError(() => {
+      this.isRecording = false
+      if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
+      uni.showToast({ title: '录音失败', icon: 'none' })
+    })
+    this.loadHistory()
+  },
+  onUnload: function() {
+    if (this.recordTimer) clearInterval(this.recordTimer)
+  },
+  methods: {
+    goBack: function() { uni.navigateBack() },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
+      var m2 = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return m2 + '-' + day
+    },
+    loadHistory: function() {
+      var self = this
+      var token = uni.getStorageSync('token')
+      if (!token || !self.memberId) return
+      uni.request({
+        url: config.API_BASE_URL + '/api/health/sleep/list',
+        method: 'POST',
+        data: { memberId: self.memberId },
+        header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        success: function(res) {
+          if (res.data && res.data.code === 200 && res.data.data) {
+            self.history = res.data.data.slice(0, 5)
+          }
+        }
+      })
+    },
+    choosePhoto: function() {
+      var self = this
+      uni.chooseImage({
+        count: 9 - self.form.photos.length,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: function(res) {
+          res.tempFilePaths.forEach(function(path) {
+            if (self.form.photos.length < 9) self.form.photos.push(path)
+          })
+        }
+      })
+    },
+    removePhoto: function(idx) {
+      this.form.photos.splice(idx, 1)
+    },
+    toggleRecord: function() {
+      if (this.isRecording) {
+        this.recorderManager.stop()
+      } else {
+        this.recorderManager.start({ duration: 60000, format: 'mp3' })
+      }
+    },
+    uploadFile: function(filePath) {
+      var self = this
+      return new Promise(function(resolve, reject) {
+        uni.uploadFile({
+          url: config.API_BASE_URL + '/api/media/upload',
+          filePath: filePath,
+          name: 'file',
+          header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+          success: function(res) {
+            try { var data = JSON.parse(res.data); resolve(data.data || data.url || '') }
+            catch (e) { resolve(res.data || '') }
+          },
+          fail: function() { reject() }
+        })
+      })
+    },
+    async doCheckin() {
+      var self = this
+      if (!self.form.sleepTime) { uni.showToast({ title: '请选择入睡时间', icon: 'none' }); return }
+      if (!self.form.wakeTime) { uni.showToast({ title: '请选择醒来时间', icon: 'none' }); return }
+      if (!self.memberId) { uni.showToast({ title: '孩子ID无效', icon: 'none' }); return }
+      self.submitting = true
+      try {
+        var photoUrls = []
+        if (self.form.photos && self.form.photos.length > 0) {
+          for (var i = 0; i < self.form.photos.length; i++) {
+            try { var url = await self.uploadFile(self.form.photos[i]) } catch(e) { url = '' }
+            if (url) photoUrls.push(url)
+          }
+        }
+        var voiceUrl = ''
+        if (self.form.voicePath) {
+          try { voiceUrl = await self.uploadFile(self.form.voicePath) } catch(e) { voiceUrl = '' }
+        }
+        var sleepParts = self.form.sleepTime.split(':')
+        var wakeParts = self.form.wakeTime.split(':')
+        var sleepMin = parseInt(sleepParts[0]) * 60 + parseInt(sleepParts[1])
+        var wakeMin = parseInt(wakeParts[0]) * 60 + parseInt(wakeParts[1])
+        var duration = wakeMin > sleepMin ? wakeMin - sleepMin : (24 * 60 - sleepMin + wakeMin)
+        var token = uni.getStorageSync('token')
+        uni.request({
+          url: config.API_BASE_URL + '/api/health/sleep/create',
+          method: 'POST',
+          data: {
+            memberId: self.memberId,
+            sleepTime: self.form.sleepTime,
+            wakeTime: self.form.wakeTime,
+            durationMinutes: duration,
+            qualityScore: self.form.qualityScore,
+            remark: self.form.remark || '',
+            photoUrls: photoUrls.join(','),
+            voiceUrl: voiceUrl
+          },
+          header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+          success: function(res) {
+            self.submitting = false
+            if (res.data && res.data.code === 200) {
+              uni.showToast({ title: '打卡成功', icon: 'success' })
+              self.form.sleepTime = '21:00'
+              self.form.wakeTime = '07:00'
+              self.form.qualityScore = 7
+              self.form.remark = ''
+              self.form.photos = []
+              self.form.voicePath = ''
+              self.recordSeconds = 0
+              self.loadHistory()
+            } else {
+              uni.showToast({ title: (res.data && res.data.message) || '打卡失败', icon: 'none' })
+            }
+          },
+          fail: function() {
+            self.submitting = false
+            uni.showToast({ title: '网络错误', icon: 'none' })
+          }
+        })
+      } catch (e) {
+        self.submitting = false
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #FFF7ED; }
+.nav-bar { display: flex; align-items: center; height: 88rpx; padding-top: env(safe-area-inset-top); background: #fff; border-bottom: 1rpx solid #f0f0f0; position: relative; }
+.nav-back { position: absolute; left: 24rpx; top: 0; bottom: 0; display: flex; align-items: center; }
+.back-text { font-size: 28rpx; color: #F97316; }
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: bold; color: #333; }
+.content { height: calc(100vh - 88rpx); }
+.card { margin: 16rpx 24rpx; background: #fff; border-radius: 20rpx; overflow: hidden; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.card-accent { height: 6rpx; background: linear-gradient(90deg, #FF8C42, #F97316); }
+.card-body { padding: 24rpx; }
+.card-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; display: block; }
+.form-group { margin-bottom: 20rpx; }
+.form-label { font-size: 26rpx; color: #333; font-weight: 500; margin-bottom: 12rpx; display: block; }
+.picker-display { border: 1rpx solid #ddd; border-radius: 12rpx; padding: 16rpx 20rpx; font-size: 26rpx; background: #FAFAFA; color: #333; }
+.slider-row { display: flex; align-items: center; gap: 12rpx; }
+.slider-min { font-size: 22rpx; color: #999; flex-shrink: 0; }
+.slider-val { font-size: 24rpx; color: #FF8C42; font-weight: bold; width: 40rpx; text-align: center; }
+.slider-max { font-size: 22rpx; color: #999; flex-shrink: 0; }
+.form-input { width: 100%; border: 1rpx solid #e0e0e0; border-radius: 12rpx; padding: 16rpx; font-size: 26rpx; background: #FAFAFA; box-sizing: border-box; }
+.photo-row { display: flex; flex-wrap: wrap; gap: 16rpx; }
+.photo-item { position: relative; width: 160rpx; height: 160rpx; }
+.photo-preview { width: 100%; height: 100%; border-radius: 12rpx; object-fit: cover; }
+.photo-del { position: absolute; top: -8rpx; right: -8rpx; width: 36rpx; height: 36rpx; background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%; font-size: 24rpx; text-align: center; line-height: 36rpx; }
+.photo-add { width: 160rpx; height: 160rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; }
+.photo-add-icon { font-size: 48rpx; color: #ccc; }
+.photo-add-text { font-size: 22rpx; color: #999; margin-top: 8rpx; }
+.voice-row { display: flex; flex-direction: column; align-items: center; gap: 12rpx; padding: 16rpx 0; }
+.voice-wave { display: flex; align-items: center; gap: 8rpx; font-size: 24rpx; color: #F97316; }
+.wave-bar { font-size: 28rpx; color: #ccc; }
+.wave-bar.active { animation: wave 0.6s infinite; }
+@keyframes wave { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
+.voice-duration { font-size: 24rpx; color: #666; }
+.voice-btn { display: flex; flex-direction: column; align-items: center; gap: 8rpx; padding: 20rpx 48rpx; border-radius: 40rpx; background: #FFF3E0; border: 2rpx solid #FFB74D; }
+.voice-btn-recording { background: #FEE2E2; border-color: #EF4444; }
+.voice-btn-icon { font-size: 40rpx; }
+.voice-btn-text { font-size: 24rpx; color: #F97316; }
+.voice-btn-recording .voice-btn-text { color: #EF4444; }
+.voice-hint { font-size: 22rpx; color: #999; text-align: center; }
+.submit-wrap { padding: 24rpx 24rpx 40rpx; }
+.submit-btn { background: linear-gradient(135deg, #FF8C42, #F97316); color: #fff; text-align: center; padding: 24rpx 0; border-radius: 16rpx; font-size: 30rpx; font-weight: bold; }
+.submitting { opacity: 0.6; }
+.history-list { display: flex; flex-direction: column; gap: 16rpx; }
+.history-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.history-left { flex: 1; min-width: 0; }
+.history-type { font-size: 26rpx; font-weight: 500; color: #333; display: block; }
+.history-content { font-size: 22rpx; color: #888; display: block; margin-top: 4rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.history-date { font-size: 22rpx; color: #999; flex-shrink: 0; margin-left: 16rpx; }
+</style>

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-7fe64802b985e68375cdcb14974a13c9fe9f14bf
+e169afc0a0c9103d9c4afadcda00bc1945d724b6

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

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

+ 1 - 1
cfc-web/package.json

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

+ 6 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,12 @@
 
 
 ---
 ---
 
 
+## v1.0.1096 (2026-08-16)
+
+### Bug 修复
+- 迁移248创建health_report_edit_logs表,修复表不存在错误
+
+
 ## v1.0.1095 (2026-08-16)
 ## v1.0.1095 (2026-08-16)
 
 
 ### Bug 修复
 ### Bug 修复

+ 7 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1095
+> 当前版本: v1.0.1096
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,12 @@
 
 
 ---
 ---
 
 
+## v1.0.1096 (2026-08-16)
+
+### Bug 修复
+- 迁移248创建health_report_edit_logs表,修复表不存在错误
+
+
 ## v1.0.1095 (2026-08-16)
 ## v1.0.1095 (2026-08-16)
 
 
 ### Bug 修复
 ### Bug 修复