TimePopup.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
  1. <template>
  2. <!-- 使用 u-popup 作为弹窗容器 -->
  3. <u-popup
  4. :show="visible"
  5. mode="bottom"
  6. round="20"
  7. :closeable="false"
  8. @close="close"
  9. :safeAreaInsetBottom="true"
  10. customStyle="height: 80vh;"
  11. >
  12. <view class="time-picker-container">
  13. <!-- 标题栏 -->
  14. <view class="picker-header">
  15. <text class="picker-title">期望上门时间</text>
  16. <text class="close-btn" @tap="close">×</text>
  17. </view>
  18. <!-- 主要内容区域:左侧日期 + 右侧时间 -->
  19. <view class="main-content">
  20. <!-- 左侧日期选择 -->
  21. <scroll-view class="date-sidebar" scroll-y>
  22. <view
  23. v-for="date in dateList"
  24. :key="date.value"
  25. class="date-item"
  26. :class="{
  27. active: selectedDate === date.value,
  28. today: date.isToday
  29. }"
  30. @tap="selectDate(date.value)"
  31. >
  32. <view class="date-content">
  33. <text class="date-name">{{ date.label }}</text>
  34. <text class="date-info">{{ date.month }}月{{ date.day }}日</text>
  35. <text class="date-week">{{ date.week }}</text>
  36. </view>
  37. </view>
  38. </scroll-view>
  39. <!-- 右侧时间选择 -->
  40. <scroll-view class="time-content" scroll-y>
  41. <!-- 推荐时间段(仅今天显示) -->
  42. <view v-if="selectedDate === todayDate && withinOneHourSlot" class="recommend-section">
  43. <view class="recommend-header">
  44. <text class="recommend-title">一小时内</text>
  45. <!-- <text class="recommend-desc">推荐时间:{{ withinOneHourSlot.label }}</text> -->
  46. </view>
  47. <view class="recommend-time"
  48. @tap="selectTime(withinOneHourSlot.value, true)">
  49. <text class="time-value">{{ withinOneHourSlot.label }}</text>
  50. <!-- <text class="time-tag">推荐</text> -->
  51. </view>
  52. </view>
  53. <!-- 时间段列表 -->
  54. <view class="time-section">
  55. <view class="section-title">可选时间段</view>
  56. <view v-if="loading" class="loading">加载中...</view>
  57. <view v-else class="time-list">
  58. <view
  59. v-for="timeSlot in timeSlots"
  60. :key="timeSlot.value"
  61. class="time-item"
  62. :class="{
  63. active: selectedTime === timeSlot.value,
  64. disabled: timeSlot.disabled,
  65. night: timeSlot.night
  66. }"
  67. @tap="!timeSlot.disabled && selectTime(timeSlot.value, false)"
  68. >
  69. <text class="time-text">{{ timeSlot.label }}</text>
  70. <text v-if="timeSlot.tag" class="time-tag">{{ timeSlot.tag }}</text>
  71. </view>
  72. </view>
  73. </view>
  74. <!-- 提示信息 -->
  75. <view class="notice-section">
  76. <text class="notice-text">
  77. 部分区域支持夜间上门,具体以快递员联系为准
  78. </text>
  79. </view>
  80. </scroll-view>
  81. </view>
  82. <!-- 操作按钮 -->
  83. <view class="action-bar">
  84. <button
  85. class="confirm-btn"
  86. :class="{ active: selectedTime }"
  87. @tap="confirm"
  88. >
  89. {{ buttonText }}
  90. </button>
  91. </view>
  92. </view>
  93. </u-popup>
  94. </template>
  95. <script setup>
  96. import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
  97. const props = defineProps({
  98. visible: {
  99. type: Boolean,
  100. default: false
  101. },
  102. // 显示天数
  103. days: {
  104. type: Number,
  105. default: 3
  106. },
  107. // 开始时间
  108. startTime: {
  109. type: Number,
  110. default: 9
  111. },
  112. // 结束时间
  113. endTime: {
  114. type: Number,
  115. default: 21
  116. },
  117. // 是否自动选择推荐时间
  118. autoSelect: {
  119. type: Boolean,
  120. default: true
  121. }
  122. })
  123. const emit = defineEmits(['update:visible', 'close', 'confirm'])
  124. // 当前时间
  125. const currentTime = ref(new Date())
  126. const selectedDate = ref('')
  127. const selectedTime = ref('')
  128. const loading = ref(false)
  129. const isWithinOneHour = ref(false)
  130. // 计算当前时间20分钟后的时间
  131. const twentyMinutesLater = computed(() => {
  132. const now = new Date()
  133. now.setMinutes(now.getMinutes() + 10)
  134. return now
  135. })
  136. // 计算一小时后的小时(向上取整)
  137. const oneHourLaterHour = computed(() => {
  138. const now = new Date()
  139. now.setHours(now.getHours() + 1)
  140. return Math.ceil(now.getHours())
  141. })
  142. // 获取今天日期字符串
  143. const todayDate = computed(() => {
  144. const today = new Date()
  145. return formatDate(today)
  146. })
  147. // 生成日期列表
  148. const dateList = computed(() => {
  149. const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
  150. const list = []
  151. const today = new Date()
  152. for (let i = 0; i < props.days; i++) {
  153. const date = new Date(today)
  154. date.setDate(date.getDate() + i)
  155. const month = date.getMonth() + 1
  156. const day = date.getDate()
  157. const week = days[date.getDay()]
  158. const dateStr = formatDate(date)
  159. let label = ''
  160. if (i === 0) {
  161. label = '今天'
  162. } else if (i === 1) {
  163. label = '明天'
  164. } else {
  165. label = '后天'
  166. }
  167. list.push({
  168. value: dateStr,
  169. label: label,
  170. week: week,
  171. month: month,
  172. day: day,
  173. fullLabel: `${month}月${day}日 ${week}`,
  174. isToday: i === 0,
  175. isTomorrow: i === 1,
  176. isDayAfterTomorrow: i === 2,
  177. displayDate: `${date.getFullYear()}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
  178. })
  179. }
  180. return list
  181. })
  182. // 基础时间段配置
  183. const baseTimeSlots = computed(() => {
  184. const slots = []
  185. const startHour = props.startTime
  186. const endHour = props.endTime
  187. const interval = 1 // 小时间隔
  188. for (let hour = startHour; hour < endHour; hour += interval) {
  189. const start = hour
  190. const end = hour + interval
  191. const startStr = start.toString().padStart(2, '0')
  192. const endStr = end.toString().padStart(2, '0')
  193. slots.push({
  194. start: start,
  195. end: end,
  196. label: `${startStr}:00-${endStr}:00`,
  197. value: `${startStr}:00-${endStr}:00`,
  198. startHour: start,
  199. endHour: end
  200. })
  201. }
  202. return slots
  203. })
  204. // 计算一小时内时间段
  205. const withinOneHourSlot = computed(() => {
  206. if (!dateList.value.length || !dateList.value[0].isToday) return null
  207. const now = currentTime.value
  208. const later20min = twentyMinutesLater.value
  209. // 计算开始时间:当前时间 + 20分钟,分钟向上取整到10的倍数
  210. const startMinutes = later20min.getMinutes()
  211. const roundedMinutes = Math.ceil(startMinutes / 10) * 10
  212. let startHour = later20min.getHours()
  213. let startMinute = roundedMinutes
  214. // 如果分钟超过60,小时加1,分钟归零
  215. if (startMinute >= 60) {
  216. startHour += 1
  217. startMinute = 0
  218. }
  219. // 计算结束时间:开始时间 + 1小时
  220. let endHour = startHour + 1
  221. let endMinute = startMinute
  222. // 如果开始时间已经超过结束时间限制,则不显示
  223. if (startHour >= props.endTime) return null
  224. // 如果结束时间超过结束时间限制,则调整
  225. if (endHour > props.endTime) {
  226. endHour = props.endTime
  227. endMinute = 0
  228. }
  229. // 如果开始时间小于开始时间限制,则调整
  230. if (startHour < props.startTime) {
  231. startHour = props.startTime
  232. startMinute = 0
  233. }
  234. // 检查时间段是否有效(至少30分钟)
  235. const slotDuration = (endHour - startHour) * 60 + (endMinute - startMinute)
  236. if (slotDuration < 30) return null
  237. const startStr = formatTime(startHour, startMinute)
  238. const endStr = formatTime(endHour, endMinute)
  239. return {
  240. start: startHour + startMinute / 60,
  241. end: endHour + endMinute / 60,
  242. label: `(${startStr}-${endStr})`,
  243. value: `${startStr}-${endStr}`,
  244. isWithinOneHour: true,
  245. startHour: startHour,
  246. startMinute: startMinute,
  247. endHour: endHour,
  248. endMinute: endMinute
  249. }
  250. })
  251. // 是否是夜间时间段
  252. const isNightTime = (hour) => {
  253. return hour >= 18
  254. }
  255. // 根据选择的日期获取时间段
  256. const timeSlots = computed(() => {
  257. const slots = []
  258. const isToday = selectedDate.value === todayDate.value
  259. if (isToday) {
  260. const oneHourLater = oneHourLaterHour.value
  261. // 添加普通时间段(开始时间从一小时后开始)
  262. baseTimeSlots.value.forEach(slot => {
  263. // 时间段开始时间必须在当前时间+1小时之后
  264. const isDisabled = slot.start < oneHourLater
  265. if (!isDisabled) {
  266. slots.push({
  267. ...slot,
  268. disabled: false,
  269. night: false,
  270. tag: ''
  271. })
  272. // night: isNightTime(slot.start),
  273. }
  274. })
  275. } else {
  276. // 明天及以后显示所有时间段
  277. baseTimeSlots.value.forEach(slot => {
  278. slots.push({
  279. ...slot,
  280. disabled: false,
  281. night: isNightTime(slot.start),
  282. tag: ''
  283. })
  284. })
  285. }
  286. return slots
  287. })
  288. // 按钮文本
  289. const buttonText = computed(() => {
  290. if (!selectedTime.value) return '请选择上门时间'
  291. return '确定'
  292. })
  293. // 初始化选择
  294. const initSelection = () => {
  295. if (dateList.value.length > 0) {
  296. selectedDate.value = dateList.value[0].value
  297. // 自动选择推荐时间或第一个可用时间
  298. if (withinOneHourSlot.value && props.autoSelect) {
  299. selectedTime.value = withinOneHourSlot.value.value
  300. isWithinOneHour.value = true
  301. } else {
  302. const firstSlot = timeSlots.value[0]
  303. if (firstSlot) {
  304. selectedTime.value = firstSlot.value
  305. isWithinOneHour.value = false
  306. }
  307. }
  308. }
  309. }
  310. // 选择日期
  311. const selectDate = (date) => {
  312. selectedDate.value = date
  313. selectedTime.value = '' // 重置时间选择
  314. isWithinOneHour.value = false
  315. }
  316. // 选择时间
  317. const selectTime = (time, isWithinOneHourFlag = false) => {
  318. selectedTime.value = time
  319. isWithinOneHour.value = isWithinOneHourFlag
  320. }
  321. // 格式化日期时间
  322. const formatDateTime = (dateStr, hour) => {
  323. const date = new Date(dateStr)
  324. const wholeHour = Math.floor(hour)
  325. const minutes = Math.round((hour - wholeHour) * 60)
  326. date.setHours(wholeHour, minutes, 0, 0)
  327. const year = date.getFullYear()
  328. const month = String(date.getMonth() + 1).padStart(2, '0')
  329. const day = String(date.getDate()).padStart(2, '0')
  330. const hours = String(date.getHours()).padStart(2, '0')
  331. const mins = String(date.getMinutes()).padStart(2, '0')
  332. const seconds = String(date.getSeconds()).padStart(2, '0')
  333. return `${year}-${month}-${day} ${hours}:${mins}:${seconds}`
  334. }
  335. // 获取开始小时
  336. const getStartHour = (timeLabel) => {
  337. if (isWithinOneHour.value) {
  338. return withinOneHourSlot.value.startHour + withinOneHourSlot.value.startMinute / 60
  339. }
  340. const match = timeLabel.match(/(\d{2}):/)
  341. return match ? parseInt(match[1]) : 0
  342. }
  343. // 获取结束小时
  344. const getEndHour = (timeLabel) => {
  345. if (isWithinOneHour.value) {
  346. return withinOneHourSlot.value.endHour + withinOneHourSlot.value.endMinute / 60
  347. }
  348. const match = timeLabel.match(/-(\d{2}):/)
  349. return match ? parseInt(match[1]) : 0
  350. }
  351. // 确认选择
  352. const confirm = () => {
  353. if (!selectedTime.value) return
  354. const selectedDateObj = dateList.value.find(d => d.value === selectedDate.value)
  355. const selectedTimeSlot = isWithinOneHour.value ? withinOneHourSlot.value :
  356. baseTimeSlots.value.find(s => s.value === selectedTime.value)
  357. if (!selectedDateObj || !selectedTimeSlot) return
  358. // 计算开始和结束时间
  359. const startHour = getStartHour(selectedTime.value)
  360. const endHour = getEndHour(selectedTime.value)
  361. // 计算具体的开始和结束时间字符串
  362. const startTimeStr = formatDateTime(selectedDate.value, startHour)
  363. const endTimeStr = formatDateTime(selectedDate.value, endHour)
  364. // 生成显示文本
  365. let displayText = ''
  366. if (isWithinOneHour.value) {
  367. displayText = `${selectedDateObj.label} 一小时内`
  368. } else {
  369. displayText = `${selectedDateObj.label} ${selectedTime.value}`
  370. }
  371. const timeData = {
  372. date: selectedDate.value,
  373. time: selectedTime.value,
  374. dateLabel: selectedDateObj.label,
  375. timeLabel: selectedTime.value,
  376. fullLabel: displayText,
  377. displayText: displayText,
  378. startTime: startTimeStr,
  379. endTime: endTimeStr,
  380. isToday: selectedDateObj.isToday,
  381. isImmediate: isWithinOneHour.value,
  382. isNight: isNightTime(selectedTimeSlot.startHour),
  383. startHour: startHour,
  384. endHour: endHour
  385. }
  386. emit('confirm', timeData)
  387. close()
  388. }
  389. // 关闭弹框
  390. const close = () => {
  391. emit('close')
  392. emit('update:visible', false)
  393. }
  394. // 工具函数
  395. const formatDate = (date) => {
  396. const year = date.getFullYear()
  397. const month = String(date.getMonth() + 1).padStart(2, '0')
  398. const day = String(date.getDate()).padStart(2, '0')
  399. return `${year}-${month}-${day}`
  400. }
  401. const formatTime = (hour, minute) => {
  402. return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
  403. }
  404. // 监听可见性变化
  405. watch(() => props.visible, (newVal) => {
  406. if (newVal) {
  407. currentTime.value = new Date()
  408. initSelection()
  409. }
  410. })
  411. // 定时更新当前时间
  412. let timer = null
  413. onMounted(() => {
  414. timer = setInterval(() => {
  415. if (props.visible) {
  416. currentTime.value = new Date()
  417. }
  418. }, 60000) // 每分钟更新一次
  419. })
  420. // 组件销毁时清除定时器
  421. onUnmounted(() => {
  422. if (timer) {
  423. clearInterval(timer)
  424. }
  425. })
  426. </script>
  427. <style scoped lang="less">
  428. /* 弹窗内部容器 */
  429. .time-picker-container {
  430. height: 80vh;
  431. display: flex;
  432. flex-direction: column;
  433. }
  434. .picker-header {
  435. display: flex;
  436. justify-content: space-between;
  437. align-items: center;
  438. padding: 32rpx 32rpx 24rpx;
  439. border-bottom: 1rpx solid #f0f0f0;
  440. flex-shrink: 0;
  441. }
  442. .picker-title {
  443. font-size: 36rpx;
  444. font-weight: 600;
  445. color: #333;
  446. }
  447. .close-btn {
  448. font-size: 50rpx;
  449. color: #999;
  450. line-height: 40rpx;
  451. padding: 10rpx;
  452. }
  453. /* 主要内容区域 */
  454. .main-content {
  455. flex: 1;
  456. display: flex;
  457. overflow: hidden;
  458. }
  459. /* 左侧日期选择 */
  460. .date-sidebar {
  461. width: 220rpx;
  462. background-color: #f8f8f8;
  463. border-right: 1rpx solid #f0f0f0;
  464. }
  465. .date-item {
  466. padding: 32rpx 24rpx;
  467. border-bottom: 1rpx solid #f0f0f0;
  468. }
  469. .date-item.active {
  470. background-color: #fff;
  471. position: relative;
  472. }
  473. .date-item.active::before {
  474. content: '';
  475. position: absolute;
  476. left: 0;
  477. top: 50%;
  478. transform: translateY(-50%);
  479. width: 6rpx;
  480. height: 40rpx;
  481. background-color: #1B64F0;
  482. border-radius: 0 4rpx 4rpx 0;
  483. }
  484. .date-item.today .date-name {
  485. color: #1B64F0;
  486. }
  487. .date-item.active .date-content {
  488. color: #1B64F0;
  489. }
  490. .date-content {
  491. display: flex;
  492. flex-direction: column;
  493. align-items: center;
  494. text-align: center;
  495. }
  496. .date-name {
  497. font-size: 32rpx;
  498. font-weight: 600;
  499. color: #333;
  500. margin-bottom: 8rpx;
  501. }
  502. .date-info {
  503. font-size: 24rpx;
  504. color: #666;
  505. margin-bottom: 4rpx;
  506. }
  507. .date-week {
  508. font-size: 22rpx;
  509. color: #999;
  510. }
  511. /* 右侧时间选择 */
  512. .time-content {
  513. flex: 1;
  514. padding: 0 32rpx;
  515. overflow-y: auto;
  516. }
  517. .recommend-section {
  518. margin-top: 32rpx;
  519. padding-bottom: 24rpx;
  520. border-bottom: 1rpx solid #f0f0f0;
  521. }
  522. .recommend-header {
  523. display: flex;
  524. justify-content: space-between;
  525. align-items: center;
  526. margin-bottom: 20rpx;
  527. }
  528. .recommend-title {
  529. font-size: 28rpx;
  530. font-weight: 500;
  531. color: #333;
  532. }
  533. .recommend-desc {
  534. font-size: 24rpx;
  535. color: #1B64F0;
  536. }
  537. .recommend-time {
  538. display: flex;
  539. align-items: center;
  540. justify-content: space-between;
  541. padding: 28rpx 24rpx;
  542. // background: linear-gradient(135deg, #fff2f0 0%, #ffe6e6 100%);
  543. border-radius: 16rpx;
  544. border: 2rpx solid #1B64F0;
  545. }
  546. .time-value {
  547. font-size: 32rpx;
  548. font-weight: 500;
  549. color: #1B64F0;
  550. }
  551. .recommend-time .time-tag {
  552. font-size: 24rpx;
  553. padding: 8rpx 16rpx;
  554. background: #1B64F0;
  555. color: white;
  556. border-radius: 20rpx;
  557. }
  558. /* 时间段列表 */
  559. .time-section {
  560. margin-top: 32rpx;
  561. margin-bottom: 32rpx;
  562. }
  563. .section-title {
  564. font-size: 28rpx;
  565. font-weight: 500;
  566. color: #333;
  567. margin-bottom: 24rpx;
  568. }
  569. .time-list {
  570. display: flex;
  571. flex-direction: column;
  572. gap: 20rpx;
  573. }
  574. .time-item {
  575. padding: 28rpx 24rpx;
  576. border: 2rpx solid #e8e8e8;
  577. border-radius: 12rpx;
  578. display: flex;
  579. align-items: center;
  580. justify-content: space-between;
  581. position: relative;
  582. transition: all 0.2s ease;
  583. }
  584. .time-item.active {
  585. border-color: #1B64F0;
  586. // background: #fff2f0;
  587. }
  588. .time-item.night {
  589. border-color: #4a5cff;
  590. }
  591. .time-item:not(.disabled):active {
  592. transform: scale(0.98);
  593. opacity: 0.8;
  594. }
  595. .time-item.disabled {
  596. opacity: 0.5;
  597. }
  598. .time-text {
  599. font-size: 28rpx;
  600. color: #333;
  601. }
  602. .time-item.active .time-text {
  603. color: #1B64F0;
  604. font-weight: 500;
  605. }
  606. .time-item.night .time-text {
  607. color: #4a5cff;
  608. }
  609. .time-item .time-tag {
  610. font-size: 20rpx;
  611. padding: 4rpx 12rpx;
  612. border-radius: 12rpx;
  613. background: #f5f5f5;
  614. color: #666;
  615. }
  616. .time-item.active .time-tag {
  617. background: #1B64F0;
  618. color: white;
  619. }
  620. .time-item.night .time-tag {
  621. background: #4a5cff;
  622. color: white;
  623. }
  624. /* 提示信息 */
  625. .notice-section {
  626. padding: 24rpx;
  627. margin-bottom: 32rpx;
  628. background: #f8f8f8;
  629. border-radius: 12rpx;
  630. }
  631. .notice-text {
  632. font-size: 24rpx;
  633. color: #666;
  634. line-height: 1.4;
  635. }
  636. /* 操作按钮 */
  637. .action-bar {
  638. padding: 24rpx 32rpx;
  639. padding-top: 0;
  640. background: #fff;
  641. border-top: 1rpx solid #f0f0f0;
  642. flex-shrink: 0;
  643. }
  644. .confirm-btn {
  645. width: 100%;
  646. height: 88rpx;
  647. background: #f5f5f5;
  648. color: #999;
  649. border-radius: 44rpx;
  650. font-size: 32rpx;
  651. font-weight: 500;
  652. display: flex;
  653. align-items: center;
  654. justify-content: center;
  655. border: none;
  656. transition: all 0.2s ease;
  657. }
  658. .confirm-btn.active {
  659. background: #1B64F0;
  660. color: white;
  661. }
  662. .confirm-btn.active:active {
  663. opacity: 0.9;
  664. transform: scale(0.99);
  665. }
  666. .loading {
  667. text-align: center;
  668. padding: 60rpx;
  669. font-size: 28rpx;
  670. color: #999;
  671. }
  672. </style>