You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

585 lines
19 KiB

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