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

fix(checkin): 补全打卡系统缺失的 schema.sql 建表 + 迁移 + 小程序前端打卡功能

E2E Test Bot 3 месяцев назад
Родитель
Сommit
7abedfdb7c

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

@@ -7634,6 +7634,52 @@ private void runMigration100() {
 			log.info("已为payment_orders.order_no添加唯一约束");
 			log.info("已为payment_orders.order_no添加唯一约束");
 		} catch (Exception e) {
 		} catch (Exception e) {
 			// 索引已存在或已处理,忽略
 			// 索引已存在或已处理,忽略
+
+		// 迁移161: 创建 daily_checkin 表(每日健康综合打卡,含饮食/运动/睡眠/饮水/心情评分)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS daily_checkin (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+				"checkin_date DATE COMMENT '打卡日期', " +
+				"diet_score INT COMMENT '饮食评分', " +
+				"exercise_score INT COMMENT '运动评分', " +
+				"sleep_score INT COMMENT '睡眠评分', " +
+				"water_intake INT COMMENT '饮水量(ml)', " +
+				"mood_score INT COMMENT '心情评分', " +
+				"weight_kg DECIMAL(5,2) COMMENT '体重(kg)', " +
+				"height_cm DECIMAL(5,2) COMMENT '身高(cm)', " +
+				"remark TEXT COMMENT '备注', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_member_id (member_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康打卡表'");
+			log.info("已创建daily_checkin表");
+} catch (Exception ex) {
+			log.warn("创建daily_checkin表失败: {}", ex.getMessage());
+		}
+
+		// 迁移162: 创建 exercise_record 表(运动打卡记录)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS exercise_record (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+				"checkin_id BIGINT COMMENT '关联打卡ID', " +
+				"exercise_type VARCHAR(50) COMMENT '运动类型', " +
+				"duration_minutes INT COMMENT '时长(分钟)', " +
+				"intensity VARCHAR(20) COMMENT '强度: low/medium/high', " +
+				"calories_burned INT COMMENT '消耗卡路里', " +
+				"heart_rate_avg INT COMMENT '平均心率', " +
+				"heart_rate_max INT COMMENT '最大心率', " +
+				"start_time DATETIME COMMENT '开始时间', " +
+				"end_time DATETIME COMMENT '结束时间', " +
+				"remark TEXT COMMENT '备注', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_member_id (member_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动打卡记录'");
+			log.info("已创建exercise_record表");
+} catch (Exception ex) {
+			log.warn("创建exercise_record表失败: {}", ex.getMessage());
+		}
 		}
 		}
 	}
 	}
 }
 }

+ 52 - 1
cfc-backend/src/main/resources/schema.sql

