فهرست منبع

feat(sleep-checkin): 睡眠时间改为顶部时间轴点选(每格30分钟)

- 头部时长图重构:24小时横向滚动时间轴,48个半小时间隔格,点击格子直接设置时间
- 入睡/醒来胶囊按钮切换当前编辑目标;选中入睡区间整体高亮,跨午夜自动分两段显示
- 点选入睡后自动切换到醒来模式;入睡了/醒了位置自动滚动到可视区中央
- 移除原「睡眠时间」选择卡片与原生时间选择器,时间选择统一在头部时间轴完成
E2E Test Bot 6 روز پیش
والد
کامیت
d6f5bb4279
1فایلهای تغییر یافته به همراه171 افزوده شده و 117 حذف شده
  1. 171 117
      cfc-frontend/pages/growth/sleep-checkin.vue

+ 171 - 117
cfc-frontend/pages/growth/sleep-checkin.vue

@@ -24,54 +24,32 @@
           <text class="hero-duration-label">本次睡眠时长</text>
         </view>
 
-        <view class="hero-track">
-          <view class="hero-track-bar">
-            <view class="hero-track-fill" :style="{ width: durationPercent + '%' }"></view>
-            <view class="hero-track-mark"></view>
-          </view>
-          <view class="hero-ends">
-            <view class="hero-end">
-              <text class="hero-end-icon">🌙</text>
-              <text class="hero-end-time">{{ form.sleepTime || '--:--' }}</text>
-              <text class="hero-end-label">入睡</text>
+        <view class="tl">
+          <view class="tl-pills">
+            <view class="tl-pill tl-pill-moon" :class="timeMode === 'sleep' ? 'tl-pill-on' : ''" @tap="setTimeMode('sleep')">
+              <text class="tl-pill-ic">🌙</text>
+              <text class="tl-pill-t">入睡</text>
+              <text class="tl-pill-time">{{ form.sleepTime || '--:--' }}</text>
             </view>
-            <view class="hero-end hero-end-right">
-              <text class="hero-end-icon">☀️</text>
-              <text class="hero-end-time">{{ form.wakeTime || '--:--' }}</text>
-              <text class="hero-end-label">醒来</text>
+            <view class="tl-pill tl-pill-sun" :class="timeMode === 'wake' ? 'tl-pill-on' : ''" @tap="setTimeMode('wake')">
+              <text class="tl-pill-ic">☀️</text>
+              <text class="tl-pill-t">醒来</text>
+              <text class="tl-pill-time">{{ form.wakeTime || '--:--' }}</text>
             </view>
           </view>
-        </view>
-
-        <view class="hero-tip">💡 建议青少年每天睡足 8-10 小时</view>
-      </view>
 
-      <!-- ===== 睡眠时间 ===== -->
-      <view class="card">
-        <view class="card-head">
-          <view class="card-head-icon"><text>🛏️</text></view>
-          <text class="card-title">睡眠时间</text>
-          <text class="card-hint">点击卡片选择</text>
-        </view>
-        <view class="card-body">
-          <view class="time-row">
-            <picker mode="time" :value="form.sleepTime" @change="onSleepTimeChange">
-              <view class="time-tile">
-                <text class="time-tile-icon">🌙</text>
-                <text class="time-tile-label">入睡时间</text>
-                <text class="time-tile-value">{{ form.sleepTime || '请选择' }}</text>
+          <scroll-view class="tl-scroll" scroll-x :scroll-left="tlScrollLeft" scroll-with-animation :show-scrollbar="false">
+            <view class="tl-bar">
+              <view class="tl-seg" v-for="seg in segments" :key="seg.k" :class="seg.cls" @tap="onTimelineTap(seg.k)">
+                <text class="tl-seg-time" v-if="seg.showLabel">{{ seg.label }}</text>
               </view>
