PickupTimeCascader.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. <template>
  2. <div class="pickup-time-cascader">
  3. <el-cascader
  4. ref="cascaderRef"
  5. v-model="selectedValue"
  6. :options="cascaderOptions"
  7. :props="cascaderProps"
  8. :show-all-levels="false"
  9. :placeholder="placeholder"
  10. :clearable="clearable"
  11. :filterable="filterable"
  12. :disabled="disabled"
  13. :size="size"
  14. @change="handleChange"
  15. @visible-change="handleVisibleChange"
  16. @expand-change="handleExpandChange"
  17. >
  18. <template #default="{ node, data }">
  19. <div class="cascader-item" :class="{'is-date': !data.timeLabel, 'is-time': data.timeLabel}">
  20. <span class="item-label">{{ data.label }}</span>
  21. <span v-if="data.tag" class="item-tag" :class="data.tagClass">
  22. {{ data.tag }}
  23. </span>
  24. <span v-if="data.recommend" class="item-recommend">推荐</span>
  25. <span v-if="data.night" class="item-night">夜间</span>
  26. </div>
  27. </template>
  28. <template #empty>
  29. <div class="empty-content">
  30. <span class="empty-text">无可用时间段</span>
  31. </div>
  32. </template>
  33. </el-cascader>
  34. </div>
  35. </template>
  36. <script setup>
  37. import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
  38. const props = defineProps({
  39. modelValue: {
  40. type: Array,
  41. default: () => []
  42. },
  43. placeholder: {
  44. type: String,
  45. default: '请选择上门时间'
  46. },
  47. clearable: {
  48. type: Boolean,
  49. default: true
  50. },
  51. filterable: {
  52. type: Boolean,
  53. default: false
  54. },
  55. disabled: {
  56. type: Boolean,
  57. default: false
  58. },
  59. size: {
  60. type: String,
  61. default: 'large',
  62. validator: (value) => ['large', 'default', 'small'].includes(value)
  63. },
  64. // 显示天数
  65. days: {
  66. type: Number,
  67. default: 3
  68. },
  69. // 时间间隔(分钟)
  70. timeInterval: {
  71. type: Number,
  72. default: 60
  73. },
  74. // 开始时间
  75. startTime: {
  76. type: Number,
  77. default: 9
  78. },
  79. // 结束时间
  80. endTime: {
  81. type: Number,
  82. default: 21
  83. },
  84. // 当前时间(用于测试)
  85. currentTime: {
  86. type: Date,
  87. default: () => new Date()
  88. },
  89. // 是否自动选择推荐时间
  90. autoSelect: {
  91. type: Boolean,
  92. default: true
  93. }
  94. })
  95. const emit = defineEmits(['update:modelValue', 'change', 'select', 'clear'])
  96. const selectedValue = ref([])
  97. const cascaderRef = ref(null)
  98. const currentTime = ref(new Date())
  99. const timer = ref(null)
  100. // 级联选择器配置
  101. const cascaderProps = {
  102. expandTrigger: 'click',
  103. multiple: false,
  104. emitPath: true,
  105. value: 'value',
  106. label: 'label',
  107. children: 'children',
  108. disabled: 'disabled',
  109. checkStrictly: false,
  110. lazy: false
  111. }
  112. // 基础时间段配置
  113. const baseTimeSlots = computed(() => {
  114. const slots = []
  115. const startHour = props.startTime
  116. const endHour = props.endTime
  117. const interval = props.timeInterval / 60 // 转换为小时
  118. for (let hour = startHour; hour < endHour; hour += interval) {
  119. const start = hour
  120. const end = hour + interval
  121. const startStr = start.toString().padStart(2, '0')
  122. const endStr = end.toString().padStart(2, '0')
  123. slots.push({
  124. start: start,
  125. end: end,
  126. label: `${startStr}:00-${endStr}:00`,
  127. value: `${startStr}:00-${endStr}:00`
  128. })
  129. }
  130. return slots
  131. })
  132. // 获取今天日期字符串
  133. const todayDate = computed(() => {
  134. const today = new Date()
  135. return formatDate(today)
  136. })
  137. // 生成日期列表
  138. const dateList = computed(() => {
  139. const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
  140. const list = []
  141. const today = new Date()
  142. for (let i = 0; i < props.days; i++) {
  143. const date = new Date(today)
  144. date.setDate(date.getDate() + i)
  145. const year = date.getFullYear()
  146. const month = date.getMonth() + 1
  147. const day = date.getDate()
  148. const week = days[date.getDay()]
  149. const dateStr = formatDate(date)
  150. let label = ''
  151. if (i === 0) {
  152. label = `今天 ${month}月${day}日 ${week}`
  153. } else if (i === 1) {
  154. label = `明天 ${month}月${day}日 ${week}`
  155. } else if (i === 2) {
  156. label = `后天 ${month}月${day}日 ${week}`
  157. } else {
  158. label = `${month}月${day}日 ${week}`
  159. }
  160. list.push({
  161. value: dateStr,
  162. label: label,
  163. year: year,
  164. month: month,
  165. day: day,
  166. week: week,
  167. isToday: i === 0,
  168. isTomorrow: i === 1,
  169. isDayAfterTomorrow: i === 2,
  170. fullLabel: `${month}月${day}日 ${week}`,
  171. displayDate: `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
  172. })
  173. }
  174. return list
  175. })
  176. // 推荐时间段(今天的一小时内)
  177. const recommendTime = computed(() => {
  178. if (!dateList.value.length || !dateList.value[0].isToday) return null
  179. const now = currentTime.value
  180. const currentHour = now.getHours()
  181. const currentMinute = now.getMinutes()
  182. // 找到下一个可用的时间段
  183. for (const slot of baseTimeSlots.value) {
  184. if (slot.start > currentHour ||
  185. (slot.start === currentHour && currentMinute < 50)) {
  186. // 如果当前时间在 startTime 到 endTime 之间
  187. if (currentHour >= props.startTime && currentHour < props.endTime) {
  188. // 计算推荐时间段(当前时间+1小时)
  189. let startHour = currentHour
  190. let startMinute = Math.ceil(currentMinute / 10) * 10
  191. if (startMinute >= 60) {
  192. startHour += 1
  193. startMinute = 0
  194. }
  195. const endHour = startHour + 1
  196. const recommendation = `${formatTime(startHour, startMinute)}-${endHour.toString().padStart(2, '0')}:00`
  197. // 检查推荐时间是否在可用时间段内
  198. if (isTimeSlotAvailable(recommendation)) {
  199. return recommendation
  200. }
  201. }
  202. return slot.label
  203. }
  204. }
  205. return null
  206. })
  207. // 检查时间段是否可用
  208. const isTimeSlotAvailable = (timeLabel) => {
  209. const timeMatch = timeLabel.match(/(\d{2}):(\d{2})-(\d{2}):(\d{2})/)
  210. if (!timeMatch) return false
  211. const startHour = parseInt(timeMatch[1])
  212. const startMinute = parseInt(timeMatch[2])
  213. // 检查是否在允许的时间范围内
  214. if (startHour < props.startTime || startHour >= props.endTime) {
  215. return false
  216. }
  217. return true
  218. }
  219. // 生成级联选择器选项
  220. const cascaderOptions = computed(() => {
  221. const options = []
  222. dateList.value.forEach(date => {
  223. const dateOption = {
  224. value: date.value,
  225. label: date.label,
  226. date: date.value,
  227. dateLabel: date.label,
  228. isToday: date.isToday,
  229. children: [],
  230. disabled: false
  231. }
  232. // 生成时间选项
  233. const isToday = date.isToday
  234. const now = currentTime.value
  235. // 今天的时间段需要根据当前时间过滤
  236. if (isToday) {
  237. const currentHour = now.getHours()
  238. const currentMinute = now.getMinutes()
  239. // 添加推荐时间段(如果有)
  240. if (recommendTime.value && props.autoSelect) {
  241. dateOption.children.push({
  242. value: recommendTime.value,
  243. label: recommendTime.value,
  244. timeLabel: recommendTime.value,
  245. recommend: true,
  246. tag: '推荐',
  247. tagClass: 'tag-recommend',
  248. disabled: false,
  249. soon: false,
  250. night: isNightTime(recommendTime.value),
  251. startHour: getStartHour(recommendTime.value),
  252. endHour: getEndHour(recommendTime.value)
  253. })
  254. }
  255. // 添加其他时间段
  256. baseTimeSlots.value.forEach(slot => {
  257. const isDisabled = slot.end <= currentHour ||
  258. (slot.start === currentHour && currentMinute > 50)
  259. const isSoon = slot.start - currentHour <= 2 && slot.start > currentHour
  260. const isRecommend = slot.label === recommendTime.value
  261. if (!isDisabled || isRecommend) {
  262. dateOption.children.push({
  263. value: slot.label,
  264. label: slot.label,
  265. timeLabel: slot.label,
  266. recommend: isRecommend,
  267. tag: isSoon ? '约满' : (isRecommend ? '推荐' : ''),
  268. tagClass: isSoon ? 'tag-soon' : (isRecommend ? 'tag-recommend' : ''),
  269. disabled: isDisabled && !isRecommend,
  270. soon: isSoon,
  271. night: isNightTime(slot.label),
  272. startHour: slot.start,
  273. endHour: slot.end
  274. })
  275. }
  276. })
  277. } else {
  278. // 明天及以后显示所有时间段
  279. baseTimeSlots.value.forEach(slot => {
  280. dateOption.children.push({
  281. value: slot.label,
  282. label: slot.label,
  283. timeLabel: slot.label,
  284. recommend: false,
  285. tag: '',
  286. tagClass: '',
  287. disabled: false,
  288. soon: false,
  289. night: isNightTime(slot.label),
  290. startHour: slot.start,
  291. endHour: slot.end
  292. })
  293. })
  294. }
  295. // 如果没有可用的时间段,则禁用该日期
  296. if (dateOption.children.length === 0) {
  297. dateOption.disabled = true
  298. dateOption.label = `${dateOption.label} (无可选时间)`
  299. }
  300. options.push(dateOption)
  301. })
  302. return options
  303. })
  304. // 是否是夜间时间段
  305. const isNightTime = (timeLabel) => {
  306. const match = timeLabel.match(/^(\d{2})/)
  307. if (match) {
  308. const hour = parseInt(match[1])
  309. return hour >= 18
  310. }
  311. return false
  312. }
  313. // 获取开始小时
  314. const getStartHour = (timeLabel) => {
  315. const match = timeLabel.match(/^(\d{2})/)
  316. return match ? parseInt(match[1]) : 0
  317. }
  318. // 获取结束小时
  319. const getEndHour = (timeLabel) => {
  320. const match = timeLabel.match(/-(\d{2})/)
  321. return match ? parseInt(match[1]) : 0
  322. }
  323. // 处理选择变化
  324. const handleChange = (value) => {
  325. if (!value || value.length === 0) {
  326. emit('update:modelValue', [])
  327. emit('change', null)
  328. emit('clear')
  329. return
  330. }
  331. const [date, time] = value
  332. const selectedDate = dateList.value.find(d => d.value === date)
  333. const selectedTime = findTimeOption(date, time)
  334. if (!selectedDate || !selectedTime) {
  335. return
  336. }
  337. const timeData = {
  338. date: date,
  339. time: time,
  340. dateLabel: selectedDate.label,
  341. timeLabel: time,
  342. fullLabel: formatDisplayText(selectedDate.label, time, selectedDate.isToday),
  343. timestamp: new Date(date).setHours(selectedTime.startHour || 0),
  344. isToday: selectedDate.isToday,
  345. isImmediate: time === recommendTime.value,
  346. isNight: selectedTime.night || false,
  347. isSoon: selectedTime.soon || false,
  348. displayText: formatDisplayText(selectedDate.label, time, selectedDate.isToday),
  349. startHour: selectedTime.startHour,
  350. endHour: selectedTime.endHour
  351. }
  352. emit('update:modelValue', value)
  353. emit('change', timeData)
  354. emit('select', timeData)
  355. }
  356. // 查找时间选项
  357. const findTimeOption = (date, time) => {
  358. const dateOption = cascaderOptions.value.find(opt => opt.value === date)
  359. if (!dateOption || !dateOption.children) return null
  360. return dateOption.children.find(child => child.value === time)
  361. }
  362. // 处理弹窗显示/隐藏
  363. const handleVisibleChange = (visible) => {
  364. if (visible) {
  365. // 更新当前时间
  366. currentTime.value = new Date()
  367. // 如果没有选中值,自动选择推荐或第一个可用选项
  368. if (selectedValue.value.length === 0 && props.autoSelect) {
  369. nextTick(() => {
  370. const defaultOption = getDefaultOption()
  371. if (defaultOption) {
  372. selectedValue.value = defaultOption
  373. handleChange(defaultOption)
  374. }
  375. })
  376. }
  377. }
  378. }
  379. // 处理菜单展开
  380. const handleExpandChange = (activeLabels) => {
  381. // 可以在这里添加菜单展开时的逻辑
  382. }
  383. // 获取默认选项
  384. const getDefaultOption = () => {
  385. if (!cascaderOptions.value.length) return null
  386. // 首先尝试今天的推荐时间段
  387. const todayOption = cascaderOptions.value[0]
  388. if (todayOption && todayOption.children && todayOption.children.length > 0) {
  389. // 优先选择推荐时间段
  390. const recommendOption = todayOption.children.find(child => child.recommend && !child.disabled)
  391. if (recommendOption) {
  392. return [todayOption.value, recommendOption.value]
  393. }
  394. // 否则选择第一个可用时间段
  395. const firstAvailable = todayOption.children.find(child => !child.disabled)
  396. if (firstAvailable) {
  397. return [todayOption.value, firstAvailable.value]
  398. }
  399. }
  400. // 尝试其他日期的第一个可用时间段
  401. for (let i = 1; i < cascaderOptions.value.length; i++) {
  402. const option = cascaderOptions.value[i]
  403. if (option.children && option.children.length > 0) {
  404. const firstAvailable = option.children.find(child => !child.disabled)
  405. if (firstAvailable) {
  406. return [option.value, firstAvailable.value]
  407. }
  408. }
  409. }
  410. return null
  411. }
  412. // 格式化显示文本
  413. const formatDisplayText = (dateLabel, timeLabel, isToday) => {
  414. if (isToday && timeLabel === recommendTime.value) {
  415. return '一小时内'
  416. }
  417. return `${dateLabel} ${timeLabel}`
  418. }
  419. // 工具函数
  420. const formatDate = (date) => {
  421. const year = date.getFullYear()
  422. const month = String(date.getMonth() + 1).padStart(2, '0')
  423. const day = String(date.getDate()).padStart(2, '0')
  424. return `${year}-${month}-${day}`
  425. }
  426. const formatTime = (hour, minute) => {
  427. return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
  428. }
  429. // 监听外部值变化
  430. watch(() => props.modelValue, (newVal) => {
  431. if (JSON.stringify(newVal) !== JSON.stringify(selectedValue.value)) {
  432. selectedValue.value = newVal || []
  433. }
  434. }, { immediate: true })
  435. // 监听当前时间变化(每分钟更新一次)
  436. watch(currentTime, () => {
  437. // 如果今天的时间选项已经过期,需要重新计算
  438. const todayOption = cascaderOptions.value[0]
  439. if (todayOption && todayOption.isToday) {
  440. // 这里可以添加逻辑来处理时间过期的选项
  441. }
  442. })
  443. // 初始化
  444. onMounted(() => {
  445. // 定时更新当前时间(每分钟更新一次)
  446. timer.value = setInterval(() => {
  447. if (cascaderRef.value && cascaderRef.value.dropDownVisible) {
  448. currentTime.value = new Date()
  449. }
  450. }, 60000) // 每分钟更新一次
  451. })
  452. // 组件卸载时清除定时器
  453. onUnmounted(() => {
  454. if (timer.value) {
  455. clearInterval(timer.value)
  456. }
  457. })
  458. // 暴露方法
  459. defineExpose({
  460. getSelectedTimeData: () => {
  461. if (selectedValue.value.length !== 2) return null
  462. const [date, time] = selectedValue.value
  463. const selectedDate = dateList.value.find(d => d.value === date)
  464. const selectedTime = findTimeOption(date, time)
  465. return {
  466. date: date,
  467. time: time,
  468. dateLabel: selectedDate?.label,
  469. timeLabel: time,
  470. fullLabel: formatDisplayText(selectedDate?.label, time, selectedDate?.isToday),
  471. timestamp: new Date(date).setHours(selectedTime?.startHour || 0),
  472. isToday: selectedDate?.isToday,
  473. isImmediate: time === recommendTime.value,
  474. isNight: selectedTime?.night || false,
  475. isSoon: selectedTime?.soon || false,
  476. startHour: selectedTime?.startHour,
  477. endHour: selectedTime?.endHour
  478. }
  479. },
  480. clearSelection: () => {
  481. selectedValue.value = []
  482. emit('update:modelValue', [])
  483. emit('change', null)
  484. emit('clear')
  485. },
  486. refreshTime: () => {
  487. currentTime.value = new Date()
  488. },
  489. setSelectedTime: (date, time) => {
  490. const dateExists = dateList.value.some(d => d.value === date)
  491. if (dateExists) {
  492. selectedValue.value = [date, time]
  493. handleChange([date, time])
  494. return true
  495. }
  496. return false
  497. }
  498. })
  499. </script>
  500. <style scoped lang="scss">
  501. .pickup-time-cascader {
  502. width: 100%;
  503. :deep(.el-cascader) {
  504. width: 100%;
  505. .el-input {
  506. width: 100%;
  507. .el-input__wrapper {
  508. width: 100%;
  509. box-sizing: border-box;
  510. &.is-focus {
  511. box-shadow: 0 0 0 1px var(--el-color-primary) inset;
  512. }
  513. }
  514. }
  515. }
  516. :deep(.el-cascader__dropdown) {
  517. max-height: 400px;
  518. overflow-y: auto;
  519. border-radius: 8px;
  520. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  521. .el-cascader-panel {
  522. border: none;
  523. .el-cascader-menu {
  524. min-width: 200px;
  525. max-height: 400px;
  526. overflow-y: auto;
  527. &:first-child {
  528. min-width: 220px;
  529. }
  530. .el-cascader-node {
  531. height: auto;
  532. min-height: 40px;
  533. padding: 8px 12px;
  534. &:hover:not(.is-disabled) {
  535. background-color: #f5f7fa;
  536. }
  537. &.is-selectable {
  538. &.in-active-path,
  539. &.is-active {
  540. background-color: #f0f7ff;
  541. .cascader-item {
  542. .item-label {
  543. color: var(--el-color-primary);
  544. font-weight: 600;
  545. }
  546. }
  547. }
  548. }
  549. &.is-disabled {
  550. .cascader-item {
  551. .item-label {
  552. color: #c0c4cc;
  553. }
  554. }
  555. }
  556. .cascader-item {
  557. display: flex;
  558. align-items: center;
  559. justify-content: space-between;
  560. width: 100%;
  561. min-height: 32px;
  562. box-sizing: border-box;
  563. font-size: 14px;
  564. &.is-date {
  565. .item-label {
  566. font-weight: 500;
  567. color: #333;
  568. }
  569. &.is-today {
  570. .item-label {
  571. color: #f2270c;
  572. }
  573. }
  574. }
  575. &.is-time {
  576. .item-label {
  577. color: #666;
  578. }
  579. }
  580. .item-label {
  581. flex: 1;
  582. text-align: left;
  583. overflow: hidden;
  584. text-overflow: ellipsis;
  585. white-space: nowrap;
  586. }
  587. .item-tag {
  588. font-size: 12px;
  589. padding: 2px 8px;
  590. border-radius: 10px;
  591. margin-left: 8px;
  592. font-weight: 500;
  593. &.tag-recommend {
  594. background-color: #fef2f0;
  595. color: #f2270c;
  596. border: 1px solid #f2270c;
  597. }
  598. &.tag-soon {
  599. background-color: #fff7e6;
  600. color: #ff9900;
  601. border: 1px solid #ff9900;
  602. }
  603. &.tag-night {
  604. background-color: #f0f5ff;
  605. color: #4a5cff;
  606. border: 1px solid #4a5cff;
  607. }
  608. }
  609. .item-recommend {
  610. font-size: 12px;
  611. color: #f2270c;
  612. font-weight: 500;
  613. margin-left: 8px;
  614. padding: 2px 6px;
  615. background-color: #fef2f0;
  616. border-radius: 4px;
  617. }
  618. .item-night {
  619. font-size: 12px;
  620. color: #4a5cff;
  621. font-weight: 500;
  622. margin-left: 8px;
  623. padding: 2px 6px;
  624. background-color: #f0f5ff;
  625. border-radius: 4px;
  626. }
  627. }
  628. }
  629. }
  630. }
  631. }
  632. .empty-content {
  633. padding: 20px;
  634. text-align: center;
  635. .empty-text {
  636. font-size: 14px;
  637. color: #999;
  638. }
  639. }
  640. }
  641. // 夜间时间段特殊样式
  642. .night-time-option {
  643. :deep(.el-cascader-node) {
  644. .cascader-item {
  645. .item-label {
  646. color: #4a5cff !important;
  647. }
  648. }
  649. &.is-active {
  650. .cascader-item {
  651. .item-label {
  652. color: #4a5cff !important;
  653. }
  654. }
  655. }
  656. }
  657. }
  658. // 推荐时间段特殊样式
  659. .recommend-time-option {
  660. :deep(.el-cascader-node) {
  661. .cascader-item {
  662. .item-label {
  663. color: #f2270c !important;
  664. }
  665. }
  666. &.is-active {
  667. .cascader-item {
  668. .item-label {
  669. color: #f2270c !important;
  670. }
  671. }
  672. }
  673. }
  674. }
  675. </style>