@@ -2845,7 +2845,40 @@ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 INDEX idx_target (target_type, target_id)
 INDEX idx_target (target_type, target_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度权重表';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度权重表';
 
 
--- 健康打卡 - 运动记录
+-- 健康打卡表
+CREATE TABLE IF NOT EXISTS health_checkins (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID(家长)',
+    child_id BIGINT COMMENT '孩子ID',
+    checkin_date DATE COMMENT '打卡日期',
+    behaviors TEXT COMMENT '打卡行为JSON数组',
+    note VARCHAR(500) COMMENT '备注',
+    mood VARCHAR(20) COMMENT '心情: happy/normal/sad/angry/sick',
+    earned_energy INT DEFAULT 5 COMMENT '获得能量值',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_id (child_id),
+    INDEX idx_checkin_date (checkin_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康打卡表';
+
+-- 每日健康打卡表(综合打卡:饮食/运动/睡眠/饮水/心情评分)
+CREATE TABLE IF NOT EXISTS daily_checkin (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    checkin_date DATE COMMENT '打卡日期',
+    diet_score INT COMMENT '饮食评分',
+    exercise_score INT COMMENT '运动评分',
+    sleep_score INT COMMENT '睡眠评分',
+    water_intake INT COMMENT '饮水量(ml)',
+    mood_score INT COMMENT '心情评分',
+    weight_kg DECIMAL(5,2) COMMENT '体重(kg)',
+    height_cm DECIMAL(5,2) COMMENT '身高(cm)',
+    remark TEXT COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_member_id (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康打卡表';
+
+-- 健康打卡 - 睡眠记录
 CREATE TABLE IF NOT EXISTS sleep_record (
 CREATE TABLE IF NOT EXISTS sleep_record (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     member_id BIGINT NOT NULL COMMENT '家庭成员ID',
     member_id BIGINT NOT NULL COMMENT '家庭成员ID',
@@ -2906,6 +2939,24 @@ CREATE TABLE IF NOT EXISTS health_meditation_record (
     INDEX idx_member_id (member_id)
     INDEX idx_member_id (member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='冥想打卡记录';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='冥想打卡记录';
 
 
+-- 健康打卡 - 运动记录
+CREATE TABLE IF NOT EXISTS exercise_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    checkin_id BIGINT COMMENT '关联打卡ID',
+    exercise_type VARCHAR(50) COMMENT '运动类型',
+    duration_minutes INT COMMENT '时长(分钟)',
+    intensity VARCHAR(20) COMMENT '强度: low/medium/high',
+    calories_burned INT COMMENT '消耗卡路里',
+    heart_rate_avg INT COMMENT '平均心率',
+    heart_rate_max INT COMMENT '最大心率',
+    start_time DATETIME COMMENT '开始时间',
+    end_time DATETIME COMMENT '结束时间',
+    remark TEXT COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_member_id (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动打卡记录';
+
 -- 电商供应商
 -- 电商供应商
 CREATE TABLE IF NOT EXISTS ecom_supplier (
 CREATE TABLE IF NOT EXISTS ecom_supplier (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 240 - 15
cfc-frontend/pages/health/diet-index.vue

@@ -78,18 +78,162 @@
           </view>
           </view>
         </view>
         </view>
       </view>
       </view>
+
+      <!-- 记录饮食表单 -->
+      <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" type="number" v-model="form.totalCalories" placeholder="估算总热量" />
+          </view>
+          <view class="form-group">
+            <text class="form-label">备注(选填)</text>
+            <input class="form-input" v-model="form.remark" placeholder="记录饮食感受..." />
+          </view>
+          <view class="submit-btn" @tap="doCheckin">
+            <text>{{ submitting ? '提交中...' : '提交打卡' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">饮食记录</text>
+          <view class="history-list" v-if="history.length > 0">
+            <view class="history-item" v-for="item in history" :key="item.id">
+              <view class="history-left">
+                <text class="history-type">{{ item.mealType }}</text>
+                <text class="history-duration">{{ item.foodItems || '-' }}</text>
+              </view>
+              <view class="history-right">
+                <text class="history-date">{{ formatDate(item.createdAt) }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="empty-state" v-else>
+            <text>还没有饮食记录</text>
+          </view>
+        </view>
+      </view>
     </scroll-view>
     </scroll-view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
+import config from '@/config.js'
+
 export default {
 export default {
   data() {
   data() {
-    return {}
+    return {
+      memberId: null,
+      submitting: false,
+      history: [],
+      mealTypes: [
+        { label: '早餐', value: 'breakfast' },
+        { label: '午餐', value: 'lunch' },
+        { label: '晚餐', value: 'dinner' },
+        { label: '加餐', value: 'snack' },
+        { label: '零食', value: 'extra' }
+      ],
+      form: {
+        mealType: '',
+        foodItems: '',
+        totalCalories: '',
+        remark: ''
+      }
+    }
+  },
+  onLoad: function() {
+    this.memberId = uni.getStorageSync('currentChildId') || null
+    this.loadHistory()
   },
   },
   methods: {
   methods: {
     goBack: function() {
     goBack: function() {
       uni.navigateBack()
       uni.navigateBack()
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      var m = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return m + '-' + 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, 10)
+          }
+        }
+      })
+    },
+    doCheckin: function() {
+      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
+      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 || '',
+          totalCalories: self.form.totalCalories ? parseInt(self.form.totalCalories) : null,
+          remark: self.form.remark || ''
+        },
+        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.totalCalories = ''
+            self.form.remark = ''
+            self.loadHistory()
+          } else {
+            var msg = (res.data && res.data.message) || '打卡失败'
+            uni.showToast({ title: msg, icon: 'none' })
+          }
+        },
+        fail: function() {
+          self.submitting = false
+          uni.showToast({ title: '网络错误', icon: 'none' })
+        }
+      })
     }
     }
   }
   }
 }
 }
@@ -102,7 +246,6 @@ export default {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
 }
 }
-
 .nav-bar {
 .nav-bar {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -113,7 +256,6 @@ export default {
   position: relative;
   position: relative;
   border-bottom: 1rpx solid #f0f0f0;
   border-bottom: 1rpx solid #f0f0f0;
 }
 }
-
 .nav-back {
 .nav-back {
   position: absolute;
   position: absolute;
   left: 30rpx;
   left: 30rpx;
@@ -121,23 +263,19 @@ export default {
   transform: translateY(-50%);
   transform: translateY(-50%);
   padding: 10rpx;
   padding: 10rpx;
 }
 }
-
 .back-text {
 .back-text {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #333;
   color: #333;
 }
 }
-
 .nav-title {
 .nav-title {
   font-size: 32rpx;
   font-size: 32rpx;
   font-weight: 600;
   font-weight: 600;
   color: #333;
   color: #333;
 }
 }
-
 .content {
 .content {
   flex: 1;
   flex: 1;
   padding: 30rpx;
   padding: 30rpx;
 }
 }
-
 .card {
 .card {
   display: flex;
   display: flex;
   background-color: #FFFFFF;
   background-color: #FFFFFF;
@@ -146,18 +284,15 @@ export default {
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
   overflow: hidden;
   overflow: hidden;
 }
 }
-
 .card-accent {
 .card-accent {
   width: 8rpx;
   width: 8rpx;
   background-color: #FF8C42;
   background-color: #FF8C42;
   flex-shrink: 0;
   flex-shrink: 0;
 }
 }
-
 .card-body {
 .card-body {
   flex: 1;
   flex: 1;
   padding: 30rpx;
   padding: 30rpx;
 }
 }
-
 .card-title {
 .card-title {
   font-size: 30rpx;
   font-size: 30rpx;
   font-weight: 600;
   font-weight: 600;
@@ -165,30 +300,120 @@ export default {
   margin-bottom: 24rpx;
   margin-bottom: 24rpx;
   display: block;
   display: block;
 }
 }
-
 .info-list {
 .info-list {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 20rpx;
   gap: 20rpx;
 }
 }
-
 .info-item {
 .info-item {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 6rpx;
   gap: 6rpx;
 }
 }
-
 .info-age {
 .info-age {
   font-size: 26rpx;
   font-size: 26rpx;
   font-weight: 500;
   font-weight: 500;
   color: #FF8C42;
   color: #FF8C42;
   display: block;
   display: block;
 }
 }
-
 .info-desc {
 .info-desc {
   font-size: 24rpx;
   font-size: 24rpx;
   color: #666;
   color: #666;
   line-height: 1.6;
   line-height: 1.6;
   display: block;
   display: block;
 }
 }
-</style>
+.form-group {
+  margin-bottom: 24rpx;
+}
+.form-label {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.form-input {
+  border: 1rpx solid #ddd;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 26rpx;
+  background-color: #FAFAFA;
+  width: auto;
+}
+.form-textarea {
+  border: 1rpx solid #ddd;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 26rpx;
+  background-color: #FAFAFA;
+  width: auto;
+  min-height: 120rpx;
+}
+.tag-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.tag-btn {
+  padding: 12rpx 24rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 30rpx;
+  font-size: 24rpx;
+  color: #666;
+  background-color: #FAFAFA;
+}
+.tag-active {
+  border-color: #FF8C42;
+  color: #FF8C42;
+  background-color: #FFF5EB;
+}
+.submit-btn {
+  background-color: #FF8C42;
+  color: #FFFFFF;
+  text-align: center;
+  padding: 22rpx 0;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+  margin-top: 10rpx;
+}
+.history-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.history-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.history-left {
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.history-type {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #333;
+}
+.history-duration {
+  font-size: 22rpx;
+  color: #999;
+}
+.history-right {
+  text-align: right;
+}
+.history-date {
+  font-size: 22rpx;
+  color: #999;
+}
+.empty-state {
+  text-align: center;
+  padding: 40rpx 0;
+  font-size: 26rpx;
+  color: #999;
+}
+</style>

+ 255 - 15
cfc-frontend/pages/health/exercise-index.vue

@@ -70,18 +70,186 @@
           </view>
           </view>
         </view>
         </view>
       </view>
       </view>
+
+      <!-- 记录运动表单 -->
+      <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 class="submit-btn" @tap="doCheckin">
+            <text>{{ submitting ? '提交中...' : '提交打卡' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">运动记录</text>
+          <view class="history-list" v-if="history.length > 0">
+            <view class="history-item" v-for="item in history" :key="item.id">
+              <view class="history-left">
+                <text class="history-type">{{ item.exerciseType }}</text>
+                <text class="history-duration">{{ item.durationMinutes }}分钟</text>
+              </view>
+              <view class="history-right">
+                <text class="history-date">{{ formatDate(item.createdAt) }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="empty-state" v-else>
+            <text>还没有运动记录</text>
+          </view>
+        </view>
+      </view>
     </scroll-view>
     </scroll-view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
+import config from '@/config.js'
+
 export default {
 export default {
   data() {
   data() {
-    return {}
+    return {
+      memberId: null,
+      submitting: false,
+      history: [],
+      exerciseTypes: [
+        { label: '跑步', value: 'running' },
+        { label: '游泳', value: 'swimming' },
+        { label: '骑行', value: 'cycling' },
+        { label: '跳绳', value: 'jump_rope' },
+        { label: '球类', value: 'ball_game' },
+        { label: '力量训练', value: 'strength' },
+        { label: '其他', value: 'other' }
+      ],
+      intensityOptions: [
+        { label: '低强度', value: 'low' },
+        { label: '中等', value: 'medium' },
+        { label: '高强度', value: 'high' }
+      ],
+      form: {
+        exerciseType: '',
+        durationMinutes: '',
+        intensity: 'medium',
+        caloriesBurned: '',
+        remark: ''
+      }
+    }
+  },
+  onLoad: function() {
+    this.memberId = uni.getStorageSync('currentChildId') || null
+    this.loadHistory()
   },
   },
   methods: {
   methods: {
     goBack: function() {
     goBack: function() {
       uni.navigateBack()
       uni.navigateBack()
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      var m = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return m + '-' + 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, 10)
+          }
+        }
+      })
+    },
+    doCheckin: function() {
+      var self = this
+      if (!self.form.exerciseType) {
+        uni.showToast({ title: '请选择运动类型', icon: 'none' })
+        return
+      }
+      if (!self.form.durationMinutes || parseInt(self.form.durationMinutes) <= 0) {
+        uni.showToast({ title: '请输入有效时长', icon: 'none' })
+        return
+      }
+      if (!self.memberId) {
+        uni.showToast({ title: '孩子ID无效', icon: 'none' })
+        return
+      }
+      self.submitting = true
+      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 || ''
+        },
+        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.loadHistory()
+          } else {
+            var msg = (res.data && res.data.message) || '打卡失败'
+            uni.showToast({ title: msg, icon: 'none' })
+          }
+        },
+        fail: function() {
+          self.submitting = false
+          uni.showToast({ title: '网络错误', icon: 'none' })
+        }
+      })
     }
     }
   }
   }
 }
 }
@@ -94,7 +262,6 @@ export default {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
 }
 }
-
 .nav-bar {
 .nav-bar {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -105,7 +272,6 @@ export default {
   position: relative;
   position: relative;
   border-bottom: 1rpx solid #f0f0f0;
   border-bottom: 1rpx solid #f0f0f0;
 }
 }
-
 .nav-back {
 .nav-back {
   position: absolute;
   position: absolute;
   left: 30rpx;
   left: 30rpx;
@@ -113,23 +279,19 @@ export default {
   transform: translateY(-50%);
   transform: translateY(-50%);
   padding: 10rpx;
   padding: 10rpx;
 }
 }
-
 .back-text {
 .back-text {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #333;
   color: #333;
 }
 }
-
 .nav-title {
 .nav-title {
   font-size: 32rpx;
   font-size: 32rpx;
   font-weight: 600;
   font-weight: 600;
   color: #333;
   color: #333;
 }
 }
-
 .content {
 .content {
   flex: 1;
   flex: 1;
   padding: 30rpx;
   padding: 30rpx;
 }
 }
-
 .card {
 .card {
   display: flex;
   display: flex;
   background-color: #FFFFFF;
   background-color: #FFFFFF;
@@ -138,18 +300,15 @@ export default {
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
   overflow: hidden;
   overflow: hidden;
 }
 }
-
 .card-accent {
 .card-accent {
   width: 8rpx;
   width: 8rpx;
   background-color: #FF8C42;
   background-color: #FF8C42;
   flex-shrink: 0;
   flex-shrink: 0;
 }
 }
-
 .card-body {
 .card-body {
   flex: 1;
   flex: 1;
   padding: 30rpx;
   padding: 30rpx;
 }
 }
-
 .card-title {
 .card-title {
   font-size: 30rpx;
   font-size: 30rpx;
   font-weight: 600;
   font-weight: 600;
@@ -157,30 +316,111 @@ export default {
   margin-bottom: 24rpx;
   margin-bottom: 24rpx;
   display: block;
   display: block;
 }
 }
-
 .info-list {
 .info-list {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 20rpx;
   gap: 20rpx;
 }
 }
-
 .info-item {
 .info-item {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 6rpx;
   gap: 6rpx;
 }
 }
-
 .info-age {
 .info-age {
   font-size: 26rpx;
   font-size: 26rpx;
   font-weight: 500;
   font-weight: 500;
   color: #FF8C42;
   color: #FF8C42;
   display: block;
   display: block;
 }
 }
-
 .info-desc {
 .info-desc {
   font-size: 24rpx;
   font-size: 24rpx;
   color: #666;
   color: #666;
   line-height: 1.6;
   line-height: 1.6;
   display: block;
   display: block;
 }
 }
-</style>
+.form-group {
+  margin-bottom: 24rpx;
+}
+.form-label {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.form-input {
+  border: 1rpx solid #ddd;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 26rpx;
+  background-color: #FAFAFA;
+  width: auto;
+}
+.tag-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.tag-btn {
+  padding: 12rpx 24rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 30rpx;
+  font-size: 24rpx;
+  color: #666;
+  background-color: #FAFAFA;
+}
+.tag-active {
+  border-color: #FF8C42;
+  color: #FF8C42;
+  background-color: #FFF5EB;
+}
+.submit-btn {
+  background-color: #FF8C42;
+  color: #FFFFFF;
+  text-align: center;
+  padding: 22rpx 0;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+  margin-top: 10rpx;
+}
+.history-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.history-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.history-left {
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.history-type {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #333;
+}
+.history-duration {
+  font-size: 22rpx;
+  color: #999;
+}
+.history-right {
+  text-align: right;
+}
+.history-date {
+  font-size: 22rpx;
+  color: #999;
+}
+.empty-state {
+  text-align: center;
+  padding: 40rpx 0;
+  font-size: 26rpx;
+  color: #999;
+}
+</style>

+ 248 - 15
cfc-frontend/pages/health/sleep-index.vue

@@ -74,18 +74,172 @@
           </view>
           </view>
         </view>
         </view>
       </view>
       </view>
+
+      <!-- 记录睡眠表单 -->
+      <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>
+            </view>
+          </view>
+          <view class="form-group">
+            <text class="form-label">深睡时长(分钟,选填)</text>
+            <input class="form-input" type="number" v-model="form.deepSleepMinutes" placeholder="深睡大约多少分钟" />
+          </view>
+          <view class="form-group">
+            <text class="form-label">备注(选填)</text>
+            <input class="form-input" v-model="form.remark" placeholder="记录睡眠感受..." />
+          </view>
+          <view class="submit-btn" @tap="doCheckin">
+            <text>{{ submitting ? '提交中...' : '提交打卡' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">睡眠记录</text>
+          <view class="history-list" v-if="history.length > 0">
+            <view class="history-item" v-for="item in history" :key="item.id">
+              <view class="history-left">
+                <text class="history-type">{{ item.durationMinutes || '-' }}分钟</text>
+                <text class="history-duration">质量评分: {{ item.qualityScore || '-' }}</text>
+              </view>
+              <view class="history-right">
+                <text class="history-date">{{ formatDate(item.createdAt) }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="empty-state" v-else>
+            <text>还没有睡眠记录</text>
+          </view>
+        </view>
+      </view>
     </scroll-view>
     </scroll-view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
+import config from '@/config.js'
+
 export default {
 export default {
   data() {
   data() {
-    return {}
+    return {
+      memberId: null,
+      submitting: false,
+      history: [],
+      form: {
+        sleepTime: '21:00',
+        wakeTime: '07:00',
+        qualityScore: 7,
+        deepSleepMinutes: '',
+        remark: ''
+      }
+    }
+  },
+  onLoad: function() {
+    this.memberId = uni.getStorageSync('currentChildId') || null
+    this.loadHistory()
   },
   },
   methods: {
   methods: {
     goBack: function() {
     goBack: function() {
       uni.navigateBack()
       uni.navigateBack()
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      var m = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return m + '-' + 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, 10)
+          }
+        }
+      })
+    },
+    doCheckin: function() {
+      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
+      var token = uni.getStorageSync('token')
+      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)
+      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,
+          deepSleepMinutes: self.form.deepSleepMinutes ? parseInt(self.form.deepSleepMinutes) : null,
+          remark: self.form.remark || ''
+        },
+        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.qualityScore = 7
+            self.form.deepSleepMinutes = ''
+            self.form.remark = ''
+            self.loadHistory()
+          } else {
+            var msg = (res.data && res.data.message) || '打卡失败'
+            uni.showToast({ title: msg, icon: 'none' })
+          }
+        },
+        fail: function() {
+          self.submitting = false
+          uni.showToast({ title: '网络错误', icon: 'none' })
+        }
+      })
     }
     }
   }
   }
 }
 }
