| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783 |
- <template>
- <!-- 使用 u-popup 作为弹窗容器 -->
- <u-popup
- :show="visible"
- mode="bottom"
- round="20"
- :closeable="false"
- @close="close"
- :safeAreaInsetBottom="true"
- customStyle="height: 80vh;"
- >
- <view class="time-picker-container">
- <!-- 标题栏 -->
- <view class="picker-header">
- <text class="picker-title">期望上门时间</text>
- <text class="close-btn" @tap="close">×</text>
- </view>
-
- <!-- 主要内容区域:左侧日期 + 右侧时间 -->
- <view class="main-content">
- <!-- 左侧日期选择 -->
- <scroll-view class="date-sidebar" scroll-y>
- <view
- v-for="date in dateList"
- :key="date.value"
- class="date-item"
- :class="{
- active: selectedDate === date.value,
- today: date.isToday
- }"
- @tap="selectDate(date.value)"
- >
- <view class="date-content">
- <text class="date-name">{{ date.label }}</text>
- <text class="date-info">{{ date.month }}月{{ date.day }}日</text>
- <text class="date-week">{{ date.week }}</text>
- </view>
- </view>
- </scroll-view>
-
- <!-- 右侧时间选择 -->
- <scroll-view class="time-content" scroll-y>
- <!-- 推荐时间段(仅今天显示) -->
- <view v-if="selectedDate === todayDate && withinOneHourSlot" class="recommend-section">
- <view class="recommend-header">
- <text class="recommend-title">一小时内</text>
- <!-- <text class="recommend-desc">推荐时间:{{ withinOneHourSlot.label }}</text> -->
- </view>
- <view class="recommend-time"
- @tap="selectTime(withinOneHourSlot.value, true)">
- <text class="time-value">{{ withinOneHourSlot.label }}</text>
- <!-- <text class="time-tag">推荐</text> -->
- </view>
- </view>
-
- <!-- 时间段列表 -->
- <view class="time-section">
- <view class="section-title">可选时间段</view>
-
- <view v-if="loading" class="loading">加载中...</view>
-
- <view v-else class="time-list">
- <view
- v-for="timeSlot in timeSlots"
- :key="timeSlot.value"
- class="time-item"
- :class="{
- active: selectedTime === timeSlot.value,
- disabled: timeSlot.disabled,
- night: timeSlot.night
- }"
- @tap="!timeSlot.disabled && selectTime(timeSlot.value, false)"
- >
- <text class="time-text">{{ timeSlot.label }}</text>
- <text v-if="timeSlot.tag" class="time-tag">{{ timeSlot.tag }}</text>
- </view>
- </view>
- </view>
-
- <!-- 提示信息 -->
- <view class="notice-section">
- <text class="notice-text">
- 部分区域支持夜间上门,具体以快递员联系为准
- </text>
- </view>
- </scroll-view>
- </view>
-
- <!-- 操作按钮 -->
- <view class="action-bar">
- <button
- class="confirm-btn"
- :class="{ active: selectedTime }"
- @tap="confirm"
- >
- {{ buttonText }}
- </button>
- </view>
- </view>
- </u-popup>
- </template>
- <script setup>
- import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
- const props = defineProps({
- visible: {
- type: Boolean,
- default: false
- },
- // 显示天数
- days: {
- type: Number,
- default: 3
- },
- // 开始时间
- startTime: {
- type: Number,
- default: 9
- },
- // 结束时间
- endTime: {
- type: Number,
- default: 21
- },
- // 是否自动选择推荐时间
- autoSelect: {
- type: Boolean,
- default: true
- }
- })
- const emit = defineEmits(['update:visible', 'close', 'confirm'])
- // 当前时间
- const currentTime = ref(new Date())
- const selectedDate = ref('')
- const selectedTime = ref('')
- const loading = ref(false)
- const isWithinOneHour = ref(false)
- // 计算当前时间20分钟后的时间
- const twentyMinutesLater = computed(() => {
- const now = new Date()
- now.setMinutes(now.getMinutes() + 10)
- return now
- })
- // 计算一小时后的小时(向上取整)
- const oneHourLaterHour = computed(() => {
- const now = new Date()
- now.setHours(now.getHours() + 1)
- return Math.ceil(now.getHours())
- })
- // 获取今天日期字符串
- const todayDate = computed(() => {
- const today = new Date()
- return formatDate(today)
- })
- // 生成日期列表
- const dateList = computed(() => {
- const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
- const list = []
- const today = new Date()
-
- for (let i = 0; i < props.days; i++) {
- const date = new Date(today)
- date.setDate(date.getDate() + i)
-
- const month = date.getMonth() + 1
- const day = date.getDate()
- const week = days[date.getDay()]
- const dateStr = formatDate(date)
-
- let label = ''
- if (i === 0) {
- label = '今天'
- } else if (i === 1) {
- label = '明天'
- } else {
- label = '后天'
- }
-
- list.push({
- value: dateStr,
- label: label,
- week: week,
- month: month,
- day: day,
- fullLabel: `${month}月${day}日 ${week}`,
- isToday: i === 0,
- isTomorrow: i === 1,
- isDayAfterTomorrow: i === 2,
- displayDate: `${date.getFullYear()}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
- })
- }
-
- return list
- })
- // 基础时间段配置
- const baseTimeSlots = computed(() => {
- const slots = []
- const startHour = props.startTime
- const endHour = props.endTime
- const interval = 1 // 小时间隔
-
- for (let hour = startHour; hour < endHour; hour += interval) {
- const start = hour
- const end = hour + interval
- const startStr = start.toString().padStart(2, '0')
- const endStr = end.toString().padStart(2, '0')
-
- slots.push({
- start: start,
- end: end,
- label: `${startStr}:00-${endStr}:00`,
- value: `${startStr}:00-${endStr}:00`,
- startHour: start,
- endHour: end
- })
- }
-
- return slots
- })
- // 计算一小时内时间段
- const withinOneHourSlot = computed(() => {
- if (!dateList.value.length || !dateList.value[0].isToday) return null
-
- const now = currentTime.value
- const later20min = twentyMinutesLater.value
-
- // 计算开始时间:当前时间 + 20分钟,分钟向上取整到10的倍数
- const startMinutes = later20min.getMinutes()
- const roundedMinutes = Math.ceil(startMinutes / 10) * 10
- let startHour = later20min.getHours()
- let startMinute = roundedMinutes
-
- // 如果分钟超过60,小时加1,分钟归零
- if (startMinute >= 60) {
- startHour += 1
- startMinute = 0
- }
-
- // 计算结束时间:开始时间 + 1小时
- let endHour = startHour + 1
- let endMinute = startMinute
-
- // 如果开始时间已经超过结束时间限制,则不显示
- if (startHour >= props.endTime) return null
-
- // 如果结束时间超过结束时间限制,则调整
- if (endHour > props.endTime) {
- endHour = props.endTime
- endMinute = 0
- }
-
- // 如果开始时间小于开始时间限制,则调整
- if (startHour < props.startTime) {
- startHour = props.startTime
- startMinute = 0
- }
-
- // 检查时间段是否有效(至少30分钟)
- const slotDuration = (endHour - startHour) * 60 + (endMinute - startMinute)
- if (slotDuration < 30) return null
-
- const startStr = formatTime(startHour, startMinute)
- const endStr = formatTime(endHour, endMinute)
-
- return {
- start: startHour + startMinute / 60,
- end: endHour + endMinute / 60,
- label: `(${startStr}-${endStr})`,
- value: `${startStr}-${endStr}`,
- isWithinOneHour: true,
- startHour: startHour,
- startMinute: startMinute,
- endHour: endHour,
- endMinute: endMinute
- }
- })
- // 是否是夜间时间段
- const isNightTime = (hour) => {
- return hour >= 18
- }
- // 根据选择的日期获取时间段
- const timeSlots = computed(() => {
- const slots = []
- const isToday = selectedDate.value === todayDate.value
-
- if (isToday) {
- const oneHourLater = oneHourLaterHour.value
-
- // 添加普通时间段(开始时间从一小时后开始)
- baseTimeSlots.value.forEach(slot => {
- // 时间段开始时间必须在当前时间+1小时之后
- const isDisabled = slot.start < oneHourLater
-
- if (!isDisabled) {
- slots.push({
- ...slot,
- disabled: false,
- night: false,
- tag: ''
- })
- // night: isNightTime(slot.start),
- }
- })
- } else {
- // 明天及以后显示所有时间段
- baseTimeSlots.value.forEach(slot => {
- slots.push({
- ...slot,
- disabled: false,
- night: isNightTime(slot.start),
- tag: ''
- })
- })
- }
-
- return slots
- })
- // 按钮文本
- const buttonText = computed(() => {
- if (!selectedTime.value) return '请选择上门时间'
- return '确定'
- })
- // 初始化选择
- const initSelection = () => {
- if (dateList.value.length > 0) {
- selectedDate.value = dateList.value[0].value
-
- // 自动选择推荐时间或第一个可用时间
- if (withinOneHourSlot.value && props.autoSelect) {
- selectedTime.value = withinOneHourSlot.value.value
- isWithinOneHour.value = true
- } else {
- const firstSlot = timeSlots.value[0]
- if (firstSlot) {
- selectedTime.value = firstSlot.value
- isWithinOneHour.value = false
- }
- }
- }
- }
- // 选择日期
- const selectDate = (date) => {
- selectedDate.value = date
- selectedTime.value = '' // 重置时间选择
- isWithinOneHour.value = false
- }
- // 选择时间
- const selectTime = (time, isWithinOneHourFlag = false) => {
- selectedTime.value = time
- isWithinOneHour.value = isWithinOneHourFlag
- }
- // 格式化日期时间
- const formatDateTime = (dateStr, hour) => {
- const date = new Date(dateStr)
- const wholeHour = Math.floor(hour)
- const minutes = Math.round((hour - wholeHour) * 60)
- date.setHours(wholeHour, minutes, 0, 0)
-
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- const hours = String(date.getHours()).padStart(2, '0')
- const mins = String(date.getMinutes()).padStart(2, '0')
- const seconds = String(date.getSeconds()).padStart(2, '0')
-
- return `${year}-${month}-${day} ${hours}:${mins}:${seconds}`
- }
- // 获取开始小时
- const getStartHour = (timeLabel) => {
- if (isWithinOneHour.value) {
- return withinOneHourSlot.value.startHour + withinOneHourSlot.value.startMinute / 60
- }
-
- const match = timeLabel.match(/(\d{2}):/)
- return match ? parseInt(match[1]) : 0
- }
- // 获取结束小时
- const getEndHour = (timeLabel) => {
- if (isWithinOneHour.value) {
- return withinOneHourSlot.value.endHour + withinOneHourSlot.value.endMinute / 60
- }
-
- const match = timeLabel.match(/-(\d{2}):/)
- return match ? parseInt(match[1]) : 0
- }
- // 确认选择
- const confirm = () => {
- if (!selectedTime.value) return
-
- const selectedDateObj = dateList.value.find(d => d.value === selectedDate.value)
- const selectedTimeSlot = isWithinOneHour.value ? withinOneHourSlot.value :
- baseTimeSlots.value.find(s => s.value === selectedTime.value)
-
- if (!selectedDateObj || !selectedTimeSlot) return
-
- // 计算开始和结束时间
- const startHour = getStartHour(selectedTime.value)
- const endHour = getEndHour(selectedTime.value)
-
- // 计算具体的开始和结束时间字符串
- const startTimeStr = formatDateTime(selectedDate.value, startHour)
- const endTimeStr = formatDateTime(selectedDate.value, endHour)
-
- // 生成显示文本
- let displayText = ''
- if (isWithinOneHour.value) {
- displayText = `${selectedDateObj.label} 一小时内`
- } else {
- displayText = `${selectedDateObj.label} ${selectedTime.value}`
- }
-
- const timeData = {
- date: selectedDate.value,
- time: selectedTime.value,
- dateLabel: selectedDateObj.label,
- timeLabel: selectedTime.value,
- fullLabel: displayText,
- displayText: displayText,
- startTime: startTimeStr,
- endTime: endTimeStr,
- isToday: selectedDateObj.isToday,
- isImmediate: isWithinOneHour.value,
- isNight: isNightTime(selectedTimeSlot.startHour),
- startHour: startHour,
- endHour: endHour
- }
-
- emit('confirm', timeData)
- close()
- }
- // 关闭弹框
- const close = () => {
- emit('close')
- emit('update:visible', false)
- }
- // 工具函数
- const formatDate = (date) => {
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
- }
- const formatTime = (hour, minute) => {
- return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
- }
- // 监听可见性变化
- watch(() => props.visible, (newVal) => {
- if (newVal) {
- currentTime.value = new Date()
- initSelection()
- }
- })
- // 定时更新当前时间
- let timer = null
- onMounted(() => {
- timer = setInterval(() => {
- if (props.visible) {
- currentTime.value = new Date()
- }
- }, 60000) // 每分钟更新一次
- })
- // 组件销毁时清除定时器
- onUnmounted(() => {
- if (timer) {
- clearInterval(timer)
- }
- })
- </script>
- <style scoped lang="less">
- /* 弹窗内部容器 */
- .time-picker-container {
- height: 80vh;
- display: flex;
- flex-direction: column;
- }
- .picker-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 32rpx 32rpx 24rpx;
- border-bottom: 1rpx solid #f0f0f0;
- flex-shrink: 0;
- }
- .picker-title {
- font-size: 36rpx;
- font-weight: 600;
- color: #333;
- }
- .close-btn {
- font-size: 50rpx;
- color: #999;
- line-height: 40rpx;
- padding: 10rpx;
- }
- /* 主要内容区域 */
- .main-content {
- flex: 1;
- display: flex;
- overflow: hidden;
- }
- /* 左侧日期选择 */
- .date-sidebar {
- width: 220rpx;
- background-color: #f8f8f8;
- border-right: 1rpx solid #f0f0f0;
- }
- .date-item {
- padding: 32rpx 24rpx;
- border-bottom: 1rpx solid #f0f0f0;
- }
- .date-item.active {
- background-color: #fff;
- position: relative;
- }
- .date-item.active::before {
- content: '';
- position: absolute;
- left: 0;
- top: 50%;
- transform: translateY(-50%);
- width: 6rpx;
- height: 40rpx;
- background-color: #1B64F0;
- border-radius: 0 4rpx 4rpx 0;
- }
- .date-item.today .date-name {
- color: #1B64F0;
- }
- .date-item.active .date-content {
- color: #1B64F0;
- }
- .date-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- }
- .date-name {
- font-size: 32rpx;
- font-weight: 600;
- color: #333;
- margin-bottom: 8rpx;
- }
- .date-info {
- font-size: 24rpx;
- color: #666;
- margin-bottom: 4rpx;
- }
- .date-week {
- font-size: 22rpx;
- color: #999;
- }
- /* 右侧时间选择 */
- .time-content {
- flex: 1;
- padding: 0 32rpx;
- overflow-y: auto;
- }
- .recommend-section {
- margin-top: 32rpx;
- padding-bottom: 24rpx;
- border-bottom: 1rpx solid #f0f0f0;
- }
- .recommend-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20rpx;
- }
- .recommend-title {
- font-size: 28rpx;
- font-weight: 500;
- color: #333;
- }
- .recommend-desc {
- font-size: 24rpx;
- color: #1B64F0;
- }
- .recommend-time {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 28rpx 24rpx;
- // background: linear-gradient(135deg, #fff2f0 0%, #ffe6e6 100%);
- border-radius: 16rpx;
- border: 2rpx solid #1B64F0;
- }
- .time-value {
- font-size: 32rpx;
- font-weight: 500;
- color: #1B64F0;
- }
- .recommend-time .time-tag {
- font-size: 24rpx;
- padding: 8rpx 16rpx;
- background: #1B64F0;
- color: white;
- border-radius: 20rpx;
- }
- /* 时间段列表 */
- .time-section {
- margin-top: 32rpx;
- margin-bottom: 32rpx;
- }
- .section-title {
- font-size: 28rpx;
- font-weight: 500;
- color: #333;
- margin-bottom: 24rpx;
- }
- .time-list {
- display: flex;
- flex-direction: column;
- gap: 20rpx;
- }
- .time-item {
- padding: 28rpx 24rpx;
- border: 2rpx solid #e8e8e8;
- border-radius: 12rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- position: relative;
- transition: all 0.2s ease;
- }
- .time-item.active {
- border-color: #1B64F0;
- // background: #fff2f0;
- }
- .time-item.night {
- border-color: #4a5cff;
- }
- .time-item:not(.disabled):active {
- transform: scale(0.98);
- opacity: 0.8;
- }
- .time-item.disabled {
- opacity: 0.5;
- }
- .time-text {
- font-size: 28rpx;
- color: #333;
- }
- .time-item.active .time-text {
- color: #1B64F0;
- font-weight: 500;
- }
- .time-item.night .time-text {
- color: #4a5cff;
- }
- .time-item .time-tag {
- font-size: 20rpx;
- padding: 4rpx 12rpx;
- border-radius: 12rpx;
- background: #f5f5f5;
- color: #666;
- }
- .time-item.active .time-tag {
- background: #1B64F0;
- color: white;
- }
- .time-item.night .time-tag {
- background: #4a5cff;
- color: white;
- }
- /* 提示信息 */
- .notice-section {
- padding: 24rpx;
- margin-bottom: 32rpx;
- background: #f8f8f8;
- border-radius: 12rpx;
- }
- .notice-text {
- font-size: 24rpx;
- color: #666;
- line-height: 1.4;
- }
- /* 操作按钮 */
- .action-bar {
- padding: 24rpx 32rpx;
- padding-top: 0;
- background: #fff;
- border-top: 1rpx solid #f0f0f0;
- flex-shrink: 0;
- }
- .confirm-btn {
- width: 100%;
- height: 88rpx;
- background: #f5f5f5;
- color: #999;
- border-radius: 44rpx;
- font-size: 32rpx;
- font-weight: 500;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- transition: all 0.2s ease;
- }
- .confirm-btn.active {
- background: #1B64F0;
- color: white;
- }
- .confirm-btn.active:active {
- opacity: 0.9;
- transform: scale(0.99);
- }
- .loading {
- text-align: center;
- padding: 60rpx;
- font-size: 28rpx;
- color: #999;
- }
- </style>
|