-            </picker>
-            <view class="time-arrow"><text>→</text></view>
-            <picker mode="time" :value="form.wakeTime" @change="onWakeTimeChange">
-              <view class="time-tile time-tile-sun">
-                <text class="time-tile-icon">☀️</text>
-                <text class="time-tile-label">醒来时间</text>
-                <text class="time-tile-value">{{ form.wakeTime || '请选择' }}</text>
-              </view>
-            </picker>
-          </view>
+            </view>
+          </scroll-view>
+
+          <view class="tl-hint">点选「{{ timeMode === 'sleep' ? '入睡' : '醒来' }}」时间 · 每格 30 分钟,左右滑动找时间</view>
         </view>
+
+        <view class="hero-tip">💡 建议青少年每天睡足 8-10 小时</view>
       </view>
 
       <!-- ===== 睡眠质量(点选打分,替代滑动条) ===== -->
@@ -188,6 +166,11 @@ export default {
       memberId: null,
       taskId: null,
       submitting: false,
+      timeMode: 'sleep',
+      tlScrollLeft: 0,
+      tlTotalPx: 0,
+      tlAdvancePx: 0,
+      tlViewPx: 0,
       history: [],
       todayDate: '',
       todayWeek: '',
@@ -248,10 +231,25 @@ export default {
     durationText: function () {
       return this.durationFromMinutes(this.durationMinutes)
     },
-    // 相对 10 小时的睡眠进度百分比
-    durationPercent: function () {
-      if (!this.durationMinutes) return 0
-      return Math.min(100, Math.round(this.durationMinutes / 600 * 100))
+    // 24 小时 × 2 格/小时 = 48 个半小时间隔段(用于时间轴高亮与标签)
+    segments: function () {
+      var self = this
+      var s = this.slotOf(this.form.sleepTime)
+      var w = this.slotOf(this.form.wakeTime)
+      var arr = []
+      for (var i = 0; i < 48; i++) {
+        var on = false
+        if (s >= 0 && w >= 0 && s !== w) {
+          if (w > s) on = i >= s && i <= w
+          else on = i >= s || i <= w
+        }
+        var cls = on ? 'tl-seg tl-seg-on' : 'tl-seg'
+        if (i === s) cls += ' tl-seg-sleep'
+        if (i === w) cls += ' tl-seg-wake'
+        var showLabel = (i % 2 === 0) || i === s || i === w
+        arr.push({ k: i, cls: cls, showLabel: showLabel, label: self.timeFromSlot(i) })
+      }
+      return arr
     },
     qualityFace: function () {
       var m = this.qualityMap[this.form.qualityScore]
@@ -302,12 +300,60 @@ export default {
     }
     this.loadHistory()
   },
+  onReady: function () {
+    var self = this
+    // 量取时间轴实际像素宽度,用于把选中时间滚动到可视区中间
+    uni.createSelectorQuery().in(this).select('.tl-bar').boundingClientRect(function (rect) {
+      if (rect && rect.width) {
+        self.tlTotalPx = rect.width
+        self.tlAdvancePx = rect.width / 48
+        var winW = uni.getWindowInfo().windowWidth
+        self.tlViewPx = winW - 112 * (winW / 750)
+        self.scrollToSlot(self.slotOf(self.form.sleepTime))
+      }
+    }).exec()
+  },
   methods: {
-    onSleepTimeChange: function (e) {
-      this.form.sleepTime = e.detail.value
+    durationFromMinutes: function (mins) {
+      if (!mins || mins <= 0) return '--'
+      var h = Math.floor(mins / 60)
+      var m = mins % 60
+      if (h > 0) return h + ' 小时' + (m > 0 ? ' ' + m + ' 分' : '')
+      return m + ' 分钟'
+    },
+    // 时间轴:半小时间隔 (0=00:00 .. 47=23:30)
+    slotOf: function (t) {
+      if (!t) return -1
+      var p = String(t).split(':')
+      var h = parseInt(p[0]); var m = parseInt(p[1])
+      if (isNaN(h) || isNaN(m)) return -1
+      return Math.round((h * 60 + m) / 30) % 48
+    },
+    timeFromSlot: function (i) {
+      var h = Math.floor(i / 2)
+      var m = (i % 2) * 30
+      return ('0' + h).slice(-2) + ':' + ('0' + m).slice(-2)
+    },
+    setTimeMode: function (mode) {
+      this.timeMode = mode
+      this.scrollToSlot(this.slotOf(this.form[mode === 'sleep' ? 'sleepTime' : 'wakeTime']))
+    },
+    onTimelineTap: function (i) {
+      var self = this
+      var t = this.timeFromSlot(i)
+      if (self.timeMode === 'sleep') {
+        self.form.sleepTime = t
+        self.timeMode = 'wake'
+      } else {
+        self.form.wakeTime = t
+      }
+      self.scrollToSlot(i)
     },
-    onWakeTimeChange: function (e) {
-      this.form.wakeTime = e.detail.value
+    scrollToSlot: function (slot) {
+      if (slot < 0 || !this.tlAdvancePx) return
+      var target = slot * this.tlAdvancePx - this.tlViewPx / 2 + this.tlAdvancePx / 2
+      var max = Math.max(0, this.tlTotalPx - this.tlViewPx)
+      this.tlScrollLeft = Math.max(0, Math.min(target, max))
     },
     setQuality: function (score) {
       this.form.qualityScore = score
@@ -320,13 +366,6 @@ export default {
         this.selectedTags.push(tag)
       }
     },
-    durationFromMinutes: function (mins) {
-      if (!mins || mins <= 0) return '--'
-      var h = Math.floor(mins / 60)
-      var m = mins % 60
-      if (h > 0) return h + ' 小时' + (m > 0 ? ' ' + m + ' 分' : '')
-      return m + ' 分钟'
-    },
     loadHistory: function () {
       var self = this
       var token = uni.getStorageSync('token')
@@ -506,48 +545,89 @@ export default {
   color: rgba(255, 255, 255, 0.7);
   margin-top: 6rpx;
 }
-.hero-track {
+.tl {
   position: relative;
   z-index: 1;
   margin-top: 28rpx;
 }
-.hero-track-bar {
-  position: relative;
-  height: 12rpx;
-  border-radius: 999rpx;
-  background: rgba(255, 255, 255, 0.16);
-}
-.hero-track-fill {
-  height: 100%;
-  border-radius: 999rpx;
-  background: linear-gradient(90deg, #FFD166, #FF9F68);
-  transition: width 0.3s ease;
+.tl-pills {
+  display: flex;
+  gap: 16rpx;
 }
-.hero-track-mark {
-  position: absolute;
-  left: 80%;
-  top: -4rpx;
-  width: 4rpx;
-  height: 20rpx;
-  border-radius: 2rpx;
-  background: rgba(255, 255, 255, 0.85);
-}
-.hero-ends {
+.tl-pill {
+  flex: 1;
   display: flex;
-  justify-content: space-between;
-  align-items: flex-start;
-  margin-top: 16rpx;
+  align-items: center;
+  gap: 10rpx;
+  padding: 16rpx 20rpx;
+  border-radius: 20rpx;
+  background: rgba(255, 255, 255, 0.1);
+  border: 2rpx solid rgba(255, 255, 255, 0.14);
+  transition: transform 0.2s ease;
 }
-.hero-end {
+.tl-pill:active { transform: scale(0.97); }
+.tl-pill-on {
+  background: rgba(255, 255, 255, 0.22);
+  border-color: rgba(255, 209, 102, 0.75);
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.22);
+}
+.tl-pill-ic { font-size: 30rpx; line-height: 1; }
+.tl-pill-t { font-size: 24rpx; color: rgba(255, 255, 255, 0.72); }
+.tl-pill-time {
+  flex: 1;
+  text-align: right;
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #fff;
+}
+.tl-pill-on .tl-pill-time { color: #FFD166; }
+.tl-scroll {
+  margin-top: 20rpx;
+  width: 100%;
+  height: 92rpx;
+}
+.tl-bar {
   display: flex;
-  flex-direction: column;
-  align-items: flex-start;
   gap: 4rpx;
+  padding: 0 2rpx;
+  align-items: stretch;
+}
+.tl-seg {
+  width: 96rpx;
+  height: 76rpx;
+  flex-shrink: 0;
+  border-radius: 10rpx;
+  background: rgba(255, 255, 255, 0.13);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 0.15s ease;
+}
+.tl-seg:active { background: rgba(255, 255, 255, 0.4); }
+.tl-seg-time {
+  font-size: 18rpx;
+  color: rgba(255, 255, 255, 0.8);
+  line-height: 1;
+}
+.tl-seg-on { background: rgba(255, 209, 102, 0.55); }
+.tl-seg-on .tl-seg-time { color: rgba(255, 255, 255, 0.95); }
+.tl-seg-sleep {
+  background: linear-gradient(180deg, #FFD166, #FFB84D);
+  box-shadow: 0 0 12rpx rgba(255, 209, 102, 0.8);
+}
+.tl-seg-wake {
+  background: linear-gradient(180deg, #FF9F68, #FF7A45);
+  box-shadow: 0 0 12rpx rgba(255, 159, 104, 0.8);
+}
+.tl-seg-sleep .tl-seg-time, .tl-seg-wake .tl-seg-time {
+  color: #4A2A00;
+  font-weight: 700;
+}
+.tl-hint {
+  font-size: 20rpx;
+  color: rgba(255, 255, 255, 0.55);
+  margin-top: 14rpx;
 }
-.hero-end-right { align-items: flex-end; }
-.hero-end-icon { font-size: 32rpx; }
-.hero-end-time { font-size: 34rpx; font-weight: 700; color: #fff; }
-.hero-end-label { font-size: 20rpx; color: rgba(255, 255, 255, 0.6); }
 .hero-tip {
   position: relative;
   z-index: 1;
@@ -591,32 +671,6 @@ export default {
 .card-hint { font-size: 22rpx; color: #94A3B8; }
 .card-body { padding: 4rpx 28rpx 30rpx; }
 
-/* ===== 睡眠时间 ===== */
-.time-row { display: flex; align-items: stretch; gap: 16rpx; }
-.time-tile {
-  flex: 1;
-  background: linear-gradient(160deg, #F8FAFF, #F1F5FF);
-  border: 2rpx solid #E6EBF5;
-  border-radius: 24rpx;
-  padding: 24rpx 16rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 8rpx;
-  transition: all 0.2s ease;
-}
-.time-tile:active { transform: scale(0.97); border-color: #C7D2FE; }
-.time-tile-icon { font-size: 40rpx; line-height: 1; }
-.time-tile-label { font-size: 22rpx; color: #94A3B8; }
-.time-tile-value { font-size: 40rpx; font-weight: 700; color: #1E293B; }
-.time-arrow {
-  align-self: center;
-  font-size: 32rpx;
-  color: #94A3B8;
-  font-weight: 700;
-  flex-shrink: 0;
-}
-
 /* ===== 睡眠质量 ===== */
 .quality-head {
   display: flex;
@@ -671,7 +725,7 @@ export default {
   display: flex;
   align-items: center;
   justify-content: center;
-  transition: all 0.18s ease;
+  transition: transform 0.18s ease;
 }
 .q-chip text { font-size: 30rpx; font-weight: 600; color: #64748B; }
 .q-chip:active { transform: scale(0.94); }
@@ -702,7 +756,7 @@ export default {
   border-radius: 999rpx;
   background: #F8FAFC;
   border: 2rpx solid #EDF1F7;
-  transition: all 0.18s ease;
+  transition: transform 0.18s ease;
 }
 .s-tag text { font-size: 26rpx; color: #475569; }
 .s-tag:active { transform: scale(0.96); }