month.vue 20 KB

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