背景
在使用element-plus
开发项目过程中,需要填入人员的生卒日期,经观察,对于大部分人来说,这类日期通常是农历日期,然而我们在系统建设过程中,对于日期字段,约定成俗的都会使用公历日期,这就存在一个问题,用户只记得自己的农历日期,那么在录入生卒日期的时候,往往就需要通过其他工具,查找到农历对应的公历日期,才能正确的录入系统中,并且,录入系统后,只能看到公历日期,不能直观的将农历日期反馈到用户,所以可能日期录入错误,也不能迅速的发现并修正,于是从实际需求出发,对element-plus
组件库中的DatePicker
组件进行自定义,在弹窗选择日期面板中,引入农历日期的显示,方便用户操作,减少错误发生。
组件设计
通过对element-plus
组件库官方文档DatePicker 日期选择器 | Element Plus (element-plus.org)的查阅,DatePicker
组件提供了一个默认的插槽,用于支持对弹出框内容的自定义,因此,我们需要借助此插槽来添加农历日期的显示。
根据日常使用惯例,大部分的日历工具,都是上面显示公历日期,下面显示对应的农历日期,如果日期是传统节日或者节气的,还会显示对应的节日或节气名称,因此,我们需要在自定义组件中,增加属性showFestival
用于控制是否显示节日、showJieQi
用于控制是否显示节气,如果都不显示,那么全都统一显示为农历日期天数。
我们知道,农历日期和公历日期是存在差异的,差异大的时候可能会相差一个月以上,然而日期选择组件的弹窗面板空间有限,因此我们需要将农历的月份融入日期中,也就是每个月的第一天显示当前农历月份,对于农历日期,用户往往还会注重当前年份的天干与地支,他们可以根据天干地支来进一步核实是否为当前年份,因此,我们还需要增加一个属性showLunarTip
,用于控制显示当前日期的完整农历日期,如二〇二四年二月廿五 【甲辰(龙)年】
,这样用户可以直观的看出当前日期正不正确,当然,出于对用户体验的改善,我们希望自定义组件更加人性化,比如,有时希望鼠标悬停到对应日期上,就马上弹出tip
显示完整的农历日期信息,有时候,我希望鼠标悬停1秒以上才显示农历日期,减少对日期选择的干扰,因此我们再增加一个属性lunarTipShowAfter
用于控制完整农历日期的弹出触发时常。
最终效果
工具选择
毋庸置疑,要显示公历对应的具体农历日期,肯定会存在日期间的换算,农历相对公历来说,规律性比较复杂,要完全自己实现公历转对应的农历,工作量较大,因此,我们优先选择三方工具,来完成两种历法的换算。
通过对几个工具库的对比,我最终选择了lunar (6tail.cn)工具库,它提供了丰富的接口,满足绝大部分场景下的使用需求,工具的强大性,请看官方文档介绍。
代码实现
因为项目使用vue3
+typescript
开发,因此自定义组件也是在此环境下完成。我们需要的是对原组件DatePicker
的增强封装,因此我们的自定义组件需要保留绝大部分原组件的功能。
下面,直接贴出自定义组件的实现代码
<template><el-date-picker v-model="dateValue" v-bind="$props"><template #default="dateCell"><el-tooltip:disabled="!showLunarTip":show-after="lunarTipShowAfter":content="getLunarDateStr(dateCell.date)"placement="bottom"><div :class="getDateClass(dateCell)"><span class="solar-text">{{ dateCell.date.getDate() }}</span><span class="lunar-tex">{{ getLunarDay(dateCell.date) }}</span></div></el-tooltip></template></el-date-picker>
</template><script setup lang="ts">
import { JieQi, Solar } from 'lunar-typescript'
import { propTypes } from '@/utils/propTypes'
import { isEmpty } from '@/utils/is'
import { datePickerProps } from 'element-plus'
import type { DateCell } from 'element-plus/es/components/date-picker/src/date-picker.type'
// 带农历日期显示的选择组件
defineOptions({ name: 'LunarDatePicker' })const emit = defineEmits(['update:modelValue'])const props = defineProps({...datePickerProps,showFestival: propTypes.bool.def(true), // 是否显示节日showJieQi: propTypes.bool.def(true), // 是否显示节气showLunarTip: propTypes.bool.def(true), // 是否使用 tooltip 显示农历日期lunarTipShowAfter: propTypes.number.def(0) // 在触发后多久使用 tooltip 显示农历日期,单位毫秒
})const dateValue: Ref<typeof props.modelValue> = ref<typeof props.modelValue>('')watch(() => props.modelValue,(val: typeof props.modelValue) => {dateValue.value = val},{immediate: true}
)watch(() => dateValue.value,(val) => {emit('update:modelValue', val)}
)/*** 获取当前日期显示样式* @param dateCell 单元格日期信息*/
const getDateClass = (dateCell: DateCell) => {let cla = 'date-wrapper'if (dateCell.type === 'today') {cla += ' today'}if (dateCell.isCurrent || dateCell.isSelected || dateCell.start || dateCell.end) {cla += ' active'} else if (dateCell.inRange) {cla += ' in-range'}if (dateCell.disabled) {cla += ' disabled-date'}return cla
}/*** 获取农历 day 显示文字*/
const getLunarDay = (date) => {const solarDate = Solar.fromDate(date)const lunarDate = solarDate.getLunar()// 每月第一天显示月数if (lunarDate.getDay() == 1) {return lunarDate.getMonthInChinese() + '月'}// 显示节日if (props.showFestival) {const festivals = lunarDate.getFestivals()if (!isEmpty(festivals)) {return festivals[0]}}// 显示节气if (props.showJieQi) {const currJieQi: JieQi = lunarDate.getCurrentJieQi() as JieQiif (currJieQi && currJieQi?.getName()) {return currJieQi?.getName()}}return lunarDate.getDayInChinese()
}/*** 根据日历获取农历日期,包含年份干支和生肖*/
const getLunarDateStr = (date: Date): string => {const solarDate = Solar.fromDate(date)const lunarDate = solarDate.getLunar()return `${lunarDate.getYearInChinese()}年${lunarDate.getMonthInChinese()}月${lunarDate.getDayInChinese()} 【${lunarDate.getYearInGanZhi()}(${lunarDate.getYearShengXiao()})年】`
}
</script><style lang="scss" scoped>
.date-wrapper {position: relative;display: flex;align-items: center;flex-direction: column;padding: 4px 0;line-height: 18px;text-align: center;.solar-text {font-size: 14px;}.lunar-text {white-space: nowrap;}
}.today {font-weight: 700;color: var(--el-color-primary);
}.active {color: #fff;background-color: var(--el-datepicker-active-color);border-radius: 5px;
}.in-range {background-color: var(--el-datepicker-inrange-bg-color);
}.disabled-date {cursor: not-allowed;
}
</style>
相关代码
引入历法换算工具
npm i lunar-typescript
propTypes 工具代码
import { VueTypeValidableDef, VueTypesInterface, createTypes, toValidableType } from 'vue-types'
import { CSSProperties } from 'vue'type PropTypes = VueTypesInterface & {readonly style: VueTypeValidableDef<CSSProperties>
}
const newPropTypes = createTypes({func: undefined,bool: undefined,string: undefined,number: undefined,object: undefined,integer: undefined
}) as PropTypesclass propTypes extends newPropTypes {static get style() {return toValidableType('style', {type: [String, Object]})}
}export { propTypes }
is 工具代码
// copy to vben-adminconst toString = Object.prototype.toStringexport const is = (val: unknown, type: string) => {return toString.call(val) === `[object ${type}]`
}export const isDef = <T = unknown>(val?: T): val is T => {return typeof val !== 'undefined'
}export const isUnDef = <T = unknown>(val?: T): val is T => {return !isDef(val)
}export const isObject = (val: any): val is Record<any, any> => {return val !== null && is(val, 'Object')
}export const isEmpty = <T = unknown>(val: T): val is T => {if (val === null) {return true}if (isArray(val) || isString(val)) {return val.length === 0}if (val instanceof Map || val instanceof Set) {return val.size === 0}if (isObject(val)) {return Object.keys(val).length === 0}return false
}export const isDate = (val: unknown): val is Date => {return is(val, 'Date')
}export const isNull = (val: unknown): val is null => {return val === null
}export const isNullAndUnDef = (val: unknown): val is null | undefined => {return isUnDef(val) && isNull(val)
}export const isNullOrUnDef = (val: unknown): val is null | undefined => {return isUnDef(val) || isNull(val)
}export const isNumber = (val: unknown): val is number => {return is(val, 'Number')
}export const isPromise = <T = any>(val: unknown): val is Promise<T> => {return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
}export const isString = (val: unknown): val is string => {return is(val, 'String')
}export const isFunction = (val: unknown): val is Function => {return typeof val === 'function'
}export const isBoolean = (val: unknown): val is boolean => {return is(val, 'Boolean')
}export const isRegExp = (val: unknown): val is RegExp => {return is(val, 'RegExp')
}export const isArray = (val: any): val is Array<any> => {return val && Array.isArray(val)
}export const isWindow = (val: any): val is Window => {return typeof window !== 'undefined' && is(val, 'Window')
}export const isElement = (val: unknown): val is Element => {return isObject(val) && !!val.tagName
}export const isMap = (val: unknown): val is Map<any, any> => {return is(val, 'Map')
}export const isServer = typeof window === 'undefined'export const isClient = !isServerexport const isUrl = (path: string): boolean => {const reg =/(((^https?:(?:\/\/)?)(?:[-:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&%@.\w_]*)#?(?:[\w]*))?)$/return reg.test(path)
}export const isDark = (): boolean => {return window.matchMedia('(prefers-color-scheme: dark)').matches
}// 是否是图片链接
export const isImgPath = (path: string): boolean => {return /(https?:\/\/|data:image\/).*?\.(png|jpg|jpeg|gif|svg|webp|ico)/gi.test(path)
}export const isEmptyVal = (val: any): boolean => {return val === '' || val === null || val === undefined
}
相关组件库版本
组件 | 版本 |
---|---|
vue | ^3.3.7 |
element-plus | 2.4.1 |
lunar-typescript | ^1.7.5 |
typescript | 5.2.2 |
vue-types | ^5.1.1 |