From d5f06dbd22de83d9ecbd5ad70bc37decda91bb4d Mon Sep 17 00:00:00 2001
From: zhangchen <1652267879@qq.com>
Date: 星期四, 24 七月 2025 23:15:08 +0800
Subject: [PATCH] ID1625-时间选择组件完成

---
 src/views/mobile/bedsideAuxiliaryScreen/components/TimePicker.vue |  285 +++++++++++++++++++++++++++++++++++++++-----------------
 1 files changed, 197 insertions(+), 88 deletions(-)

diff --git a/src/views/mobile/bedsideAuxiliaryScreen/components/TimePicker.vue b/src/views/mobile/bedsideAuxiliaryScreen/components/TimePicker.vue
index 74479f7..4a18241 100644
--- a/src/views/mobile/bedsideAuxiliaryScreen/components/TimePicker.vue
+++ b/src/views/mobile/bedsideAuxiliaryScreen/components/TimePicker.vue
@@ -1,119 +1,228 @@
 <template>
   <div class="time-picker">
     <div class="picker-column" ref="hourRef" @scroll="onScroll('hour')">
-      <div v-for="h in hours" :key="h" class="picker-item" :class="{ active: h === selectedHour }">{{ h.toString().padStart(2, '0') }}</div>
+      <div
+        v-for="(h, index) in loopHours"
+        :key="index"
+        class="picker-item hours"
+        :class="getClassByIndex(index, selectedIndexHour)"
+        :style="getStyleByIndex(index, selectedIndexHour)"
+      >
+        {{ h.toString().padStart(2, "0") }}
+      </div>
     </div>
     <span class="colon">:</span>
     <div class="picker-column" ref="minuteRef" @scroll="onScroll('minute')">
-      <div v-for="m in minutes" :key="m" class="picker-item" :class="{ active: m === selectedMinute }">{{ m.toString().padStart(2, '0') }}</div>
+      <div
+        v-for="(m, index) in loopMinutes"
+        :key="index"
+        class="picker-item minutes"
+        :class="getClassByIndex(index, selectedIndexMinute)"
+        :style="getStyleByIndex(index, selectedIndexMinute)"
+      >
+        {{ m.toString().padStart(2, "0") }}
+      </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, watch, onMounted, nextTick } from 'vue'
+import { ref, watch, onMounted, nextTick } from "vue";
 
-interface Props {
-  modelValue: string // 格式为 "HH:mm"
-}
-const props = defineProps<Props>()
-const emit = defineEmits(['update:modelValue'])
+const props = defineProps<{ modelValue: string }>();
+const emit = defineEmits(["update:modelValue"]);
 
-const selectedHour = ref(0)
-const selectedMinute = ref(0)
-const hourRef = ref<HTMLDivElement | null>(null)
-const minuteRef = ref<HTMLDivElement | null>(null)
+const hours = Array.from({ length: 24 }, (_, i) => i);
+const minutes = Array.from({ length: 60 }, (_, i) => i);
 