@@ -98,7 +252,6 @@ export default {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
 }
 }
-
 .nav-bar {
 .nav-bar {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -109,7 +262,6 @@ export default {
   position: relative;
   position: relative;
   border-bottom: 1rpx solid #f0f0f0;
   border-bottom: 1rpx solid #f0f0f0;
 }
 }
-
 .nav-back {
 .nav-back {
   position: absolute;
   position: absolute;
   left: 30rpx;
   left: 30rpx;
@@ -117,23 +269,19 @@ export default {
   transform: translateY(-50%);
   transform: translateY(-50%);
   padding: 10rpx;
   padding: 10rpx;
 }
 }
-
 .back-text {
 .back-text {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #333;
   color: #333;
 }
 }
-
 .nav-title {
 .nav-title {
   font-size: 32rpx;
   font-size: 32rpx;
   font-weight: 600;
   font-weight: 600;
   color: #333;
   color: #333;
 }
 }
-
 .content {
 .content {
   flex: 1;
   flex: 1;
   padding: 30rpx;
   padding: 30rpx;
 }
 }
-
 .card {
 .card {
   display: flex;
   display: flex;
   background-color: #FFFFFF;
   background-color: #FFFFFF;
@@ -142,18 +290,15 @@ export default {
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
   overflow: hidden;
   overflow: hidden;
 }
 }
-
 .card-accent {
 .card-accent {
   width: 8rpx;
   width: 8rpx;
   background-color: #FF8C42;
   background-color: #FF8C42;
   flex-shrink: 0;
   flex-shrink: 0;
 }
 }
-
 .card-body {
 .card-body {
   flex: 1;
   flex: 1;
   padding: 30rpx;
   padding: 30rpx;
 }
 }
-
 .card-title {
 .card-title {
   font-size: 30rpx;
   font-size: 30rpx;
   font-weight: 600;
   font-weight: 600;
@@ -161,30 +306,118 @@ export default {
   margin-bottom: 24rpx;
   margin-bottom: 24rpx;
   display: block;
   display: block;
 }
 }
-
 .info-list {
 .info-list {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 20rpx;
   gap: 20rpx;
 }
 }
-
 .info-item {
 .info-item {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 6rpx;
   gap: 6rpx;
 }
 }
-
 .info-age {
 .info-age {
   font-size: 26rpx;
   font-size: 26rpx;
   font-weight: 500;
   font-weight: 500;
   color: #FF8C42;
   color: #FF8C42;
   display: block;
   display: block;
 }
 }
-
 .info-desc {
 .info-desc {
   font-size: 24rpx;
   font-size: 24rpx;
   color: #666;
   color: #666;
   line-height: 1.6;
   line-height: 1.6;
   display: block;
   display: block;
 }
 }
-</style>
+.form-group {
+  margin-bottom: 24rpx;
+}
+.form-label {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.form-input {
+  border: 1rpx solid #ddd;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 26rpx;
+  background-color: #FAFAFA;
+  width: auto;
+}
+.picker-display {
+  border: 1rpx solid #ddd;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 26rpx;
+  background-color: #FAFAFA;
+  color: #333;
+}
+.slider-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.slider-min {
+  font-size: 22rpx;
+  color: #999;
+  flex-shrink: 0;
+}
+.slider-val {
+  font-size: 24rpx;
+  color: #FF8C42;
+  font-weight: 500;
+  width: 40rpx;
+  text-align: center;
+}
+.submit-btn {
+  background-color: #FF8C42;
+  color: #FFFFFF;
+  text-align: center;
+  padding: 22rpx 0;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+  margin-top: 10rpx;
+}
+.history-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.history-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.history-left {
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.history-type {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #333;
+}
+.history-duration {
+  font-size: 22rpx;
+  color: #999;
+}
+.history-right {
+  text-align: right;
+}
+.history-date {
+  font-size: 22rpx;
+  color: #999;
+}
+.empty-state {
+  text-align: center;
+  padding: 40rpx 0;
+  font-size: 26rpx;
+  color: #999;
+}
+</style>