Parcourir la source

feat: 重新设计预约充电时间选择,支持智能推荐与24小时窗口

由左侧日期+5分钟粒度长列表改为:智能推荐快捷项(尽快/1小时后/2小时后/00:00低谷)+ 今天明天分组30分钟粒度chips网格(含各时段电价)+ 底部选中确认条;打开即智能默认选中最近可充时间;严格限定从此时起24小时内,超窗与过去时间不可选;弹层沿用滑入动效与按压反馈
skyline il y a 3 semaines
Parent
commit
9c904d5161
1 fichiers modifiés avec 304 ajouts et 171 suppressions
  1. 304 171
      charge-front/src/pages-charge/appointment/appointment.vue

+ 304 - 171
charge-front/src/pages-charge/appointment/appointment.vue

@@ -269,63 +269,63 @@
         '请选择' + (chargeTime.isPowerSaving ? '省电模式' : '') + '充电时间'
       "
     >
-      <view class="flex" style="border-top: 1rpx solid var(--divider)">
-        <view
-            class="flex-shrink"
-            style="height: 840rpx; width: 256rpx; background-color: var(--color-sec)"
-        >
-          <view
-              class="flex-align-center pl-32 height-90"
-              v-for="(day, index) in timesInfo.day"
-              :key="index"
-              :style="{
-              backgroundColor:
-                day.format === chargeTime.day ? '#fff' : 'transparent',
-            }"
-              @click="changeDay(index)"
-          >
-            <view
-                :class="[
-                'fs-32',
-                'fw-500',
-                day.format === chargeTime.day ? 'color-primary' : 'color-000',
-              ]"
-            >{{ day.format }}
-            </view
-            >
-            <view
-                class="fs-24 color-666 ml-12 br-8 width-60 height-40 flex-center"
-                style="background-color: rgba(28, 43, 41, 0.06)"
-                v-if="day.year && day.format !== chargeTime.day"
-            >{{ day.year }}年
-            </view
-            >
-          </view>
+      <view class="time-picker">
+        <!-- 智能推荐快捷项 -->
+        <view class="tp-quick">
+          <view class="fs-24 color-999 mb-16">智能推荐(24小时内)</view>
+          <scroll-view scroll-x class="tp-quick_scroll">
+            <view class="tp-quick_list">
+              <view
+                  v-for="(q, i) in quickOptions"
+                  :key="i"
+                  class="tp-chip pressable"
+                  hover-class="pressed"
+                  hover-stay-time="80"
+                  :class="{ 'tp-chip_active': selectedTs === q.ts }"
+                  @click="pickOption(q)"
+              >{{ q.label }}</view>
+            </view>
+          </scroll-view>
         </view>
-        <scroll-view
-            scroll-y
-            class="flex-grow"
-            style="height: 840rpx"
-            :scroll-into-view="chargeTime.scrollId"
-        >
-          <view
-              :id="`hour-${hour.format}`"
-              class="flex-align-center pl-40 height-90 fs-32"
-              v-for="(hour, index) in timesInfo.hour"
-              :key="index"
-              :style="{
-              color: hour.disabled
-                ? '#999'
-                : hour.format === chargeTime.hour
-                ? 'var(--color-primary)'
-                : '#000',
-            }"
-              @click="changeHour(index)"
-          >
-            {{ hour.format }}
-          </view>
-          <view class="hour-placeholder"></view>
+        <!-- 时段网格(30分钟粒度) -->
+        <scroll-view scroll-y class="tp-grid_scroll">
+          <block v-for="(group, gi) in timeGroups" :key="gi">
+            <view class="tp-group_title fs-26 color-999">{{ group.title }}</view>
+            <view class="tp-grid">
+              <view
+                  v-for="opt in group.options"
+                  :key="opt.ts"
+                  class="tp-cell pressable"
+                  hover-class="pressed"
+                  hover-stay-time="80"
+                  :class="{
+                  'tp-cell_active': selectedTs === opt.ts,
+                  'tp-cell_disabled': opt.disabled,
+                }"
+                  @click="pickOption(opt)"
+              >
+                <view class="fs-28">{{ opt.timeLabel }}</view>
+                <view class="fs-20 tp-cell_price" v-if="opt.price">{{ opt.price }}元/度</view>
+              </view>
+            </view>
+          </block>
+          <view style="height: 24rpx"></view>
         </scroll-view>