-const hours = Array.from({ length: 24 }, (_, i) => i)
-const minutes = Array.from({ length: 60 }, (_, i) => i)
-
-function scrollTo(refEl: HTMLDivElement | null, index: number) {
-  if (!refEl) return
-  refEl.scrollTo({ top: index * 40, behavior: 'smooth' })
+// 为循环滚动,前面后面都各补两个,要不要选不中最后一个
+function createLoopArray(arr: number[]) {
+  return [...arr.slice(-2), ...arr, ...arr.slice(0, 2)];
 }
 
-function updateModel() {
-  const value = `${selectedHour.value.toString().padStart(2, '0')}:${selectedMinute.value.toString().padStart(2, '0')}`
-  emit('update:modelValue', value)
-}
+const loopHours = createLoopArray(hours);
+const loopMinutes = createLoopArray(minutes);
 
-function onScroll(type: 'hour' | 'minute') {
-  const el = type === 'hour' ? hourRef.value : minuteRef.value
-  if (!el) return
+const hourRef = ref<HTMLElement | null>(null);
+const minuteRef = ref<HTMLElement | null>(null);
 
-  clearTimeout((el as any)._timer)
-  ;(el as any)._timer = setTimeout(() => {
-    const index = Math.round(el.scrollTop / 40)
-    if (type === 'hour') {
-      selectedHour.value = hours[index]
-      scrollTo(hourRef.value, index)
-    } else {
-      selectedMinute.value = minutes[index]
-      scrollTo(minuteRef.value, index)
-    }
-    updateModel()
-  }, 100)
-}
+const ITEM_REM = 0.4;
+const itemHeight = remToPx(ITEM_REM);
+
+const selectedIndexHour = ref(2);
+const selectedIndexMinute = ref(2);
+
+const selectedHour = ref(hours[0]);
+const selectedMinute = ref(minutes[0]);
+
+// 标记程序主动滚动,防止滚动事件死循环
+const isProgrammaticScroll = {
+  hour: false,
+  minute: false,
+};
+
+// 初始化滚动,选中传入时间
+onMounted(() => {
+  const [h, m] = props.modelValue.split(":").map(Number);
+  const hourIdx = hours.indexOf(h);
+  const minuteIdx = minutes.indexOf(m);
+
+  selectedIndexHour.value = hourIdx === -1 ? 2 : hourIdx + 2;
+  selectedIndexMinute.value = minuteIdx === -1 ? 2 : minuteIdx + 2;
+
+  selectedHour.value = h;
+  selectedMinute.value = m;
+
+  nextTick(() => {
+    scrollToSelected("hour", selectedIndexHour.value);
+    scrollToSelected("minute", selectedIndexMinute.value);
+  });
+});
 
 watch(
-  () => props.modelValue,
-  (newVal) => {
-    if (!newVal) return
-    const [h, m] = newVal.split(':').map(Number)
-    selectedHour.value = h
-    selectedMinute.value = m
-    nextTick(() => {
-      scrollTo(hourRef.value, h)
-      scrollTo(minuteRef.value, m)
-    })
-  },
-  { immediate: true }
-)
+  () =>
+    `${selectedHour.value.toString().padStart(2, "0")}:${selectedMinute.value
+      .toString()
+      .padStart(2, "0")}`,
+  (val) => {
+    emit("update:modelValue", val);
+  }
+);
+
+function onScroll(type: "hour" | "minute") {
+  if (isProgrammaticScroll[type]) {
+    // 程序滚动,忽略,避免死循环
+    isProgrammaticScroll[type] = false;
+    return;
+  }
+
+  const refEl = type === "hour" ? hourRef.value : minuteRef.value;
+  if (!refEl) return;
+
+  const scrollTop = refEl.scrollTop;
+  let index = Math.round(scrollTop / itemHeight + 2);
+
+  const arrLen = type === "hour" ? hours.length : minutes.length;
+
+  if (index < 2) {
+    isProgrammaticScroll[type] = true;
+    refEl.scrollTop = itemHeight * (arrLen + (index - 2));
+    index = arrLen + (index - 2);
+  } else if (index > arrLen + 1) {
+    isProgrammaticScroll[type] = true;
+    refEl.scrollTop = itemHeight * (index - arrLen - 2);
+    index = index - arrLen - 2;
+  }
+
+  if (type === "hour") {
+    selectedIndexHour.value = index;
+    selectedHour.value = loopHours[index];
+  } else {
+    selectedIndexMinute.value = index;
+    selectedMinute.value = loopMinutes[index];
+  }
+}
+
+function scrollToSelected(type: "hour" | "minute", index: number) {
+  const refEl = type === "hour" ? hourRef.value : minuteRef.value;
+  if (!refEl) return;
+
+  isProgrammaticScroll[type] = true;
+  refEl.scrollTo({
+    top: (index - 2) * itemHeight,
+    behavior: "auto",
+  });
+}
+
+function getClassByIndex(index: number, selectedIndex: number) {
+  const diff = Math.abs(index - selectedIndex);
+  return {
+    active: diff === 0,
+    medium: diff === 1,
+    small: diff === 2,
+  };
+}
+
+function getStyleByIndex(index: number, selectedIndex: number) {
+  const diff = Math.min(Math.abs(index - selectedIndex), 2);
+  return {
+    opacity: diff === 2 ? 0.4 : 1,
+    zIndex: 10 - diff,
+    transition: "opacity 0.3s ease",
+  };
+}
+
+function remToPx(rem: number) {
+  return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
+}
 </script>
 
-<style scoped>
+<style scoped lang="less">
 .time-picker {
   display: flex;
-  justify-content: center;
   align-items: center;
-  height: 200px;
-  background: #e6efff;
-  font-family: sans-serif;
-}
+  justify-content: center;
+  height: 2rem; // 5 * 0.4rem 每个item高度0.4rem
+  overflow: hidden;
 
-.picker-column {
-  width: 60px;
-  height: 200px;
-  overflow-y: scroll;
-  scroll-snap-type: y mandatory;
-  -webkit-overflow-scrolling: touch;
-  text-align: center;
-  position: relative;
-  padding-top: 80px;
-  padding-bottom: 80px;
-}
+  .picker-column {
+    height: 2rem;
+    width: 0.9rem;
+    overflow-y: scroll;
+    scroll-snap-type: y mandatory;
+    -webkit-overflow-scrolling: touch;
+    scrollbar-width: none; /* Firefox */
+    -ms-overflow-style: none; /* IE 10+ */
+    &::-webkit-scrollbar {
+      display: none; /* Chrome Safari */
+    }
 
-.picker-item {
-  height: 40px;
-  line-height: 40px;
-  font-size: 14px;
-  color: #666;
-  scroll-snap-align: center;
-  transition: all 0.2s;
-  transform: scale(0.8);
-  opacity: 0.5;
-}
+    .picker-item {
+      height: 0.4rem;
+      line-height: 0.4rem;
+      text-align: center;
+      font-size: 0.24rem;
+      scroll-snap-align: center;
+      user-select: none;
+      cursor: pointer;
+      transition: color 0.3s ease, font-size 0.3s ease;
 
-.picker-item.active {
-  font-size: 24px;
-  font-weight: bold;
-  color: #333;
-  transform: scale(1.2);
-  opacity: 1;
-}
 
-.colon {
-  font-size: 24px;
-  margin: 0 10px;
+      &.active {
+        font-size: 0.5rem;
+        font-weight: 700;
+        color: #111;
+        &.hours {
+          text-align: left;
+        }
+        &.minutes {
+          text-align: right;
+        }
+      }
+      &.medium {
+        font-size: 0.3rem;
+        color: #666;
+        &.hours {
+          text-align: left;
+          padding-left: 0.2rem;
+        }
+        &.minutes {
+          text-align: right;
+          padding-right: 0.2rem;
+        }
+      }
+      &.small {
+        font-size: 0.24rem;
+        color: #aaa;
+      }
+    }
+  }
+
+  .colon {
+    font-size: 0.5rem;
+    font-weight: 600;
+    color: #444;
+    user-select: none;
+  }
 }
 </style>

--
Gitblit v1.8.0