| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769 |
- <template>
- <div class="pickup-time-cascader">
- <el-cascader
- ref="cascaderRef"
- v-model="selectedValue"
- :options="cascaderOptions"
- :props="cascaderProps"
- :show-all-levels="false"
- :placeholder="placeholder"
- :clearable="clearable"
- :filterable="filterable"
- :disabled="disabled"
- :size="size"
- @change="handleChange"
- @visible-change="handleVisibleChange"
- @expand-change="handleExpandChange"
- >
- <template #default="{ node, data }">
- <div class="cascader-item" :class="{'is-date': !data.timeLabel, 'is-time': data.timeLabel}">
- <span class="item-label">{{ data.label }}</span>
- <span v-if="data.tag" class="item-tag" :class="data.tagClass">
- {{ data.tag }}
- </span>
- <span v-if="data.recommend" class="item-recommend">推荐</span>
- <span v-if="data.night" class="item-night">夜间</span>
- </div>
- </template>
- <template #empty>
- <div class="empty-content">
- <span class="empty-text">无可用时间段</span>
- </div>
- </template>
- </el-cascader>
- </div>
- </template>
- <script setup>
- import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
- const props = defineProps({
- modelValue: {
- type: Array,
- default: () => []
- },
- placeholder: {
- type: String,
- default: '请选择上门时间'
- },
- clearable: {
- type: Boolean,
- default: true
- },
- filterable: {
- type: Boolean,
- default: false
- },
- disabled: {
- type: Boolean,
- default: false
- },
- size: {
- type: String,
- default: 'large',
- validator: (value) => ['large', 'default', 'small'].includes(value)
- },
- // 显示天数
- days: {
- type: Number,
- default: 3
- },
- // 时间间隔(分钟)
- timeInterval: {
- type: Number,
- default: 60
- },
- // 开始时间
- startTime: {
- type: Number,
- default: 9
- },
- // 结束时间
- endTime: {
- type: Number,
- default: 21
- },
- // 当前时间(用于测试)
- currentTime: {
- type: Date,
- default: () => new Date()
- },
- // 是否自动选择推荐时间
- autoSelect: {
- type: Boolean,
- default: true
- }
- })
- const emit = defineEmits(['update:modelValue', 'change', 'select', 'clear'])
- const selectedValue = ref([])
- const cascaderRef = ref(null)
- const currentTime = ref(new Date())
- const timer = ref(null)
- // 级联选择器配置
- const cascaderProps = {
- expandTrigger: 'click',
- multiple: false,
- emitPath: true,
- value: 'value',
- label: 'label',
- children: 'children',
- disabled: 'disabled',
- checkStrictly: false,
- lazy: false
- }
- // 基础时间段配置
- const baseTimeSlots = computed(() => {
- const slots = []
- const startHour = props.startTime
- const endHour = props.endTime
- const interval = props.timeInterval / 60 // 转换为小时
- 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`
- })
- }
- return slots
- })
- // 获取今天日期字符串
- 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 year = date.getFullYear()
- const month = date.getMonth() + 1
- const day = date.getDate()
- const week = days[date.getDay()]
- const dateStr = formatDate(date)
- let label = ''
- if (i === 0) {
- label = `今天 ${month}月${day}日 ${week}`
- } else if (i === 1) {
- label = `明天 ${month}月${day}日 ${week}`
- } else if (i === 2) {
- label = `后天 ${month}月${day}日 ${week}`
- } else {
- label = `${month}月${day}日 ${week}`
- }
- list.push({
- value: dateStr,
- label: label,
- year: year,
- month: month,
- day: day,
- week: week,
- isToday: i === 0,
- isTomorrow: i === 1,
- isDayAfterTomorrow: i === 2,
- fullLabel: `${month}月${day}日 ${week}`,
- displayDate: `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
- })
- }
- return list
- })
- // 推荐时间段(今天的一小时内)
- const recommendTime = computed(() => {
- if (!dateList.value.length || !dateList.value[0].isToday) return null
- const now = currentTime.value
- const currentHour = now.getHours()
- const currentMinute = now.getMinutes()
- // 找到下一个可用的时间段
- for (const slot of baseTimeSlots.value) {
- if (slot.start > currentHour ||
- (slot.start === currentHour && currentMinute < 50)) {
- // 如果当前时间在 startTime 到 endTime 之间
- if (currentHour >= props.startTime && currentHour < props.endTime) {
- // 计算推荐时间段(当前时间+1小时)
- let startHour = currentHour
- let startMinute = Math.ceil(currentMinute / 10) * 10
- if (startMinute >= 60) {
- startHour += 1
- startMinute = 0
- }
- const endHour = startHour + 1
- const recommendation = `${formatTime(startHour, startMinute)}-${endHour.toString().padStart(2, '0')}:00`
- // 检查推荐时间是否在可用时间段内
- if (isTimeSlotAvailable(recommendation)) {
- return recommendation
- }
- }
- return slot.label
- }
- }
- return null
- })
- // 检查时间段是否可用
- const isTimeSlotAvailable = (timeLabel) => {
- const timeMatch = timeLabel.match(/(\d{2}):(\d{2})-(\d{2}):(\d{2})/)
- if (!timeMatch) return false
- const startHour = parseInt(timeMatch[1])
- const startMinute = parseInt(timeMatch[2])
- // 检查是否在允许的时间范围内
- if (startHour < props.startTime || startHour >= props.endTime) {
- return false
- }
- return true
- }
- // 生成级联选择器选项
- const cascaderOptions = computed(() => {
- const options = []
- dateList.value.forEach(date => {
- const dateOption = {
- value: date.value,
- label: date.label,
- date: date.value,
- dateLabel: date.label,
- isToday: date.isToday,
- children: [],
- disabled: false
- }
- // 生成时间选项
- const isToday = date.isToday
- const now = currentTime.value
- // 今天的时间段需要根据当前时间过滤
- if (isToday) {
- const currentHour = now.getHours()
- const currentMinute = now.getMinutes()
- // 添加推荐时间段(如果有)
- if (recommendTime.value && props.autoSelect) {
- dateOption.children.push({
- value: recommendTime.value,
- label: recommendTime.value,
- timeLabel: recommendTime.value,
- recommend: true,
- tag: '推荐',
- tagClass: 'tag-recommend',
- disabled: false,
- soon: false,
- night: isNightTime(recommendTime.value),
- startHour: getStartHour(recommendTime.value),
- endHour: getEndHour(recommendTime.value)
- })
- }
- // 添加其他时间段
- baseTimeSlots.value.forEach(slot => {
- const isDisabled = slot.end <= currentHour ||
- (slot.start === currentHour && currentMinute > 50)
- const isSoon = slot.start - currentHour <= 2 && slot.start > currentHour
- const isRecommend = slot.label === recommendTime.value
- if (!isDisabled || isRecommend) {
- dateOption.children.push({
- value: slot.label,
- label: slot.label,
- timeLabel: slot.label,
- recommend: isRecommend,
- tag: isSoon ? '约满' : (isRecommend ? '推荐' : ''),
- tagClass: isSoon ? 'tag-soon' : (isRecommend ? 'tag-recommend' : ''),
- disabled: isDisabled && !isRecommend,
- soon: isSoon,
- night: isNightTime(slot.label),
- startHour: slot.start,
- endHour: slot.end
- })
- }
- })
- } else {
- // 明天及以后显示所有时间段
- baseTimeSlots.value.forEach(slot => {
- dateOption.children.push({
- value: slot.label,
- label: slot.label,
- timeLabel: slot.label,
- recommend: false,
- tag: '',
- tagClass: '',
- disabled: false,
- soon: false,
- night: isNightTime(slot.label),
- startHour: slot.start,
- endHour: slot.end
- })
- })
- }
- // 如果没有可用的时间段,则禁用该日期
- if (dateOption.children.length === 0) {
- dateOption.disabled = true
- dateOption.label = `${dateOption.label} (无可选时间)`
- }
- options.push(dateOption)
- })
- return options
- })
- // 是否是夜间时间段
- const isNightTime = (timeLabel) => {
- const match = timeLabel.match(/^(\d{2})/)
- if (match) {
- const hour = parseInt(match[1])
- return hour >= 18
- }
- return false
- }
- // 获取开始小时
- const getStartHour = (timeLabel) => {
- const match = timeLabel.match(/^(\d{2})/)
- return match ? parseInt(match[1]) : 0
- }
- // 获取结束小时
- const getEndHour = (timeLabel) => {
- const match = timeLabel.match(/-(\d{2})/)
- return match ? parseInt(match[1]) : 0
- }
- // 处理选择变化
- const handleChange = (value) => {
- if (!value || value.length === 0) {
- emit('update:modelValue', [])
- emit('change', null)
- emit('clear')
- return
- }
- const [date, time] = value
- const selectedDate = dateList.value.find(d => d.value === date)
- const selectedTime = findTimeOption(date, time)
- if (!selectedDate || !selectedTime) {
- return
- }
- const timeData = {
- date: date,
- time: time,
- dateLabel: selectedDate.label,
- timeLabel: time,
- fullLabel: formatDisplayText(selectedDate.label, time, selectedDate.isToday),
- timestamp: new Date(date).setHours(selectedTime.startHour || 0),
- isToday: selectedDate.isToday,
- isImmediate: time === recommendTime.value,
- isNight: selectedTime.night || false,
- isSoon: selectedTime.soon || false,
- displayText: formatDisplayText(selectedDate.label, time, selectedDate.isToday),
- startHour: selectedTime.startHour,
- endHour: selectedTime.endHour
- }
- emit('update:modelValue', value)
- emit('change', timeData)
- emit('select', timeData)
- }
- // 查找时间选项
- const findTimeOption = (date, time) => {
- const dateOption = cascaderOptions.value.find(opt => opt.value === date)
- if (!dateOption || !dateOption.children) return null
- return dateOption.children.find(child => child.value === time)
- }
- // 处理弹窗显示/隐藏
- const handleVisibleChange = (visible) => {
- if (visible) {
- // 更新当前时间
- currentTime.value = new Date()
- // 如果没有选中值,自动选择推荐或第一个可用选项
- if (selectedValue.value.length === 0 && props.autoSelect) {
- nextTick(() => {
- const defaultOption = getDefaultOption()
- if (defaultOption) {
- selectedValue.value = defaultOption
- handleChange(defaultOption)
- }
- })
- }
- }
- }
- // 处理菜单展开
- const handleExpandChange = (activeLabels) => {
- // 可以在这里添加菜单展开时的逻辑
- }
- // 获取默认选项
- const getDefaultOption = () => {
- if (!cascaderOptions.value.length) return null
- // 首先尝试今天的推荐时间段
- const todayOption = cascaderOptions.value[0]
- if (todayOption && todayOption.children && todayOption.children.length > 0) {
- // 优先选择推荐时间段
- const recommendOption = todayOption.children.find(child => child.recommend && !child.disabled)
- if (recommendOption) {
- return [todayOption.value, recommendOption.value]
- }
- // 否则选择第一个可用时间段
- const firstAvailable = todayOption.children.find(child => !child.disabled)
- if (firstAvailable) {
- return [todayOption.value, firstAvailable.value]
- }
- }
- // 尝试其他日期的第一个可用时间段
- for (let i = 1; i < cascaderOptions.value.length; i++) {
- const option = cascaderOptions.value[i]
- if (option.children && option.children.length > 0) {
- const firstAvailable = option.children.find(child => !child.disabled)
- if (firstAvailable) {
- return [option.value, firstAvailable.value]
- }
- }
- }
- return null
- }
- // 格式化显示文本
- const formatDisplayText = (dateLabel, timeLabel, isToday) => {
- if (isToday && timeLabel === recommendTime.value) {
- return '一小时内'
- }
- return `${dateLabel} ${timeLabel}`
- }
- // 工具函数
- 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.modelValue, (newVal) => {
- if (JSON.stringify(newVal) !== JSON.stringify(selectedValue.value)) {
- selectedValue.value = newVal || []
- }
- }, { immediate: true })
- // 监听当前时间变化(每分钟更新一次)
- watch(currentTime, () => {
- // 如果今天的时间选项已经过期,需要重新计算
- const todayOption = cascaderOptions.value[0]
- if (todayOption && todayOption.isToday) {
- // 这里可以添加逻辑来处理时间过期的选项
- }
- })
- // 初始化
- onMounted(() => {
- // 定时更新当前时间(每分钟更新一次)
- timer.value = setInterval(() => {
- if (cascaderRef.value && cascaderRef.value.dropDownVisible) {
- currentTime.value = new Date()
- }
- }, 60000) // 每分钟更新一次
- })
- // 组件卸载时清除定时器
- onUnmounted(() => {
- if (timer.value) {
- clearInterval(timer.value)
- }
- })
- // 暴露方法
- defineExpose({
- getSelectedTimeData: () => {
- if (selectedValue.value.length !== 2) return null
- const [date, time] = selectedValue.value
- const selectedDate = dateList.value.find(d => d.value === date)
- const selectedTime = findTimeOption(date, time)
- return {
- date: date,
- time: time,
- dateLabel: selectedDate?.label,
- timeLabel: time,
- fullLabel: formatDisplayText(selectedDate?.label, time, selectedDate?.isToday),
- timestamp: new Date(date).setHours(selectedTime?.startHour || 0),
- isToday: selectedDate?.isToday,
- isImmediate: time === recommendTime.value,
- isNight: selectedTime?.night || false,
- isSoon: selectedTime?.soon || false,
- startHour: selectedTime?.startHour,
- endHour: selectedTime?.endHour
- }
- },
- clearSelection: () => {
- selectedValue.value = []
- emit('update:modelValue', [])
- emit('change', null)
- emit('clear')
- },
- refreshTime: () => {
- currentTime.value = new Date()
- },
- setSelectedTime: (date, time) => {
- const dateExists = dateList.value.some(d => d.value === date)
- if (dateExists) {
- selectedValue.value = [date, time]
- handleChange([date, time])
- return true
- }
- return false
- }
- })
- </script>
- <style scoped lang="scss">
- .pickup-time-cascader {
- width: 100%;
- :deep(.el-cascader) {
- width: 100%;
- .el-input {
- width: 100%;
- .el-input__wrapper {
- width: 100%;
- box-sizing: border-box;
- &.is-focus {
- box-shadow: 0 0 0 1px var(--el-color-primary) inset;
- }
- }
- }
- }
- :deep(.el-cascader__dropdown) {
- max-height: 400px;
- overflow-y: auto;
- border-radius: 8px;
- box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
- .el-cascader-panel {
- border: none;
- .el-cascader-menu {
- min-width: 200px;
- max-height: 400px;
- overflow-y: auto;
- &:first-child {
- min-width: 220px;
- }
- .el-cascader-node {
- height: auto;
- min-height: 40px;
- padding: 8px 12px;
- &:hover:not(.is-disabled) {
- background-color: #f5f7fa;
- }
- &.is-selectable {
- &.in-active-path,
- &.is-active {
- background-color: #f0f7ff;
- .cascader-item {
- .item-label {
- color: var(--el-color-primary);
- font-weight: 600;
- }
- }
- }
- }
- &.is-disabled {
- .cascader-item {
- .item-label {
- color: #c0c4cc;
- }
- }
- }
- .cascader-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- min-height: 32px;
- box-sizing: border-box;
- font-size: 14px;
- &.is-date {
- .item-label {
- font-weight: 500;
- color: #333;
- }
- &.is-today {
- .item-label {
- color: #f2270c;
- }
- }
- }
- &.is-time {
- .item-label {
- color: #666;
- }
- }
- .item-label {
- flex: 1;
- text-align: left;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .item-tag {
- font-size: 12px;
- padding: 2px 8px;
- border-radius: 10px;
- margin-left: 8px;
- font-weight: 500;
- &.tag-recommend {
- background-color: #fef2f0;
- color: #f2270c;
- border: 1px solid #f2270c;
- }
- &.tag-soon {
- background-color: #fff7e6;
- color: #ff9900;
- border: 1px solid #ff9900;
- }
- &.tag-night {
- background-color: #f0f5ff;
- color: #4a5cff;
- border: 1px solid #4a5cff;
- }
- }
- .item-recommend {
- font-size: 12px;
- color: #f2270c;
- font-weight: 500;
- margin-left: 8px;
- padding: 2px 6px;
- background-color: #fef2f0;
- border-radius: 4px;
- }
- .item-night {
- font-size: 12px;
- color: #4a5cff;
- font-weight: 500;
- margin-left: 8px;
- padding: 2px 6px;
- background-color: #f0f5ff;
- border-radius: 4px;
- }
- }
- }
- }
- }
- }
- .empty-content {
- padding: 20px;
- text-align: center;
- .empty-text {
- font-size: 14px;
- color: #999;
- }
- }
- }
- // 夜间时间段特殊样式
- .night-time-option {
- :deep(.el-cascader-node) {
- .cascader-item {
- .item-label {
- color: #4a5cff !important;
- }
- }
- &.is-active {
- .cascader-item {
- .item-label {
- color: #4a5cff !important;
- }
- }
- }
- }
- }
- // 推荐时间段特殊样式
- .recommend-time-option {
- :deep(.el-cascader-node) {
- .cascader-item {
- .item-label {
- color: #f2270c !important;
- }
- }
- &.is-active {
- .cascader-item {
- .item-label {
- color: #f2270c !important;
- }
- }
- }
- }
- }
- </style>
|