wd-calendar.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. <template>
  2. <view :class="`wd-calendar ${customClass}`">
  3. <template v-if="withCell">
  4. <wd-cell
  5. v-if="!$slots.default"
  6. :title="label"
  7. :value="showValue || placeholder || translate('placeholder')"
  8. :required="required"
  9. :size="size"
  10. :title-width="labelWidth"
  11. :prop="prop"
  12. :rules="rules"
  13. :clickable="!disabled && !readonly"
  14. :value-align="alignRight ? 'right' : 'left'"
  15. :center="center"
  16. :custom-class="cellClass"
  17. :custom-style="customStyle"
  18. :custom-title-class="customLabelClass"
  19. :custom-value-class="customValueClass"
  20. :ellipsis="ellipsis"
  21. :use-title-slot="!!$slots.label"
  22. :marker-side="markerSide"
  23. @click="open"
  24. >
  25. <template #title v-if="$slots.label">
  26. <slot name="label"></slot>
  27. </template>
  28. <template #right-icon>
  29. <wd-icon v-if="showArrow" custom-class="wd-calendar__arrow" name="arrow-right" />
  30. <view v-else-if="showClear" @click.stop="handleClear">
  31. <wd-icon custom-class="wd-calendar__clear" name="error-fill" />
  32. </view>
  33. </template>
  34. </wd-cell>
  35. <view v-else @click="open">
  36. <slot></slot>
  37. </view>
  38. </template>
  39. <wd-action-sheet
  40. v-model="pickerShow"
  41. :duration="250"
  42. :close-on-click-modal="closeOnClickModal"
  43. :safe-area-inset-bottom="safeAreaInsetBottom"
  44. :z-index="zIndex"
  45. :root-portal="rootPortal"
  46. @close="close"
  47. >
  48. <view class="wd-calendar__header">
  49. <view v-if="!showTypeSwitch && shortcuts.length === 0" class="wd-calendar__title">{{ title || translate('title') }}</view>
  50. <view v-if="showTypeSwitch" class="wd-calendar__tabs">
  51. <wd-tabs ref="calendarTabs" v-model="currentTab" @change="handleTypeChange">
  52. <wd-tab :title="translate('day')" :name="translate('day')" />
  53. <wd-tab :title="translate('week')" :name="translate('week')" />
  54. <wd-tab :title="translate('month')" :name="translate('month')" />
  55. </wd-tabs>
  56. </view>
  57. <view v-if="shortcuts.length > 0" class="wd-calendar__shortcuts">
  58. <wd-tag
  59. v-for="(item, index) in shortcuts"
  60. :key="index"
  61. custom-class="wd-calendar__tag"
  62. type="primary"
  63. plain
  64. round
  65. @click="handleShortcutClick(index)"
  66. >
  67. {{ item.text }}
  68. </wd-tag>
  69. </view>
  70. <wd-icon custom-class="wd-calendar__close" name="add" @click="close" />
  71. </view>
  72. <view
  73. v-if="inited"
  74. :class="`wd-calendar__view ${currentType.indexOf('range') > -1 ? 'is-range' : ''} ${showConfirm ? 'is-show-confirm' : ''}`"
  75. >
  76. <view v-if="range(type)" :class="`wd-calendar__range-label ${type === 'monthrange' ? 'is-monthrange' : ''}`">
  77. <view
  78. :class="`wd-calendar__range-label-item ${!calendarValue || !isArray(calendarValue) || !calendarValue[0] ? 'is-placeholder' : ''}`"
  79. style="text-align: right"
  80. >
  81. {{ rangeLabel[0] }}
  82. </view>
  83. <view class="wd-calendar__range-sperator">/</view>
  84. <view :class="`wd-calendar__range-label-item ${!calendarValue || !isArray(calendarValue) || !calendarValue[1] ? 'is-placeholder' : ''}`">
  85. {{ rangeLabel[1] }}
  86. </view>
  87. </view>
  88. <wd-calendar-view
  89. ref="calendarView"
  90. v-model="calendarValue"
  91. :type="currentType"
  92. :min-date="minDate"
  93. :max-date="maxDate"
  94. :first-day-of-week="firstDayOfWeek"
  95. :formatter="formatter"
  96. :panel-height="panelHeight"
  97. :max-range="maxRange"
  98. :range-prompt="rangePrompt"
  99. :allow-same-day="allowSameDay"
  100. :default-time="defaultTime"
  101. :time-filter="timeFilter"
  102. :hide-second="hideSecond"
  103. :show-panel-title="!range(type)"
  104. :immediate-change="immediateChange"
  105. @change="handleChange"
  106. />
  107. </view>
  108. <view v-if="showConfirm" class="wd-calendar__confirm">
  109. <wd-button block :disabled="confirmBtnDisabled" @click="handleConfirm">{{ confirmText || translate('confirm') }}</wd-button>
  110. </view>
  111. </wd-action-sheet>
  112. </view>
  113. </template>
  114. <script lang="ts">
  115. export default {
  116. name: 'wd-calendar',
  117. options: {
  118. addGlobalClass: true,
  119. virtualHost: true,
  120. styleIsolation: 'shared'
  121. }
  122. }
  123. </script>
  124. <script lang="ts" setup>
  125. import wdIcon from '../wd-icon/wd-icon.vue'
  126. import wdCalendarView from '../wd-calendar-view/wd-calendar-view.vue'
  127. import wdActionSheet from '../wd-action-sheet/wd-action-sheet.vue'
  128. import wdButton from '../wd-button/wd-button.vue'
  129. import wdCell from '../wd-cell/wd-cell.vue'
  130. import { ref, computed, watch } from 'vue'
  131. import dayjs from '../../dayjs'
  132. import { deepClone, isArray, isEqual, padZero, pause } from '../common/util'
  133. import { getWeekNumber, isRange } from '../wd-calendar-view/utils'
  134. import { FORM_KEY, type FormItemRule } from '../wd-form/types'
  135. import { useParent } from '../composables/useParent'
  136. import { useTranslate } from '../composables/useTranslate'
  137. import { calendarProps, type CalendarExpose } from './types'
  138. import type { CalendarType } from '../wd-calendar-view/types'
  139. const { translate } = useTranslate('calendar')
  140. const defaultDisplayFormat = (value: number | number[], type: CalendarType): string => {
  141. switch (type) {
  142. case 'date':
  143. return dayjs(value as number).format('YYYY-MM-DD')
  144. case 'dates':
  145. return (value as number[])
  146. .map((item) => {
  147. return dayjs(item).format('YYYY-MM-DD')
  148. })
  149. .join(', ')
  150. case 'daterange':
  151. return `${(value as number[])[0] ? dayjs((value as number[])[0]).format('YYYY-MM-DD') : translate('startTime')} ${translate('to')} ${
  152. (value as number[])[1] ? dayjs((value as number[])[1]).format('YYYY-MM-DD') : translate('endTime')
  153. }`
  154. case 'datetime':
  155. return dayjs(value as number).format('YYYY-MM-DD HH:mm:ss')
  156. case 'datetimerange':
  157. return `${(value as number[])[0] ? dayjs((value as number[])[0]).format(translate('timeFormat')) : translate('startTime')} ${translate(
  158. 'to'
  159. )}\n${(value as number[])[1] ? dayjs((value as number[])[1]).format(translate('timeFormat')) : translate('endTime')}`
  160. case 'week': {
  161. const date = new Date(value as number)
  162. const year = date.getFullYear()
  163. const week = getWeekNumber(value as number)
  164. const weekStart = new Date(date)
  165. weekStart.setDate(date.getDate() - date.getDay() + 1)
  166. const weekEnd = new Date(date)
  167. weekEnd.setDate(date.getDate() + (7 - date.getDay()))
  168. const adjustedYear = weekEnd.getFullYear() > year ? weekEnd.getFullYear() : year
  169. return translate('weekFormat', adjustedYear, padZero(week))
  170. }
  171. case 'weekrange': {
  172. const date1 = new Date((value as number[])[0])
  173. const date2 = new Date((value as number[])[1])
  174. const year1 = date1.getFullYear()
  175. const year2 = date2.getFullYear()
  176. const week1 = getWeekNumber((value as number[])[0])
  177. const week2 = getWeekNumber((value as number[])[1])
  178. const weekStart1 = new Date(date1)
  179. weekStart1.setDate(date1.getDate() - date1.getDay() + 1)
  180. const weekEnd1 = new Date(date1)
  181. weekEnd1.setDate(date1.getDate() + (7 - date1.getDay()))
  182. const weekStart2 = new Date(date2)
  183. weekStart2.setDate(date2.getDate() - date2.getDay() + 1)
  184. const weekEnd2 = new Date(date2)
  185. weekEnd2.setDate(date2.getDate() + (7 - date2.getDay()))
  186. const adjustedYear1 = weekEnd1.getFullYear() > year1 ? weekEnd1.getFullYear() : year1
  187. const adjustedYear2 = weekEnd2.getFullYear() > year2 ? weekEnd2.getFullYear() : year2
  188. return `${(value as number[])[0] ? translate('weekFormat', adjustedYear1, padZero(week1)) : translate('startWeek')} - ${
  189. (value as number[])[1] ? translate('weekFormat', adjustedYear2, padZero(week2)) : translate('endWeek')
  190. }`
  191. }
  192. case 'month':
  193. return dayjs(value as number).format('YYYY / MM')
  194. case 'monthrange':
  195. return `${(value as number[])[0] ? dayjs((value as number[])[0]).format('YYYY / MM') : translate('startMonth')} ${translate('to')} ${
  196. (value as number[])[1] ? dayjs((value as number[])[1]).format('YYYY / MM') : translate('endMonth')
  197. }`
  198. }
  199. }
  200. const formatRange = (value: number, rangeType: 'start' | 'end', type: CalendarType) => {
  201. switch (type) {
  202. case 'daterange':
  203. if (!value) {
  204. return rangeType === 'end' ? translate('endTime') : translate('startTime')
  205. }
  206. return dayjs(value).format(translate('dateFormat'))
  207. case 'datetimerange':
  208. if (!value) {
  209. return rangeType === 'end' ? translate('endTime') : translate('startTime')
  210. }
  211. return dayjs(value).format(translate('timeFormat'))
  212. case 'weekrange': {
  213. if (!value) {
  214. return rangeType === 'end' ? translate('endWeek') : translate('startWeek')
  215. }
  216. const date = new Date(value)
  217. const year = date.getFullYear()
  218. const week = getWeekNumber(value)
  219. return translate('weekFormat', year, padZero(week))
  220. }
  221. case 'monthrange':
  222. if (!value) {
  223. return rangeType === 'end' ? translate('endMonth') : translate('startMonth')
  224. }
  225. return dayjs(value).format(translate('monthFormat'))
  226. }
  227. }
  228. const props = defineProps(calendarProps)
  229. const emit = defineEmits(['cancel', 'change', 'update:modelValue', 'confirm', 'open', 'clear'])
  230. const pickerShow = ref<boolean>(false)
  231. const calendarValue = ref<null | number | number[]>(null)
  232. const lastCalendarValue = ref<null | number | number[]>(null)
  233. const panelHeight = ref<number>(338)
  234. const confirmBtnDisabled = ref<boolean>(true)
  235. const currentTab = ref<number>(0)
  236. const lastTab = ref<number>(0)
  237. const currentType = ref<CalendarType>('date')
  238. const lastCurrentType = ref<CalendarType>()
  239. const inited = ref<boolean>(false)
  240. const calendarView = ref()
  241. const calendarTabs = ref()
  242. const rangeLabel = computed(() => {
  243. const [start, end] = deepClone(isArray(calendarValue.value) ? calendarValue.value : [])
  244. return [start, end].map((item, index) => {
  245. return (props.innerDisplayFormat || formatRange)(item, index === 0 ? 'start' : 'end', currentType.value)
  246. })
  247. })
  248. const showValue = computed(() => {
  249. if ((!isArray(props.modelValue) && props.modelValue) || (isArray(props.modelValue) && props.modelValue.length)) {
  250. return (props.displayFormat || defaultDisplayFormat)(props.modelValue, lastCurrentType.value || currentType.value)
  251. } else {
  252. return ''
  253. }
  254. })
  255. const cellClass = computed(() => {
  256. const classes = ['wd-calendar__cell']
  257. if (props.disabled) classes.push('is-disabled')
  258. if (props.readonly) classes.push('is-readonly')
  259. if (props.error) classes.push('is-error')
  260. if (!showValue.value) classes.push('wd-calendar__cell--placeholder')
  261. return classes.join(' ')
  262. })
  263. watch(
  264. () => props.modelValue,
  265. (val, oldVal) => {
  266. if (isEqual(val, oldVal)) return
  267. calendarValue.value = deepClone(val)
  268. confirmBtnDisabled.value = getConfirmBtnStatus(val)
  269. },
  270. {
  271. immediate: true
  272. }
  273. )
  274. watch(
  275. () => props.type,
  276. (newValue, oldValue) => {
  277. if (props.showTypeSwitch) {
  278. const tabs = ['date', 'week', 'month']
  279. const rangeTabs = ['daterange', 'weekrange', 'monthrange']
  280. const index = newValue.indexOf('range') > -1 ? rangeTabs.indexOf(newValue) || 0 : tabs.indexOf(newValue)
  281. currentTab.value = index
  282. }
  283. panelHeight.value = props.showConfirm ? 338 : 400
  284. currentType.value = deepClone(newValue)
  285. },
  286. {
  287. deep: true,
  288. immediate: true
  289. }
  290. )
  291. watch(
  292. () => props.showConfirm,
  293. (val) => {
  294. panelHeight.value = val ? 338 : 400
  295. },
  296. {
  297. deep: true,
  298. immediate: true
  299. }
  300. )
  301. const range = computed(() => {
  302. return (type: CalendarType) => {
  303. return isRange(type)
  304. }
  305. })
  306. // 是否展示清除按钮
  307. const showClear = computed(() => {
  308. return props.clearable && !props.disabled && !props.readonly && showValue.value.length > 0
  309. })
  310. // 是否展示箭头
  311. const showArrow = computed(() => {
  312. return !props.disabled && !props.readonly && !showClear.value
  313. })
  314. function handleClear() {
  315. emit('clear')
  316. emit('update:modelValue', null)
  317. }
  318. function scrollIntoView() {
  319. calendarView.value && calendarView.value && calendarView.value.$.exposed.scrollIntoView()
  320. }
  321. // 对外暴露方法
  322. async function open() {
  323. const { disabled, readonly } = props
  324. if (disabled || readonly) return
  325. inited.value = true
  326. pickerShow.value = true
  327. lastCalendarValue.value = deepClone(calendarValue.value)
  328. lastTab.value = currentTab.value
  329. lastCurrentType.value = currentType.value
  330. // 等待渲染完毕
  331. await pause()
  332. scrollIntoView()
  333. setTimeout(() => {
  334. if (props.showTypeSwitch) {
  335. calendarTabs.value.scrollIntoView()
  336. calendarTabs.value.updateLineStyle(false)
  337. }
  338. }, 250)
  339. emit('open')
  340. }
  341. // 对外暴露方法
  342. function close() {
  343. pickerShow.value = false
  344. setTimeout(() => {
  345. calendarValue.value = deepClone(lastCalendarValue.value)
  346. currentTab.value = lastTab.value
  347. currentType.value = lastCurrentType.value || 'date'
  348. confirmBtnDisabled.value = getConfirmBtnStatus(lastCalendarValue.value)
  349. }, 250)
  350. emit('cancel')
  351. }
  352. function handleTypeChange({ index }: { index: number }) {
  353. const tabs = ['date', 'week', 'month']
  354. const rangeTabs = ['daterange', 'weekrange', 'monthrange']
  355. const type = props.type.indexOf('range') > -1 ? rangeTabs[index] : tabs[index]
  356. currentTab.value = index
  357. currentType.value = type as CalendarType
  358. }
  359. function getConfirmBtnStatus(value: number | number[] | null) {
  360. let confirmBtnDisabled = false
  361. // 范围选择未选择满,或者多日期选择未选择日期,按钮置灰不可点击
  362. if (
  363. (props.type.indexOf('range') > -1 && (!isArray(value) || !value[0] || !value[1] || !value)) ||
  364. (props.type === 'dates' && (!isArray(value) || value.length === 0 || !value)) ||
  365. !value
  366. ) {
  367. confirmBtnDisabled = true
  368. }
  369. return confirmBtnDisabled
  370. }
  371. function handleChange({ value }: { value: number | number[] | null }) {
  372. calendarValue.value = deepClone(value)
  373. confirmBtnDisabled.value = getConfirmBtnStatus(value)
  374. emit('change', {
  375. value
  376. })
  377. if (!props.showConfirm && !confirmBtnDisabled.value) {
  378. handleConfirm()
  379. }
  380. }
  381. function handleConfirm() {
  382. if (props.beforeConfirm) {
  383. props.beforeConfirm({
  384. value: calendarValue.value,
  385. resolve: (isPass: boolean) => {
  386. isPass && onConfirm()
  387. }
  388. })
  389. } else {
  390. onConfirm()
  391. }
  392. }
  393. function onConfirm() {
  394. pickerShow.value = false
  395. lastCurrentType.value = currentType.value
  396. emit('update:modelValue', calendarValue.value)
  397. emit('confirm', {
  398. value: calendarValue.value,
  399. type: currentType.value
  400. })
  401. }
  402. function handleShortcutClick(index: number) {
  403. if (props.onShortcutsClick && typeof props.onShortcutsClick === 'function') {
  404. calendarValue.value = deepClone(
  405. props.onShortcutsClick({
  406. item: props.shortcuts[index],
  407. index
  408. })
  409. )
  410. confirmBtnDisabled.value = getConfirmBtnStatus(calendarValue.value)
  411. }
  412. if (!props.showConfirm) {
  413. handleConfirm()
  414. }
  415. }
  416. defineExpose<CalendarExpose>({
  417. close,
  418. open
  419. })
  420. </script>
  421. <style lang="scss" scoped>
  422. @import './index.scss';
  423. </style>