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.

579 lines
19 KiB

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