+        <!-- 底部确认 -->
+        <view class="tp-footer">
+          <view class="flex-column flex-grow" v-if="selected">
+            <view class="fs-30 fw-600 color-000">{{ selected.fullLabel }}</view>
+            <view class="fs-22 color-999 mt-4" v-if="selected.price"
+            >该时段电价 {{ selected.price }}元/度
+            </view>
+          </view>
+          <view class="fs-26 color-999 flex-grow" v-else>请选择充电开始时间</view>
+          <view
+              class="tp-confirm flex-center fs-28 fw-500"
+              :class="{ 'tp-confirm_disabled': !selected }"
+              @click="confirmTime"
+          >确定</view>
+        </view>
       </view>
     </style-dialog>
     <PriceDesc
@@ -369,10 +369,6 @@ const station = ref<any>();
 const stationId = ref<any>();
 const priceInfo = ref();
 const priceDescVisible = ref(false);
-const timesInfo = ref<any>({
-  day: [],
-  time: [],
-});
 
 
 const checkedUserRightsOrCoupon = ref<any>({
@@ -405,7 +401,6 @@ const chargeTime = ref({
   hour: "",
   format: "",
   formatPrice: "",
-  scrollId: "",
   isPowerSaving: false, // 省电模式
 });
 const appointmentData = ref();
@@ -446,12 +441,6 @@ const changeType = (index: number) => {
   chargeType.value = index;
 };
 
-const isPassTime = (hour: string, day: number) => {
-  const now = new Date();
-  const time = new Date(format("y/M/d", day) + " " + hour + ":00");
-  return now > time;
-};
-
 const getHourPrice = (hour: string) => {
   let price = "";
   priceInfo.value.policyInfos.forEach((item: any) => {
@@ -462,37 +451,114 @@ const getHourPrice = (hour: string) => {
   return price;
 };
 
-const rebuildHours = (day: Date) => {
-  timesInfo.value.hour = [];
-  const nowHour = format("h");
-  const isToday = format("d") === format("d", day.getTime());
-  // 当前时间的24小时内
-  let startHour = isToday ? Number(nowHour) : 0;
-  let endHour = isToday ? 24 : Number(nowHour) + 1;
-  if (chargeTime.value.isPowerSaving) {
-    startHour = 0;
-    endHour = 8;
-  }
-  for (let index = startHour; index < endHour; index++) {
-    timesInfo.value.hour = [...timesInfo.value.hour, ...buildHours(index, day)];
-  }
+/* ============ 时间选择(智能推荐 + 24小时内,30分钟粒度) ============ */
+const STEP = 30 * 60 * 1000; // 30分钟粒度
+const WINDOW = 24 * 60 * 60 * 1000; // 只能选择从此时开始24小时内
+const QUICK_OFFSETS = [30 * 60 * 1000, 60 * 60 * 1000, 2 * 60 * 60 * 1000]; // 尽快/1小时/2小时
+
+const timesInfo = ref<any>({
+  groups: [] as any[],
+  flat: {} as Record<number, any>,
+});
+const selectedTs = ref(0);
+
+const pad2 = (n: number) => (n < 10 ? `0${n}` : `${n}`);
+
+/** 把时间对齐到下一个5分钟(智能推荐用) */
+const ceil5 = (ts: number) => Math.ceil(ts / (5 * 60 * 1000)) * 5 * 60 * 1000;
+
+const buildOption = (ts: number, now: number): any => {
+  const d = new Date(ts);
+  const hourStr = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+  return {
+    ts,
+    timeLabel: hourStr,
+    price: getHourPrice(`${pad2(d.getHours())}`),
+    disabled: ts <= now,
+    get fullLabel() {
+      const dayDiff = Math.floor(
+          (new Date(ts).setHours(0, 0, 0, 0) - new Date(now).setHours(0, 0, 0, 0)) / DAY
+      );
+      const dayText = dayDiff <= 0 ? "今天" : dayDiff === 1 ? "明天" : "";
+      return `${dayText} ${format("M月d日", ts)} ${hourStr}`;
+    },
+  };
 };
 
-const buildHours = (hour: number, day: Date) => {
-  let res: any[] = [];
-  let hourTemp = hour >= 10 ? `${hour}` : `0${hour}`;
-  let priceTemp = getHourPrice(hourTemp);
-  for (let index = 0; index < 12; index++) {
-    res.push({
-      format: `${hourTemp}:${index * 5 >= 10 ? index * 5 : `0${index * 5}`}`,
-      formatPrice: priceTemp,
-      disabled: isPassTime(
-          `${hourTemp}:${index * 5 >= 10 ? index * 5 : `0${index * 5}`}`,
-          chargeTime.value.time ? chargeTime.value.time : day.getTime()
-      ),
+/** 生成24小时内的可选项:今天/明天两组,30分钟粒度 */
+const buildTimeOptions = () => {
+  const now = Date.now();
+  // 网格首个时间:10分钟后取整到下一个半点
+  let first = Math.ceil((now + 10 * 60 * 1000) / STEP) * STEP;
+  const last = now + WINDOW; // 窗口上限
+  const groups: any[] = [];
+  const flat: Record<number, any> = {};
+
+  // 按天分组
+  const firstDay = new Date(first).setHours(0, 0, 0, 0);
+  for (let dayStart = firstDay; dayStart <= last; dayStart += DAY) {
+    const dayEnd = dayStart + DAY;
+    const dayFirst = Math.max(first, dayStart);
+    const dayLast = Math.min(last, dayEnd - 1);
+    const options: any[] = [];
+    for (let ts = dayFirst; ts <= dayLast; ts += STEP) {
+      // 对齐到半点(dayFirst 来自 first 已对齐,跨天起点为 00:00 天然对齐)
+      const d = new Date(ts);
+      if (d.getMinutes() !== 0 && d.getMinutes() !== 30) {
+        continue;
+      }
+      const opt = buildOption(ts, now);
+      options.push(opt);
+      flat[opt.ts] = opt;
+    }
+    if (!options.length) {
+      continue;
+    }
+    const dayDiff = Math.round((dayStart - new Date(now).setHours(0, 0, 0, 0)) / DAY);
+    groups.push({
+      title: dayDiff <= 0 ? `今天 ${format("M月d日", dayStart)}` : `明天 ${format("M月d日", dayStart)}`,
+      options,
     });
   }
+  timesInfo.value = { groups, flat };
+};
+
+/** 智能推荐:尽快开始 / 1小时后 / 2小时后 / 00:00 低谷(5分钟粒度,独立于网格) */
+const quickOptions = computed(() => {
+  const res: any[] = [];
+  const now = Date.now();
+  const labels = ["尽快", "1小时后", "2小时后"];
+  QUICK_OFFSETS.forEach((offset, i) => {
+    const ts = ceil5(now + offset);
+    if (ts <= now + WINDOW) {
+      const opt = buildOption(ts, now);
+      res.push({ ...opt, label: `${labels[i]} ${opt.timeLabel}` });
+    }
+  });
+  // 下一低谷 00:00
+  const tomorrowZero = new Date().setHours(24, 0, 0, 0);
+  if (tomorrowZero <= now + WINDOW) {
+    const opt = buildOption(tomorrowZero, now);
+    res.push({ ...opt, label: "00:00 低谷充电" });
+  }
   return res;
+});
+
+const selected = computed(
+    () =>
+        timesInfo.value.flat[selectedTs.value] ||
+        quickOptions.value.find((q: any) => q.ts === selectedTs.value)
+);
+
+const pickOption = (opt: any) => {
+  if (opt.disabled) {
+    uni.showToast({
+      title: "无法选择过去的时间",
+      icon: "none",
+    });
+    return;
+  }
+  selectedTs.value = opt.ts;
 };
 
 const selectTime = () => {
@@ -510,84 +576,36 @@ const selectTime = () => {
     });
     return;
   }
-  const now = appointmentData.value
-      ? new Date(appointmentData.value.startTime.replace(/-/g, "/"))
-      : new Date();
-  const year = format("y", now.getTime());
-  const nextyear = format("y", now.getTime() + DAY);
-
-  // 天
-  timesInfo.value.day = [
-    {
-      time: now.getTime(),
-      format: format("M月d日", now.getTime()),
-      year: "",
-    },
-    {
-      time: now.getTime() + DAY,
-      format: format("M月d日", now.getTime() + DAY),
-      year: year === nextyear ? "" : nextyear.slice(2),
-    },
-  ];
-  rebuildHours(now);
-
-  // 预约时间
-  if (!appointmentData.value) {
-    if (!chargeTime.value.day) {
-      chargeTime.value.time = timesInfo.value.day[0].time;
-      chargeTime.value.day = timesInfo.value.day[0].format;
-    }
-    // 滚动到特定位置
-    if (chargeTime.value.hour) {
-      chargeTime.value.scrollId = `hour-${chargeTime.value.hour}`;
-    } else {
-      let hourNow = now.getHours();
-      chargeTime.value.scrollId = `hour-${hourNow}:00`;
-    }
-  }
-  // 修改时间
+  buildTimeOptions();
+  // 智能默认:已预约则定位当前预约时间,否则默认选中"尽快开始"
   if (appointmentData.value) {
-    chargeTime.value.time = timesInfo.value.day[0].time;
-    chargeTime.value.day = timesInfo.value.day[0].format;
-    const findIndex = timesInfo.value.hour.findIndex(
-        (item: any) => item.format === format("h:m:s", now.getTime())
-    );
-    if (findIndex >= 0) {
-      chargeTime.value.hour = timesInfo.value.hour[findIndex].format;
-      chargeTime.value.formatPrice =
-          timesInfo.value.hour[findIndex].formatPrice;
-    }
+    const start = new Date(
+        appointmentData.value.startTime.replace(/-/g, "/")
+    ).getTime();
+    const aligned = Math.ceil(start / STEP) * STEP;
+    selectedTs.value = timesInfo.value.flat[aligned]
+        ? aligned
+        : quickOptions.value[0]?.ts || 0;
+  } else {
+    selectedTs.value = quickOptions.value[0]?.ts || 0;
   }
   chargeTimeDialog.value = true;
 };
 
-const changeDay = (index: number) => {
-  chargeTime.value.time = timesInfo.value.day[index].time;
-  chargeTime.value.day = timesInfo.value.day[index].format;
-  if (chargeTime.value.hour) {
-    chargeTime.value.hour = "";
-    chargeTime.value.formatPrice = "";
-  }
-  const now = new Date(chargeTime.value.time);
-  rebuildHours(now);
-  setTimeout(() => {
-    chargeTime.value.scrollId = `hour-${timesInfo.value.hour[0].format}`;
-  }, 0);
-};
-
-const changeHour = (index: number) => {
-  if (timesInfo.value.hour[index].disabled) {
+const confirmTime = () => {
+  const opt = selected.value;
+  if (!opt) {
     uni.showToast({
-      title: "无法选择过去的时间",
+      title: "请选择充电时间",
       icon: "none",
     });
     return;
   }
   if (appointmentData.value) {
-    // 修改时间
+    // 修改已预约时间
     uni.showModal({
       title: "温馨提示",
-      content: `确认修改为${timesInfo.value.hour[index].format}吗?`,
+      content: `确认修改为${opt.fullLabel}吗?`,
       confirmText: "确定",
       confirmColor: "#2d9e95",
       success(modal) {
@@ -598,9 +616,7 @@ const changeHour = (index: number) => {
           });
           changeAppointmentTime(
               appointmentData.value.startChargeSeq,
-              `${format("y-M-d", chargeTime.value.time)} ${
-                  timesInfo.value.hour[index].format
-              }:00`
+              `${format("y-M-d", opt.ts)} ${opt.timeLabel}:00`
           )
               .then(() => {
                 return fetchChargeStatus();
@@ -622,11 +638,11 @@ const changeHour = (index: number) => {
     });
     return;
   }
-  chargeTime.value.hour = timesInfo.value.hour[index].format;
-  chargeTime.value.format = `${format("y年M月d日", chargeTime.value.time)} ${
-      chargeTime.value.hour
-  }`;
-  chargeTime.value.formatPrice = timesInfo.value.hour[index].formatPrice;
+  chargeTime.value.time = opt.ts;
+  chargeTime.value.day = format("M月d日", opt.ts);
+  chargeTime.value.hour = opt.timeLabel;
+  chargeTime.value.format = `${format("y年M月d日", opt.ts)} ${opt.timeLabel}`;
+  chargeTime.value.formatPrice = opt.price;
   closeTime();
 };
 
@@ -638,7 +654,6 @@ const changePowerSaving = (e: any) => {
     isPowerSaving: e.detail.value,
     format: "",
     formatPrice: "",
-    scrollId: "",
   };
 };
 
@@ -655,7 +670,6 @@ const closeTime = () => {
       isPowerSaving: chargeTime.value.isPowerSaving, // 省电模式
       format: "",
       formatPrice: "",
-      scrollId: "",
     };
   }
 };
@@ -1018,9 +1032,128 @@ onShow(() => {
   border-bottom: 1rpx solid var(--divider);
 }
 
-.hour-placeholder {
-  box-sizing: content-box;
-  padding-bottom: constant(safe-area-inset-bottom);
-  padding-bottom: env(safe-area-inset-bottom);
+/* ============ 时间选择器 ============ */
+.time-picker {
+  background-color: #fff;
+}
+
+.tp-quick {
+  padding: 24rpx 30rpx 8rpx;
+  border-bottom: 1rpx solid var(--divider);
+}
+
+.tp-quick_scroll {
+  white-space: nowrap;
+  width: 100%;
+}
+
+.tp-quick_list {
+  display: inline-flex;
+  padding-bottom: 16rpx;
+}
+
+.tp-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 64rpx;
+  padding: 0 28rpx;
+  margin-right: 16rpx;
+  background: var(--color-sec);
+  border-radius: 32rpx;
+  font-size: 26rpx;
+  color: #5c6b69;
+  transition: background-color 0.15s cubic-bezier(0.22, 1, 0.36, 1),
+    color 0.15s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.tp-chip_active {
+  background: var(--color-primary);
+  color: #fff;
+  font-weight: 500;
+}
+
+.tp-grid_scroll {
+  height: 560rpx;
+  padding: 8rpx 30rpx 0;
+  box-sizing: border-box;
+}
+
+.tp-group_title {
+  padding: 24rpx 0 16rpx;
+  font-weight: 500;
+}
+
+.tp-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 12rpx;
+}
+
+.tp-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 96rpx;
+  background: var(--color-sec);
+  border-radius: 16rpx;
+  color: #1c2b29;
+  transition: background-color 0.15s cubic-bezier(0.22, 1, 0.36, 1),
+    color 0.15s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.tp-cell_price {
+  margin-top: 2rpx;
+  color: #8c9997;
+}
+
+.tp-cell_active {
+  background: var(--color-primary-tint-strong);
+  color: var(--color-primary);
+  font-weight: 600;
+
+  .tp-cell_price {
+    color: var(--color-primary);
+  }
+}
+
+.tp-cell_disabled {
+  color: #c4cfcd;
+  background: rgba(28, 43, 41, 0.03);
+
+  .tp-cell_price {
+    color: #d5dedc;
+  }
+}
+
+.tp-footer {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  border-top: 1rpx solid var(--divider);
+  background: #fff;
+}
+
+.tp-confirm {
+  flex-shrink: 0;
+  width: 200rpx;
+  height: 80rpx;
+  margin-left: 24rpx;
+  background: var(--color-primary);
+  color: #fff;
+  border-radius: 40rpx;
+  transition: background-color 0.15s cubic-bezier(0.22, 1, 0.36, 1),
+    opacity 0.15s cubic-bezier(0.22, 1, 0.36, 1);
+
+  &:active {
+    background: var(--color-primary-deep);
+  }
+}
+
+.tp-confirm_disabled {
+  opacity: 0.45;
 }
 </style>