month-panel.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. <template>
  2. <view class="wd-month-panel">
  3. <view v-if="showPanelTitle" class="wd-month-panel__title">
  4. {{ title }}
  5. </view>
  6. <view class="wd-month-panel__weeks">
  7. <view v-for="item in 7" :key="item" class="wd-month-panel__week">{{ weekLabel(item + firstDayOfWeek) }}</view>
  8. </view>
  9. <scroll-view
  10. :class="`wd-month-panel__container ${!!timeType ? 'wd-month-panel__container--time' : ''}`"
  11. :style="`height: ${scrollHeight}px`"
  12. scroll-y
  13. @scroll="monthScroll"
  14. :scroll-top="scrollTop"
  15. >
  16. <view v-for="(item, index) in months" :key="index" :id="`month${index}`">
  17. <month
  18. :type="type"
  19. :date="item.date"
  20. :value="value"
  21. :min-date="minDate"
  22. :max-date="maxDate"
  23. :first-day-of-week="firstDayOfWeek"
  24. :formatter="formatter"
  25. :max-range="maxRange"
  26. :range-prompt="rangePrompt"
  27. :allow-same-day="allowSameDay"
  28. :default-time="defaultTime"
  29. :showTitle="index !== 0"
  30. @change="handleDateChange"
  31. />
  32. </view>
  33. </scroll-view>
  34. <view v-if="timeType" class="wd-month-panel__time">
  35. <view v-if="type === 'datetimerange'" class="wd-month-panel__time-label">
  36. <view class="wd-month-panel__time-text">{{ timeType === 'start' ? translate('startTime') : translate('endTime') }}</view>
  37. </view>
  38. <view class="wd-month-panel__time-picker">
  39. <wd-picker-view
  40. v-if="timeData.length"
  41. v-model="timeValue"
  42. :columns="timeData"
  43. :columns-height="125"
  44. :immediate-change="immediateChange"
  45. @change="handleTimeChange"
  46. @pickstart="handlePickStart"
  47. @pickend="handlePickEnd"
  48. />
  49. </view>
  50. </view>
  51. </view>
  52. </template>
  53. <script lang="ts">
  54. export default {
  55. options: {
  56. addGlobalClass: true,
  57. virtualHost: true,
  58. styleIsolation: 'shared'
  59. }
  60. }
  61. </script>
  62. <script lang="ts" setup>
  63. import wdPickerView from '../../wd-picker-view/wd-picker-view.vue'
  64. import { computed, ref, watch, onMounted } from 'vue'
  65. import { debounce, isArray, isEqual, isNumber, pause } from '../../common/util'
  66. import { compareMonth, formatMonthTitle, getMonthEndDay, getMonths, getTimeData, getWeekLabel } from '../utils'
  67. import Month from '../month/month.vue'
  68. import { monthPanelProps, type MonthInfo, type MonthPanelTimeType, type MonthPanelExpose } from './types'
  69. import { useTranslate } from '../../composables/useTranslate'
  70. import type { CalendarItem } from '../types'
  71. const props = defineProps(monthPanelProps)
  72. const emit = defineEmits(['change', 'pickstart', 'pickend'])
  73. const { translate } = useTranslate('calendar-view')
  74. const scrollTop = ref<number>(0) // 滚动位置
  75. const scrollIndex = ref<number>(0) // 当前显示的月份索引
  76. const timeValue = ref<number[]>([]) // 当前选中的时分秒
  77. const timeType = ref<MonthPanelTimeType>('') // 当前时间类型,是开始还是结束
  78. const innerValue = ref<string | number | (number | null)[]>('') // 内部保存一个值,用于判断新老值,避免监听器触发
  79. const handleChange = debounce((value) => {
  80. emit('change', {
  81. value
  82. })
  83. }, 50)
  84. // 时间picker的列数据
  85. const timeData = computed<Array<CalendarItem[]>>(() => {
  86. let timeColumns: Array<CalendarItem[]> = []
  87. if (props.type === 'datetime' && isNumber(props.value)) {
  88. const date = new Date(props.value)
  89. date.setHours(timeValue.value[0])
  90. date.setMinutes(timeValue.value[1])
  91. date.setSeconds(props.hideSecond ? 0 : timeValue.value[2])
  92. const dateTime = date.getTime()
  93. timeColumns = getTime(dateTime) || []
  94. } else if (isArray(props.value) && props.type === 'datetimerange') {
  95. const [start, end] = props.value!
  96. const dataValue = timeType.value === 'start' ? start : end
  97. const date = new Date(dataValue || '')
  98. date.setHours(timeValue.value[0])
  99. date.setMinutes(timeValue.value[1])
  100. date.setSeconds(props.hideSecond ? 0 : timeValue.value[2])
  101. const dateTime = date.getTime()
  102. const finalValue = [start, end]
  103. if (timeType.value === 'start') {
  104. finalValue[0] = dateTime
  105. } else {
  106. finalValue[1] = dateTime
  107. }
  108. timeColumns = getTime(finalValue, timeType.value) || []
  109. }
  110. return timeColumns
  111. })
  112. // 标题
  113. const title = computed(() => {
  114. return formatMonthTitle(months.value[scrollIndex.value].date)
  115. })
  116. // 周标题
  117. const weekLabel = computed(() => {
  118. return (index: number) => {
  119. return getWeekLabel(index - 1)
  120. }
  121. })
  122. // 滚动区域的高度
  123. const scrollHeight = computed(() => {
  124. const scrollHeight: number = timeType.value ? props.panelHeight - 125 : props.panelHeight
  125. return scrollHeight
  126. })
  127. // 月份日期和月份高度
  128. const months = computed<MonthInfo[]>(() => {
  129. return getMonths(props.minDate, props.maxDate).map((month, index) => {
  130. const offset = (7 + new Date(month).getDay() - props.firstDayOfWeek) % 7
  131. const totalDay = getMonthEndDay(new Date(month).getFullYear(), new Date(month).getMonth() + 1)
  132. const rows = Math.ceil((offset + totalDay) / 7)
  133. return {
  134. height: rows * 64 + (rows - 1) * 4 + (index === 0 ? 0 : 45), // 每行64px高度,除最后一行外每行加4px margin,加上标题45px
  135. date: month
  136. }
  137. })
  138. })
  139. watch(
  140. () => props.type,
  141. (val) => {
  142. if (
  143. (val === 'datetime' && props.value) ||
  144. (val === 'datetimerange' && isArray(props.value) && props.value && props.value.length > 0 && props.value[0])
  145. ) {
  146. setTime(props.value, 'start')
  147. }
  148. },
  149. {
  150. deep: true,
  151. immediate: true
  152. }
  153. )
  154. watch(
  155. () => props.value,
  156. (val) => {
  157. if (isEqual(val, innerValue.value)) return
  158. if ((props.type === 'datetime' && val) || (props.type === 'datetimerange' && val && isArray(val) && val.length > 0 && val[0])) {
  159. setTime(val, 'start')
  160. }
  161. },
  162. {
  163. deep: true,
  164. immediate: true
  165. }
  166. )
  167. onMounted(() => {
  168. scrollIntoView()
  169. })
  170. /**
  171. * 使当前日期或者选中日期滚动到可视区域
  172. */
  173. async function scrollIntoView() {
  174. // 等待渲染完毕
  175. await pause()
  176. let activeDate: number | null = 0
  177. if (isArray(props.value)) {
  178. // 对数组按时间排序,取第一个值
  179. const sortedValue = [...props.value].sort((a, b) => (a || 0) - (b || 0))
  180. activeDate = sortedValue[0]
  181. } else if (isNumber(props.value)) {
  182. activeDate = props.value
  183. }
  184. if (!activeDate) {
  185. activeDate = Date.now()
  186. }
  187. let top: number = 0
  188. let activeMonthIndex = -1
  189. for (let index = 0; index < months.value.length; index++) {
  190. if (compareMonth(months.value[index].date, activeDate) === 0) {
  191. activeMonthIndex = index
  192. // 找到选中月份后,计算选中日期在月份中的位置
  193. const date = new Date(activeDate)
  194. const day = date.getDate()
  195. const firstDay = new Date(date.getFullYear(), date.getMonth(), 1)
  196. const offset = (7 + firstDay.getDay() - props.firstDayOfWeek) % 7
  197. const row = Math.floor((offset + day - 1) / 7)
  198. // 每行高度64px,每行加4px margin
  199. top += row * 64 + row * 4
  200. break
  201. }
  202. top += months.value[index] ? Number(months.value[index].height) : 0
  203. }
  204. scrollTop.value = 0
  205. if (top > 0) {
  206. await pause()
  207. // 如果不是第一个月才加45
  208. scrollTop.value = top + (activeMonthIndex > 0 ? 45 : 0)
  209. }
  210. }
  211. /**
  212. * 获取时间 picker 的数据
  213. * @param {timestamp|array} value 当前时间
  214. * @param {string} type 类型,是开始还是结束
  215. */
  216. function getTime(value: number | (number | null)[], type?: string) {
  217. if (props.type === 'datetime') {
  218. return getTimeData({
  219. date: value as number,
  220. minDate: props.minDate,
  221. maxDate: props.maxDate,
  222. filter: props.timeFilter,
  223. isHideSecond: props.hideSecond
  224. })
  225. } else {
  226. if (type === 'start' && isArray(props.value)) {
  227. return getTimeData({
  228. date: (value as Array<number>)[0],
  229. minDate: props.minDate,
  230. maxDate: props.value[1] ? props.value[1] : props.maxDate,
  231. filter: props.timeFilter,
  232. isHideSecond: props.hideSecond
  233. })
  234. } else {
  235. return getTimeData({
  236. date: (value as Array<number>)[1],
  237. minDate: (value as Array<number>)[0],
  238. maxDate: props.maxDate,
  239. filter: props.timeFilter,
  240. isHideSecond: props.hideSecond
  241. })
  242. }
  243. }
  244. }
  245. /**
  246. * 获取 date 的时分秒
  247. * @param {timestamp} date 时间
  248. * @param {string} type 类型,是开始还是结束
  249. */
  250. function getTimeValue(date: number | (number | null)[], type: MonthPanelTimeType) {
  251. let dateValue: Date = new Date()
  252. if (props.type === 'datetime') {
  253. dateValue = new Date(date as number)
  254. } else if (isArray(date)) {
  255. if (type === 'start') {
  256. dateValue = new Date(date[0] || '')
  257. } else {
  258. dateValue = new Date(date[1] || '')
  259. }
  260. }
  261. const hour = dateValue.getHours()
  262. const minute = dateValue.getMinutes()
  263. const second = dateValue.getSeconds()
  264. return props.hideSecond ? [hour, minute] : [hour, minute, second]
  265. }
  266. function setTime(value: number | (number | null)[], type?: MonthPanelTimeType) {
  267. if (isArray(value) && value[0] && value[1] && type === 'start' && timeType.value === 'start') {
  268. type = 'end'
  269. }
  270. timeType.value = type || ''
  271. timeValue.value = getTimeValue(value, type || '')
  272. }
  273. function handleDateChange({ value, type }: { value: number | (number | null)[]; type?: MonthPanelTimeType }) {
  274. if (!isEqual(value, props.value)) {
  275. // 内部保存一个值,用于判断新老值,避免监听器触发
  276. innerValue.value = value
  277. handleChange(value)
  278. }
  279. // datetime 和 datetimerange 类型,需要计算 timeData 并做展示
  280. if (props.type.indexOf('time') > -1) {
  281. setTime(value, type)
  282. }
  283. }
  284. function handleTimeChange({ value }: { value: any[] }) {
  285. if (!props.value) {
  286. return
  287. }
  288. if (props.type === 'datetime' && isNumber(props.value)) {
  289. const date = new Date(props.value)
  290. date.setHours(value[0])
  291. date.setMinutes(value[1])
  292. date.setSeconds(props.hideSecond ? 0 : value[2])
  293. const dateTime = date.getTime()
  294. handleChange(dateTime)
  295. } else if (isArray(props.value) && props.type === 'datetimerange') {
  296. const [start, end] = props.value!
  297. const dataValue = timeType.value === 'start' ? start : end
  298. const date = new Date(dataValue || '')
  299. date.setHours(value[0])
  300. date.setMinutes(value[1])
  301. date.setSeconds(props.hideSecond ? 0 : value[2])
  302. const dateTime = date.getTime()
  303. if (dateTime === dataValue) return
  304. const finalValue = [start, end]
  305. if (timeType.value === 'start') {
  306. finalValue[0] = dateTime
  307. } else {
  308. finalValue[1] = dateTime
  309. }
  310. innerValue.value = finalValue // 内部保存一个值,用于判断新老值,避免监听器触发
  311. handleChange(finalValue)
  312. }
  313. }
  314. function handlePickStart() {
  315. emit('pickstart')
  316. }
  317. function handlePickEnd() {
  318. emit('pickend')
  319. }
  320. const monthScroll = (event: { detail: { scrollTop: number } }) => {
  321. if (months.value.length <= 1) {
  322. return
  323. }
  324. const scrollTop = Math.max(0, event.detail.scrollTop)
  325. doSetSubtitle(scrollTop)
  326. }
  327. /**
  328. * 设置小标题
  329. * scrollTop 滚动条位置
  330. */
  331. function doSetSubtitle(scrollTop: number) {
  332. let height: number = 0 // 月份高度和
  333. for (let index = 0; index < months.value.length; index++) {
  334. height = height + months.value[index].height
  335. if (scrollTop < height) {
  336. scrollIndex.value = index
  337. return
  338. }
  339. }
  340. }
  341. defineExpose<MonthPanelExpose>({
  342. scrollIntoView
  343. })
  344. </script>
  345. <style lang="scss" scoped>
  346. @import './index.scss';
  347. </style>