背景
在使用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 JieQi
if (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 PropTypes
class propTypes extends newPropTypes {
static get style() {
return toValidableType('style', {
type: [String, Object]
})
}
}
export { propTypes }
is 工具代码
// copy to vben-admin
const toString = Object.prototype.toString
export 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 = !isServer
export 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 |