month.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. <template>
  2. <view class="u-calendar-month-wrapper" ref="u-calendar-month-wrapper">
  3. <view
  4. v-for="(item, index) in months"
  5. :key="index"
  6. :class="[`u-calendar-month-${index}`]"
  7. :ref="`u-calendar-month-${index}`"
  8. :id="`month-${index}`"
  9. >
  10. <text v-if="index !== 0" class="u-calendar-month__title"
  11. >{{ item.year }}年{{ item.month }}月</text
  12. >
  13. <view class="u-calendar-month__days">
  14. <view
  15. v-if="showMark"
  16. class="u-calendar-month__days__month-mark-wrapper"
  17. >
  18. <text class="u-calendar-month__days__month-mark-wrapper__text">{{
  19. item.month
  20. }}</text>
  21. </view>
  22. <view
  23. class="u-calendar-month__days__day"
  24. v-for="(item1, index1) in item.date"
  25. :key="index1"
  26. :style="[dayStyle(index, index1, item1)]"
  27. @tap="clickHandler(index, index1, item1)"
  28. :class="[
  29. item1.selected && 'u-calendar-month__days__day__select--selected'
  30. ]"
  31. >
  32. <view
  33. class="u-calendar-month__days__day__select"
  34. :style="[daySelectStyle(index, index1, item1)]"
  35. >
  36. <text
  37. class="u-calendar-month__days__day__select__info"
  38. :class="[
  39. item1.disabled &&
  40. 'u-calendar-month__days__day__select__info--disabled'
  41. ]"
  42. :style="[textStyle(item1)]"
  43. >{{ item1.day }}</text
  44. >
  45. <text
  46. v-if="getBottomInfo(index, index1, item1)"
  47. class="u-calendar-month__days__day__select__buttom-info"
  48. :class="[
  49. item1.disabled &&
  50. 'u-calendar-month__days__day__select__buttom-info--disabled'
  51. ]"
  52. :style="[textStyle(item1)]"
  53. >{{ getBottomInfo(index, index1, item1) }}</text
  54. >
  55. <text
  56. v-if="item1.dot"
  57. class="u-calendar-month__days__day__select__dot"
  58. ></text>
  59. </view>
  60. </view>
  61. </view>
  62. </view>
  63. </view>
  64. </template>
  65. <script>
  66. // #ifdef APP-NVUE
  67. // 由于nvue不支持百分比单位,需要查询宽度来计算每个日期的宽度
  68. const dom = uni.requireNativePlugin('dom')
  69. // #endif
  70. import dayjs from '../../libs/util/dayjs.js'
  71. export default {
  72. name: 'u-calendar-month',
  73. mixins: [uni.$u.mpMixin, uni.$u.mixin],
  74. props: {
  75. // 是否显示月份背景色
  76. showMark: {
  77. type: Boolean,
  78. default: true
  79. },
  80. // 主题色,对底部按钮和选中日期有效
  81. color: {
  82. type: String,
  83. default: '#3c9cff'
  84. },
  85. // 月份数据
  86. months: {
  87. type: Array,
  88. default: () => []
  89. },
  90. // 日期选择类型
  91. mode: {
  92. type: String,
  93. default: 'single'
  94. },
  95. // 日期行高
  96. rowHeight: {
  97. type: [String, Number],
  98. default: 58
  99. },
  100. // mode=multiple时,最多可选多少个日期
  101. maxCount: {
  102. type: [String, Number],
  103. default: Infinity
  104. },
  105. // mode=range时,第一个日期底部的提示文字
  106. startText: {
  107. type: String,
  108. default: '开始'
  109. },
  110. // mode=range时,最后一个日期底部的提示文字
  111. endText: {
  112. type: String,
  113. default: '结束'
  114. },
  115. // 默认选中的日期,mode为multiple或range是必须为数组格式
  116. defaultDate: {
  117. type: [Array, String, Date],
  118. default: null
  119. },
  120. // 最小的可选日期
  121. minDate: {
  122. type: [String, Number],
  123. default: 0
  124. },
  125. // 最大可选日期
  126. maxDate: {
  127. type: [String, Number],
  128. default: 0
  129. },
  130. // 如果没有设置maxDate,则往后推多少个月
  131. maxMonth: {
  132. type: [String, Number],
  133. default: 2
  134. },
  135. // 是否为只读状态,只读状态下禁止选择日期
  136. readonly: {
  137. type: Boolean,
  138. default: uni.$u.props.calendar.readonly
  139. },
  140. // 日期区间最多可选天数,默认无限制,mode = range时有效
  141. maxRange: {
  142. type: [Number, String],
  143. default: Infinity
  144. },
  145. // 范围选择超过最多可选天数时的提示文案,mode = range时有效
  146. rangePrompt: {
  147. type: String,
  148. default: ''
  149. },
  150. // 范围选择超过最多可选天数时,是否展示提示文案,mode = range时有效
  151. showRangePrompt: {
  152. type: Boolean,
  153. default: true
  154. },
  155. // 是否允许日期范围的起止时间为同一天,mode = range时有效
  156. allowSameDay: {
  157. type: Boolean,
  158. default: false
  159. }
  160. },
  161. data () {
  162. return {
  163. // 每个日期的宽度
  164. width: 0,
  165. // 当前选中的日期item
  166. item: {},
  167. selected: []
  168. }
  169. },
  170. watch: {
  171. selectedChange: {
  172. immediate: true,
  173. handler (n) {
  174. this.setDefaultDate()
  175. }
  176. }
  177. },
  178. computed: {
  179. // 多个条件的变化,会引起选中日期的变化,这里统一管理监听
  180. selectedChange () {
  181. return [this.minDate, this.maxDate, this.defaultDate]
  182. },
  183. dayStyle (index1, index2, item) {
  184. return (index1, index2, item) => {
  185. const style = {}
  186. let week = item.week
  187. // 不进行四舍五入的形式保留2位小数
  188. const dayWidth = Number(
  189. parseFloat(this.width / 7)
  190. .toFixed(3)
  191. .slice(0, -1)
  192. )
  193. // 得出每个日期的宽度
  194. // #ifdef APP-NVUE
  195. style.width = uni.$u.addUnit(dayWidth)
  196. // #endif
  197. style.height = uni.$u.addUnit(this.rowHeight)
  198. if (index2 === 0) {
  199. // 获取当前为星期几,如果为0,则为星期天,减一为每月第一天时,需要向左偏移的item个数
  200. week = (week === 0 ? 7 : week) - 1
  201. style.marginLeft = uni.$u.addUnit(week * dayWidth)
  202. }
  203. if (this.mode === 'range') {
  204. // 之所以需要这么写,是因为DCloud公司的iOS客户端的开发者能力有限导致的bug
  205. style.paddingLeft = 0
  206. style.paddingRight = 0
  207. style.paddingBottom = 0
  208. style.paddingTop = 0
  209. }
  210. return style
  211. }
  212. },
  213. daySelectStyle () {
  214. return (index1, index2, item) => {
  215. let date = dayjs(item.date).format('YYYY-MM-DD'),
  216. style = {}
  217. // 判断date是否在selected数组中,因为月份可能会需要补0,所以使用dateSame判断,而不用数组的includes判断
  218. if (this.selected.some(item => this.dateSame(item, date))) {
  219. style.backgroundColor = this.color
  220. }
  221. if (this.mode === 'single') {
  222. if (date === this.selected[0]) {
  223. // 因为需要对nvue的兼容,只能这么写,无法缩写,也无法通过类名控制等等
  224. style.borderTopLeftRadius = '3px'
  225. style.borderBottomLeftRadius = '3px'
  226. style.borderTopRightRadius = '3px'
  227. style.borderBottomRightRadius = '3px'
  228. }
  229. } else if (this.mode === 'range') {
  230. if (this.selected.length >= 2) {
  231. const len = this.selected.length - 1
  232. // 第一个日期设置左上角和左下角的圆角
  233. if (this.dateSame(date, this.selected[0])) {
  234. style.borderTopLeftRadius = '3px'
  235. style.borderBottomLeftRadius = '3px'
  236. }
  237. // 最后一个日期设置右上角和右下角的圆角
  238. if (this.dateSame(date, this.selected[len])) {
  239. style.borderTopRightRadius = '3px'
  240. style.borderBottomRightRadius = '3px'
  241. }
  242. // 处于第一和最后一个之间的日期,背景色设置为浅色,通过将对应颜色进行等分,再取其尾部的颜色值
  243. if (
  244. dayjs(date).isAfter(dayjs(this.selected[0])) &&
  245. dayjs(date).isBefore(dayjs(this.selected[len]))
  246. ) {
  247. style.backgroundColor = uni.$u.colorGradient(
  248. this.color,
  249. '#ffffff',
  250. 100
  251. )[90]
  252. // 增加一个透明度,让范围区间的背景色也能看到底部的mark水印字符
  253. style.opacity = 0.7
  254. }
  255. } else if (this.selected.length === 1) {
  256. // 之所以需要这么写,是因为DCloud公司的iOS客户端的开发者能力有限导致的bug
  257. // 进行还原操作,否则在nvue的iOS,uni-app有bug,会导致诡异的表现
  258. style.borderTopLeftRadius = '3px'
  259. style.borderBottomLeftRadius = '3px'
  260. }
  261. } else {
  262. if (this.selected.some(item => this.dateSame(item, date))) {
  263. style.borderTopLeftRadius = '3px'
  264. style.borderBottomLeftRadius = '3px'
  265. style.borderTopRightRadius = '3px'
  266. style.borderBottomRightRadius = '3px'
  267. }
  268. }
  269. return style
  270. }
  271. },
  272. // 某个日期是否被选中
  273. textStyle () {
  274. return item => {
  275. const date = dayjs(item.date).format('YYYY-MM-DD'),
  276. style = {}
  277. // 选中的日期,提示文字设置白色
  278. if (this.selected.some(item => this.dateSame(item, date))) {
  279. style.color = '#ffffff'
  280. }
  281. if (this.mode === 'range') {
  282. const len = this.selected.length - 1
  283. // 如果是范围选择模式,第一个和最后一个之间的日期,文字颜色设置为高亮的主题色
  284. if (
  285. dayjs(date).isAfter(dayjs(this.selected[0])) &&
  286. dayjs(date).isBefore(dayjs(this.selected[len]))
  287. ) {
  288. style.color = this.color
  289. }
  290. }
  291. return style
  292. }
  293. },
  294. // 获取底部的提示文字
  295. getBottomInfo () {
  296. return (index1, index2, item) => {
  297. const date = dayjs(item.date).format('YYYY-MM-DD')
  298. const bottomInfo = item.bottomInfo
  299. // 当为日期范围模式时,且选择的日期个数大于0时
  300. if (this.mode === 'range' && this.selected.length > 0) {
  301. if (this.selected.length === 1) {
  302. // 选择了一个日期时,如果当前日期为数组中的第一个日期,则显示底部文字为“开始”
  303. if (this.dateSame(date, this.selected[0])) return this.startText
  304. else return bottomInfo
  305. } else {
  306. const len = this.selected.length - 1
  307. // 如果数组中的日期大于2个时,第一个和最后一个显示为开始和结束日期
  308. if (
  309. this.dateSame(date, this.selected[0]) &&
  310. this.dateSame(date, this.selected[1]) &&
  311. len === 1
  312. ) {
  313. // 如果长度为2,且第一个等于第二个日期,则提示语放在同一个item中
  314. return `${this.startText}/${this.endText}`
  315. } else if (this.dateSame(date, this.selected[0])) {
  316. return this.startText
  317. } else if (this.dateSame(date, this.selected[len])) {
  318. return this.endText
  319. } else {
  320. return bottomInfo
  321. }
  322. }
  323. } else {
  324. return bottomInfo
  325. }
  326. }
  327. }
  328. },
  329. mounted () {
  330. this.init()
  331. },
  332. methods: {
  333. init () {
  334. // 初始化默认选中
  335. this.$emit('monthSelected', this.selected)
  336. this.$nextTick(() => {
  337. // 这里需要另一个延时,因为获取宽度后,会进行月份数据渲染,只有渲染完成之后,才有真正的高度
  338. // 因为nvue下,$nextTick并不是100%可靠的
  339. uni.$u.sleep(10).then(() => {
  340. this.getWrapperWidth()
  341. this.getMonthRect()
  342. })
  343. })
  344. },
  345. // 判断两个日期是否相等
  346. dateSame (date1, date2) {
  347. return dayjs(date1).isSame(dayjs(date2))
  348. },
  349. // 获取月份数据区域的宽度,因为nvue不支持百分比,所以无法通过css设置每个日期item的宽度
  350. getWrapperWidth () {
  351. // #ifdef APP-NVUE
  352. dom.getComponentRect(this.$refs['u-calendar-month-wrapper'], res => {
  353. this.width = res.size.width
  354. })
  355. // #endif
  356. // #ifndef APP-NVUE
  357. this.$uGetRect('.u-calendar-month-wrapper').then(size => {
  358. this.width = size.width
  359. })
  360. // #endif
  361. },
  362. getMonthRect () {
  363. // 获取每个月份数据的尺寸,用于父组件在scroll-view滚动事件中,监听当前滚动到了第几个月份
  364. const promiseAllArr = this.months.map((item, index) =>
  365. this.getMonthRectByPromise(`u-calendar-month-${index}`)
  366. )
  367. // 一次性返回
  368. Promise.all(promiseAllArr).then(sizes => {
  369. let height = 1
  370. const topArr = []
  371. for (let i = 0; i < this.months.length; i++) {
  372. // 添加到months数组中,供scroll-view滚动事件中,判断当前滚动到哪个月份
  373. topArr[i] = height
  374. height += sizes[i].height
  375. }
  376. // 由于微信下,无法通过this.months[i].top的形式(引用类型)去修改父组件的month的top值,所以使用事件形式对外发出
  377. this.$emit('updateMonthTop', topArr)
  378. })
  379. },
  380. // 获取每个月份区域的尺寸
  381. getMonthRectByPromise (el) {
  382. // #ifndef APP-NVUE
  383. // $uGetRect为uView自带的节点查询简化方法,详见文档介绍:https://www.uviewui.com/js/getRect.html
  384. // 组件内部一般用this.$uGetRect,对外的为uni.$u.getRect,二者功能一致,名称不同
  385. return new Promise(resolve => {
  386. this.$uGetRect(`.${el}`).then(size => {
  387. resolve(size)
  388. })
  389. })
  390. // #endif
  391. // #ifdef APP-NVUE
  392. // nvue下,使用dom模块查询元素高度
  393. // 返回一个promise,让调用此方法的主体能使用then回调
  394. return new Promise(resolve => {
  395. dom.getComponentRect(this.$refs[el][0], res => {
  396. resolve(res.size)
  397. })
  398. })
  399. // #endif
  400. },
  401. // 点击某一个日期
  402. clickHandler (index1, index2, item) {
  403. if (this.readonly) {
  404. return
  405. }
  406. this.item = item
  407. const date = dayjs(item.date).format('YYYY-MM-DD')
  408. if (item.disabled) return
  409. // 对上一次选择的日期数组进行深度克隆
  410. let selected = uni.$u.deepClone(this.selected)
  411. if (this.mode === 'single') {
  412. // 单选情况下,让数组中的元素为当前点击的日期
  413. selected = [date]
  414. } else if (this.mode === 'multiple') {
  415. if (selected.some(item => this.dateSame(item, date))) {
  416. // 如果点击的日期已在数组中,则进行移除操作,也就是达到反选的效果
  417. const itemIndex = selected.findIndex(item => item === date)
  418. selected.splice(itemIndex, 1)
  419. } else {
  420. // 如果点击的日期不在数组中,且已有的长度小于总可选长度时,则添加到数组中去
  421. if (selected.length < this.maxCount) selected.push(date)
  422. }
  423. } else {
  424. // 选择区间形式
  425. if (selected.length === 0 || selected.length >= 2) {
  426. // 如果原来就为0或者大于2的长度,则当前点击的日期,就是开始日期
  427. selected = [date]
  428. } else if (selected.length === 1) {
  429. // 如果已经选择了开始日期
  430. const existsDate = selected[0]
  431. // 如果当前选择的日期小于上一次选择的日期,则当前的日期定为开始日期
  432. if (dayjs(date).isBefore(existsDate)) {
  433. selected = [date]
  434. } else if (dayjs(date).isAfter(existsDate)) {
  435. // 当前日期减去最大可选的日期天数,如果大于起始时间,则进行提示
  436. if (
  437. dayjs(dayjs(date).subtract(this.maxRange, 'day')).isAfter(
  438. dayjs(selected[0])
  439. ) &&
  440. this.showRangePrompt
  441. ) {
  442. if (this.rangePrompt) {
  443. uni.$u.toast(this.rangePrompt)
  444. } else {
  445. uni.$u.toast(`选择天数不能超过 ${this.maxRange} 天`)
  446. }
  447. return
  448. }
  449. // 如果当前日期大于已有日期,将当前的添加到数组尾部
  450. selected.push(date)
  451. const startDate = selected[0]
  452. const endDate = selected[1]
  453. const arr = []
  454. let i = 0
  455. do {
  456. // 将开始和结束日期之间的日期添加到数组中
  457. arr.push(dayjs(startDate).add(i, 'day').format('YYYY-MM-DD'))
  458. i++
  459. // 累加的日期小于结束日期时,继续下一次的循环
  460. } while (dayjs(startDate).add(i, 'day').isBefore(dayjs(endDate)))
  461. // 为了一次性修改数组,避免computed中多次触发,这里才用arr变量一次性赋值的方式,同时将最后一个日期添加近来
  462. arr.push(endDate)
  463. selected = arr
  464. } else {
  465. // 选择区间时,只有一个日期的情况下,且不允许选择起止为同一天的话,不允许选择自己
  466. if (selected[0] === date && !this.allowSameDay) return
  467. selected.push(date)
  468. }
  469. }
  470. }
  471. this.setSelected(selected)
  472. },
  473. // 设置默认日期
  474. setDefaultDate () {
  475. if (!this.defaultDate) {
  476. // 如果没有设置默认日期,则将当天日期设置为默认选中的日期
  477. const selected = [dayjs().format('YYYY-MM-DD')]
  478. return this.setSelected(selected, false)
  479. }
  480. let defaultDate = []
  481. const minDate = this.minDate || dayjs().format('YYYY-MM-DD')
  482. const maxDate =
  483. this.maxDate ||
  484. dayjs(minDate)
  485. .add(this.maxMonth - 1, 'month')
  486. .format('YYYY-MM-DD')
  487. if (this.mode === 'single') {
  488. // 单选模式,可以是字符串或数组,Date对象等
  489. if (!uni.$u.test.array(this.defaultDate)) {
  490. defaultDate = [dayjs(this.defaultDate).format('YYYY-MM-DD')]
  491. } else {
  492. defaultDate = [this.defaultDate[0]]
  493. }
  494. } else {
  495. // 如果为非数组,则不执行
  496. if (!uni.$u.test.array(this.defaultDate)) return
  497. defaultDate = this.defaultDate
  498. }
  499. // 过滤用户传递的默认数组,取出只在可允许最大值与最小值之间的元素
  500. defaultDate = defaultDate.filter(item => {
  501. return (
  502. dayjs(item).isAfter(dayjs(minDate).subtract(1, 'day')) &&
  503. dayjs(item).isBefore(dayjs(maxDate).add(1, 'day'))
  504. )
  505. })
  506. this.setSelected(defaultDate, false)
  507. },
  508. setSelected (selected, event = true) {
  509. this.selected = selected
  510. event && this.$emit('monthSelected', this.selected)
  511. }
  512. }
  513. }
  514. </script>
  515. <style lang="scss" scoped>
  516. @import '../../libs/css/components.scss';
  517. .u-calendar-month-wrapper {
  518. margin-top: 4px;
  519. }
  520. .u-calendar-month {
  521. &__title {
  522. font-size: 32rpx;
  523. line-height: 42px;
  524. height: 42px;
  525. color: $u-main-color;
  526. text-align: center;
  527. font-weight: bold;
  528. }
  529. &__days {
  530. position: relative;
  531. @include flex;
  532. flex-wrap: wrap;
  533. &__month-mark-wrapper {
  534. position: absolute;
  535. top: 0;
  536. bottom: 0;
  537. left: 0;
  538. right: 0;
  539. @include flex;
  540. justify-content: center;
  541. align-items: center;
  542. &__text {
  543. font-size: 155px;
  544. color: rgba(231, 232, 234, 0.83);
  545. }
  546. }
  547. &__day {
  548. @include flex;
  549. padding: 2px;
  550. /* #ifndef APP-NVUE */
  551. // vue下使用css进行宽度计算,因为某些安卓机会无法进行js获取父元素宽度进行计算得出,会有偏移
  552. width: calc(100% / 7);
  553. box-sizing: border-box;
  554. /* #endif */
  555. &__select {
  556. flex: 1;
  557. @include flex;
  558. align-items: center;
  559. justify-content: center;
  560. position: relative;
  561. &__dot {
  562. width: 7px;
  563. height: 7px;
  564. border-radius: 100px;
  565. background-color: $u-error;
  566. position: absolute;
  567. top: 12px;
  568. right: 7px;
  569. }
  570. &__buttom-info {
  571. color: $u-content-color;
  572. text-align: center;
  573. position: absolute;
  574. bottom: 5px;
  575. font-size: 10px;
  576. text-align: center;
  577. left: 0;
  578. right: 0;
  579. &--selected {
  580. color: #ffffff;
  581. }
  582. &--disabled {
  583. color: #cacbcd;
  584. }
  585. }
  586. &__info {
  587. text-align: center;
  588. font-size: 16px;
  589. &--selected {
  590. color: #ffffff;
  591. }
  592. &--disabled {
  593. color: #cacbcd;
  594. }
  595. }
  596. &--selected {
  597. background-color: $u-primary;
  598. @include flex;
  599. justify-content: center;
  600. align-items: center;
  601. flex: 1;
  602. border-radius: 3px;
  603. }
  604. &--range-selected {
  605. opacity: 0.3;
  606. border-radius: 0;
  607. }
  608. &--range-start-selected {
  609. border-top-right-radius: 0;
  610. border-bottom-right-radius: 0;
  611. }
  612. &--range-end-selected {
  613. border-top-left-radius: 0;
  614. border-bottom-left-radius: 0;
  615. }
  616. }
  617. }
  618. }
  619. }
  620. </style>