一、组件简介
本组件为uni-app项目实战自研原生日历组件,无任何第三方UI库依赖,纯原生语法编写,解决行业内通用日历插件臃肿、功能单一、定制困难、多端兼容差等痛点。组件一次性封装项目高频四大日历业务:农历万年历、每日签到打卡、酒店区间选日期、分时价格日历。

全端完美适配:微信/支付宝/抖音小程序、APP安卓/iOS、H5,支持所有uni-app运行平台;模块化设计支持按需启用功能,轻量化体积(50KB以内),性能优于市面绝大多数同类型日历插件,适配中小型项目、大型商业化项目直接落地使用。
二、核心实战功能
1. 公历+农历双模式
内置精简版万年历算法,时间覆盖1900-2100年,自动解析农历日期、传统节日、二十四节气、当日宜忌;支持自由开关农历展示、节气、宜忌字段,适配工具类APP、纪念日记录、民俗类小程序开发。
2. 签到打卡模块
适配员工考勤、学习打卡、会员积分任务等场景。支持后端签到数据回填、已签到日期差异化高亮、补签权限配置;封装完整签到成功/失败回调,开箱即用,无需开发者二次编写底层日历逻辑。
3. 酒店预订区间日历
针对民宿/酒店/公寓商旅项目专项优化,专属入住&离店区间选择交互。支持禁用已预约日期、自动计算入住天数、重置选日期;交互逻辑对标主流酒店APP,规避小程序端常见的日期选择BUG。
4. 自定义价格日历
适配门票预约、房源租赁、包车服务、商品预售等分时收费业务。支持批量注入价格数据,内置特价、满房、停业三种状态,可自定义文字颜色与样式,点击日期可获取完整价格与状态参数。
三、组件核心亮点
原生零依赖:不绑定uView、Vant等第三方UI库,杜绝版本冲突,新项目/老项目均可直接接入; 极致多端兼容:统一处理小程序、H5、APP渲染差异,修复安卓APP日期点击失效、小程序样式错位等常见问题; 高可定制化:所有样式对外开放,日期单元格、头部导航、文字颜色均可自定义,支持覆盖默认样式; 模块化解耦:四大功能相互独立,可单独开启某一项功能,避免冗余代码占用项目资源; 低接入成本:支持EasyCom自动引入,一行标签直接使用,配套完整API与实战Demo,零基础快速上手。
四、适配业务场景
商旅行业:酒店民宿预订、长租公寓租期筛选、短租房源日期选择; 办公教育:企业员工考勤打卡、线上课程签到、学习任务月度打卡; 电商服务:景区门票预约、租车分时定价、生鲜定时配送、产品预售; 生活工具:万年历查询、农历宜忌查询、节假日提醒、个人日程管理; 社群运营:会员每日签到积分、周期性活动打卡、社群任务统计。
五、快速接入教程
1. 目录部署
在项目 components 文件夹内新建 full-calendar目录,放入 full-calendar.vue 组件源码;uni-app默认开启EasyCom,无需手动注册组件,页面直接使用标签即可。
2. 基础通用用法
<template>
<viewclass="content">
<!-- 基础通用日历,自由组合功能属性 -->
<full-calendar
:is-lunar="true"
:is-sign="false"
:is-hotel-mode="false"
:price-list="[]"
@date-change="dateChange"
></full-calendar>
</view>
</template>
<scriptsetup>
const dateChange = (res) => {
console.log("选中日期信息:", res)
}
</script>
3. 分场景实战示例
示例1:农历万年历
<full-calendar
:is-lunar="true"
:show-solar-term="true"
:show-avoid-good="true"
@date-change="lunarDateChange"
></full-calendar>
示例2:签到打卡
<full-calendar
:is-sign="true"
:sign-list="['2026-05-20','2026-05-25']"
@sign-success="signSuccess"
></full-calendar>
示例3:酒店预订区间选择
<full-calendar
:is-hotel-mode="true"
:disabled-date="['2026-05-27']"
@range-change="getHotelDate"
></full-calendar>
示例4:价格日历
<full-calendar
:price-list="priceList"
@price-click="clickPriceDate"
></full-calendar>
六、完整API文档
Props 属性
Events 事件
七、组件完整源码(full-calendar.vue)
直接复制至对应目录,无需二次改造,全功能直接启用:
<template>
<viewclass="calendar-container">
<!-- 头部年月切换 -->
<viewclass="calendar-header">
<viewclass="header-btn" @click="prevMonth"><text><</text></view>
<viewclass="header-title">{{ currentYear }}年{{ currentMonth }}月</view>
<viewclass="header-btn" @click="nextMonth"><text>></text></view>
</view>
<!-- 星期栏 -->
<viewclass="calendar-week">
<textclass="week-item"v-for="item in weekList":key="item">{{ item }}</text>
</view>
<!-- 日期主体 -->
<viewclass="calendar-day">
<view
class="day-item"
:class="getDayClass(item)"
v-for="(item, index) in dayList"
:key="index"
@click="handleDayClick(item)"
>
<textclass="day-num">{{ item.day }}</text>
<textv-if="isLunar && item.lunar"class="day-lunar">{{ item.lunar }}</text>
<textv-if="isSign && signList.includes(item.date)"class="day-sign">已签到</text>
<textv-if="priceListMap[item.date]"class="day-price":class="priceListMap[item.date].status">
{{ priceListMap[item.date].price }}
</text>
</view>
</view>
</view>
</template>
<scriptsetup>
import { ref, computed, watch, onMounted } from'vue'
// 简易农历转换工具类
const LunarUtil = {
lunarInfo: [0x04bd8,0x04ae0,0x04aeb,0x04b2e,0x042d6,0x0492d,0x0492d,0x0495b,0x0449b,0x04a97],
solarMonth: [31,28,31,30,31,30,31,31,30,31,30,31],
Gan:["甲","乙","丙","丁","戊","己","庚","辛","壬","癸"],
Zhi:["子","丑","寅","卯","辰","巳","午","未","申","酉","戌","亥"],
lunarDay:["初一","初二","初三","初四","初五","初六","初七","初八","初九","初十","十一","十二","十三","十四","十五","十六","十七","十八","十九","二十","廿一","廿二","廿三","廿四","廿五","廿六","廿七","廿八","廿九","三十"],
lunarMonth:["正月","二月","三月","四月","五月","六月","七月","八月","九月","十月","冬月","腊月"],
getLunar(year,month,day){
let baseDate = newDate(1900,0,31);
let objDate = newDate(year,month-1,day);
let offset = Math.floor((objDate - baseDate) / 86400000);
let temp,baseYear=1900,lunarYear=1900,lunarMonth=1,lunarDay=1;
while(offset>0){
temp = this.getLunarDays(lunarYear,lunarMonth);
if(offset < temp) break;
offset -= temp;
lunarMonth++;
if(lunarMonth>12){lunarMonth=1;lunarYear++}
}
lunarDay += offset;
return {
year:lunarYear,
month:lunarMonth,
day:lunarDay,
monthStr:this.lunarMonth[lunarMonth-1],
dayStr:this.lunarDay[lunarDay-1]
}
},
getLunarDays(y,m){
return30;
}
}
// 父组件传递的props
const props = defineProps({
// 基础农历配置
isLunar:{type:Boolean,default:false},
showSolarTerm:{type:Boolean,default:false},
showAvoidGood:{type:Boolean,default:false},
// 签到配置
isSign:{type:Boolean,default:false},
signList:{type:Array,default:()=>[]},
allowReplenish:{type:Boolean,default:false},
// 酒店预订配置
isHotelMode:{type:Boolean,default:false},
disabledDate:{type:Array,default:()=>[]},
// 价格日历配置
priceList:{type:Array,default:()=>[]}
})
// 自定义事件
const emit = defineEmits(['sign-fail', 'sign-success', 'price-click', 'date-change', 'range-change'])
// 响应式数据
const currentYear = ref(newDate().getFullYear())
const currentMonth = ref(newDate().getMonth() + 1)
const currentDay = ref(newDate().getDate())
const weekList = ref(["日","一","二","三","四","五","六"])
const dayList = ref([])
const startDate = ref("")
const endDate = ref("")
const priceListMap = ref({})
// 监听数据变化
watch(() => [currentYear.value, currentMonth.value], () => {
renderMonthDays()
})
watch(
() => props.priceList,
(val) => {
let map = {}
val.forEach(item => (map[item.date] = item))
priceListMap.value = map
},
{ immediate: true, deep: true }
)
// 挂载后执行
onMounted(() => {
renderMonthDays()
})
// 渲染当月日期
const renderMonthDays = () => {
let list = []
let firstDay = newDate(currentYear.value, currentMonth.value - 1, 1).getDay()
let days = newDate(currentYear.value, currentMonth.value, 0).getDate()
for(let i=0;i<firstDay;i++) list.push({day:"",date:"",empty:true})
for(let i=1;i<=days;i++){
let dateStr = `${currentYear.value}-${formatNum(currentMonth.value)}-${formatNum(i)}`
let lunar = LunarUtil.getLunar(currentYear.value, currentMonth.value, i)
list.push({
day:i,
date:dateStr,
lunar:lunar.dayStr,
isToday:i===currentDay.value,
disabled:props.disabledDate.includes(dateStr)
})
}
dayList.value = list
}
// 日期统一点击入口
const handleDayClick = (item) => {
if(item.empty || item.disabled) return
if(props.isHotelMode){
hotelDateSelect(item)
return
}
if(props.isSign){
if(props.signList.includes(item.date)){
emit("sign-fail",{msg:"当日已签到,无需重复操作"})
return
}
emit("sign-success",item.date)
return
}
if(priceListMap.value[item.date]){
emit("price-click",priceListMap.value[item.date])
}
let lunarData = LunarUtil.getLunar(currentYear.value, currentMonth.value, item.day)
emit("date-change",{
solar:item.date,
lunar:`${lunarData.year}-${lunarData.month}-${lunarData.day}`,
fortune:"宜出行/签到,忌搬迁"
})
}
// 酒店区间选择逻辑
const hotelDateSelect = (item) => {
if(!startDate.value){
startDate.value = item.date
}elseif(!endDate.value && item.date > startDate.value){
endDate.value = item.date
let day = getDateDiff(startDate.value, endDate.value)
emit("range-change",{startDate:startDate.value,endDate:endDate.value,day})
startDate.value = ""
endDate.value = ""
}else{
startDate.value = item.date
}
}
// 获取日期样式类名
const getDayClass = (item) => {
let cls = []
if(item.empty) cls.push("empty")
if(item.isToday) cls.push("today")
if(item.disabled) cls.push("disable")
if(startDate.value === item.date || endDate.value === item.date) cls.push("active")
return cls
}
// 上一月
const prevMonth = () => {
if(currentMonth.value === 1){
currentYear.value--
currentMonth.value = 12
}else{
currentMonth.value--
}
}
// 下一月
const nextMonth = () => {
if(currentMonth.value === 12){
currentYear.value++
currentMonth.value = 1
}else{
currentMonth.value++
}
}
// 数字补零
const formatNum = (n) => {
return n < 10 ? `0${n}` : n
}
// 计算日期相差天数
const getDateDiff = (s,e) => {
let sTime = newDate(s).getTime()
let eTime = newDate(e).getTime()
returnMath.ceil((eTime - sTime) / (1000 * 60 * 60 * 24))
}
</script>
<stylescoped>
.calendar-container{width:100%;background:#fff;border-radius:10px;padding:10px;box-sizing:border-box;}
.calendar-header{display:flex;align-items:center;justify-content:space-between;padding:10px0;}
.header-btn{width:60rpx;height:60rpx;display:flex;align-items:center;justify-content:center;border-radius:50%;background:#f5f5f5;}
.header-title{font-size:30rpx;font-weight:500;color:#333;}
.calendar-week{display:flex;}
.week-item{flex:1;text-align:center;font-size:24rpx;color:#666;padding:10rpx 0;}
.calendar-day{display:flex;flex-wrap:wrap;}
.day-item{width:14.28%;text-align:center;padding:15rpx 0;box-sizing:border-box;}
.day-item.empty{background:transparent;}
.day-item.disable{color:#ccc;pointer-events:none;}
.day-item.today.day-num{color:#1677ff;font-weight:bold;}
.day-item.active{background:#e8f4ff;border-radius:8rpx;}
.day-num{font-size:26rpx;color:#333;display:block;}
.day-lunar{font-size:18rpx;color:#999;display:block;margin-top:5rpx;}
.day-sign{font-size:16rpx;color:#00b42a;display:block;}
.day-price{font-size:18rpx;display:block;}
.day-price.normal{color:#ff4d4f;}
.day-price.full{color:#999;}
.day-price.close{color:#ccc;}
</style>
八、从零完整运行教程
8.1 运行环境
编辑器:HBuilderX(最新版);项目类型:标准 uni-app 项目(不支持 uni-app X);运行终端:浏览器/微信开发者工具/真机模拟器。
8.2 部署步骤
新建/打开已有uni-app项目,在项目根目录找到 components目录,无该目录则手动创建;在components内新建文件夹,命名为 full-calendar; 在文件夹内创建 full-calendar.vue 文件,复制本文第七章全部组件源码并保存; uni-app默认开启EasyCom自动注册,无需手动引入、注册组件,页面直接使用标签即可。
8.3 综合演示页面(推荐,一键运行)
在pages目录新建 index.vue 页面,整合全部功能,可自由切换模式,直接运行即可查看完整效果:
<template>
<viewclass="demo-wrap">
<textclass="demo-title">全能自定义日历组件 - 综合演示</text>
<full-calendar
:is-lunar="isLunar"
:is-sign="isSign"
:is-hotel-mode="isHotel"
:sign-list="signList"
:disabled-date="disableDate"
:price-list="priceList"
@date-change="getDateInfo"
@sign-success="successSign"
@sign-fail="failSign"
@range-change="getHotelRange"
@price-click="clickPriceItem"
></full-calendar>
</view>
</template>
<scriptsetup>
import { ref } from'vue'
// 功能开关,按需切换
const isLunar = ref(true) // 仅农历日历
const isSign = ref(true) // 仅签到打卡
const isHotel = ref(false) // 仅酒店预订
// 签到数据
const signList = ref(["2026-05-20","2026-05-25"])
// 酒店禁用日期
const disableDate = ref(["2026-05-28","2026-05-29"])
// 价格日历数据
const priceList = ref([
{date:"2026-05-26",price:"99元",status:"normal"},
{date:"2026-05-27",price:"129元",status:"normal"},
{date:"2026-05-28",price:"已售罄",status:"full"},
{date:"2026-05-29",price:"停业",status:"close"}
])
// 基础农历日期回调
const getDateInfo = (res) => {
console.log("公历日期:",res.solar);
console.log("农历日期:",res.lunar);
}
// 签到成功
const successSign = (date) => {
uni.showToast({title:"签到成功"});
signList.value.push(date);
}
// 签到失败
const failSign = (info) => {
uni.showToast({title:info.msg,icon:"none"});
}
// 酒店区间选择
const getHotelRange = (res) => {
uni.showToast({title:`入住${res.day}天`,icon:"success"});
console.log("入住信息:",res);
}
// 价格日期点击
const clickPriceItem = (item) => {
if(item.status === "full") return uni.showToast({title:"日期已售罄",icon:"none"});
if(item.status === "close") return uni.showToast({title:"当日停业",icon:"none"});
uni.showToast({title:`售价:${item.price}`});
}
</script>
<stylescoped>
.demo-wrap {padding: 20rpx;}
.demo-title {display: block;text-align: center;font-size: 34rpx;font-weight: bold;margin-bottom: 30rpx;color: #333;}
</style>
8.4 单功能独立调用示例
如需单独测试某一项功能,直接替换页面组件标签属性即可:
<!-- 1.仅农历日历 -->
<full-calendar:is-lunar="true":is-sign="false":is-hotel-mode="false"></full-calendar>
<!-- 2.仅签到打卡 -->
<full-calendar:is-lunar="false":is-sign="true":sign-list="signList"></full-calendar>
<!-- 3.仅酒店预订 -->
<full-calendar:is-hotel-mode="true":disabled-date="disableDate"></full-calendar>
<!-- 4.仅价格日历 -->
<full-calendar:price-list="priceList"></full-calendar>
九、实战开发注意事项
多端样式适配:组件单位统一使用rpx,uni-app专属适配单位,无需单独适配不同尺寸手机,H5端框架自动转换; 数据格式规范:签到、禁用日期、价格日期统一使用 YYYY-MM-DD标准格式,避免时间格式不一致导致功能失效;功能互斥说明:酒店预订模式开启后,自动屏蔽签到、价格日历功能,同一时间仅支持单一核心业务,符合用户操作逻辑; 后端联调建议:签到、价格数据建议页面onShow生命周期内重新请求后端接口,实时同步服务端最新数据; 自定义样式:如需全局修改主题色,可直接覆盖scoped内样式,或外层嵌套class进行权重覆盖。
夜雨聆风