Vue 3 NutUI日历组件:展开/收缩功能的实现逻辑详解
📖 概述
本文详细介绍了一个基于 Vue 3 + TypeScript + Taro + NutUI的智能日历组件中展开/收缩功能的实现逻辑。该组件支持两种显示模式:展开模式显示整月,收缩模式只显示本周,通过平滑的动画效果提供良好的用户体验。
✨ 核心功能
双模式切换机制
- 展开模式: 显示整月日历,用户可以查看和选择整个月的日期
- 收缩模式: 只显示当前周,节省界面空间,专注于本周安排
🛠 技术实现
1. 状态管理
// 控制日历展开/收缩状态
const isExpanded = ref(false)
// 所有日期元素的总高度(展开模式)
const allDaysHeight = ref(0)
// 单行日期元素的高度(收缩模式)
const oneDayHeight = ref(60)
2. 日期范围计算逻辑
获取本周范围(收缩模式)
function getWeekRange(): DateRange {
const today = new Date()
// 计算本周日的日期(一周的开始)
// getDay() 返回 0-6,0 表示周日,需要特殊处理
const dayOfWeek = today.getDay() === 0 ? 7 : today.getDay()
const firstDay = new Date(today)
firstDay.setDate(today.getDate() - dayOfWeek) // 回退到本周日
firstDay.setHours(0, 0, 0, 0) // 设置为当天开始时间
// 计算本周六的日期(一周的结束)
const lastDay = new Date(firstDay)
lastDay.setDate(firstDay.getDate() + 6) // 前进6天到周六
lastDay.setHours(23, 59, 59, 999) // 设置为当天结束时间
return { firstDay, lastDay }
}
获取本月范围(展开模式)
function getMonthRange(): DateRange {
const today = new Date()
const month = today.getMonth()
const year = today.getFullYear()
// 本月第一天
const firstDay = new Date(year, month, 1)
// 本月最后一天(通过设置下月第0天实现)
const lastDay = new Date(year, month + 1, 0)
return { firstDay, lastDay }
}
3. 智能日期禁用逻辑
const disableDay = (day: CalendarCardDay): boolean => {
// 如果有自定义禁用规则,优先使用
if (props.customDisableDay) {
return props.customDisableDay(day)
}
const { firstDay, lastDay } = currentDateRange.value
const current = new Date(day.year, day.month - 1, day.date)
return current < firstDay || current > lastDay
}
4. 核心展开/收缩实现
DOM 操作函数
const toggleCalendarDays = () => {
nextTick(() => {
// 使用缓存的 DOM 查询结果
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found')
return
}
try {
if (isExpanded.value) {
// 展开模式:显示所有日期
calendarDays.style.height = `${allDaysHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 恢复所有日期的显示
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
dayElement.style.display = ''
})
} else {
// 收缩模式:只显示当前周
calendarDays.style.height = `${oneDayHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 隐藏非当前周的日期(通过禁用状态判断)
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
if (dayElement.classList.contains('disabled')) {
dayElement.style.display = 'none'
} else {
dayElement.style.display = ''
}
})
}
} catch (error) {
console.error('Error toggling calendar days:', error)
}
})
}
安全 DOM 查询
const safeQuerySelector = (selector: string, index: number = 0): HTMLElement | null => {
try {
const elements = document.querySelectorAll(selector)
return elements[index] as HTMLElement || null
} catch (error) {
console.warn(`Failed to query selector: ${selector}`, error)
return null
}
}
5. 用户交互处理
防抖优化
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number): T => {
return ((...args: any[]) => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => fn(...args), delay)
}) as T
}
const handleToggleMode = debounce(() => {
if (isLoading.value) return
isLoading.value = true
try {
toggleMode()
} finally {
// 延迟重置加载状态,确保动画完成
setTimeout(() => {
isLoading.value = false
}, 300)
}
}, 100)
切换逻辑
const toggleMode = () => {
// 切换展开状态
isExpanded.value = !isExpanded.value
// 触发事件
emit('toggle', isExpanded.value)
// 控制日历日期元素的展开/收缩
toggleCalendarDays()
}
6. 初始化逻辑
const initializeCalendarSizes = async () => {
try {
await nextTick()
// 获取日期容器元素
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found during initialization')
return
}
// 获取展开模式下的总高度
const height = calendarDays.getBoundingClientRect().height
allDaysHeight.value = height
// 获取单个日期元素的高度
const oneDay = safeQuerySelector('.nut-calendarcard-day', 0)
if (oneDay) {
oneDayHeight.value = oneDay.getBoundingClientRect().height
}
// 初始化日历的展开/收缩状态
toggleCalendarDays()
} catch (error) {
console.error('Error initializing calendar sizes:', error)
}
}
🎨 动画实现
CSS 过渡效果
.calendar-wrapper {
position: relative;
transition: all 0.3s ease;
overflow-y: auto;
}
.toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
/* 展开状态下的旋转效果 */
&.rotate {
transform: rotate(180deg);
}
}
/* 全局样式覆盖 */
:global(.nut-calendarcard-days) {
transition: max-height 0.3s ease;
}
加载状态动画
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f3f3f3;
border-top: 2px solid #fa6c21;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
📱 使用示例
<template>
<Calender
:default-expanded="false"
@change="handleDateChange"
@toggle="handleToggle"
/>
</template>
<script setup>
const handleDateChange = (date) => {
console.log('选中日期:', date)
}
const handleToggle = (isExpanded) => {
console.log('展开状态:', isExpanded)
}
</script>
🚀 扩展思路
- 多选模式: 支持日期范围选择
- 事件标记: 在日期上显示事件标记
- 农历支持: 显示农历日期
- 主题切换: 支持多种视觉主题
- 国际化: 支持多语言和不同日期格式
🎯 总结
这个展开/收缩功能的实现核心在于:
-
状态管理: 通过
isExpanded控制显示模式 - DOM 操作: 直接操作容器高度和元素显示状态
- 日期计算: 精确计算当前周和当前月的日期范围
- 动画效果: 使用 CSS transition 实现平滑过渡
- 性能优化: 防抖处理和缓存机制
通过这种实现方式,既保证了功能的完整性,又确保了良好的用户体验和性能表现。这种设计模式可以应用到其他需要展开/收缩功能的组件中,具有很好的复用价值。
全部代码
<template>
<!-- 日历容器:使用 SkyCard 组件提供统一的卡片样式 -->
<SkyCard class="calendar-container">
<!-- 日历主体区域:包含展开/收缩状态控制 -->
<view class="calendar-wrapper" :class="{ expanded: isExpanded }">
<!-- 加载状态指示器 -->
<view v-if="isLoading" class="loading-overlay">
<view class="loading-spinner"></view>
</view>
<!-- NutUI 日历卡片组件:提供基础的日历功能 -->
<nut-calendar-card
ref="calendarRef"
v-model="value"
:disable-day="disableDay"
@change="onChange"
></nut-calendar-card>
</view>
<!-- 展开/收缩切换按钮:用户交互入口 -->
<view
class="toggle-button"
:class="{ 'toggle-button--loading': isLoading }"
role="button"
:aria-label="isExpanded ? '收起日历' : '展开日历'"
:aria-expanded="isExpanded"
@click="handleToggleMode"
>
<!-- 动态图标:根据展开状态显示不同方向的箭头 -->
<image
:src="images[IconImageName.Down]"
class="toggle-icon"
:class="{ rotate: isExpanded }"
:alt="isExpanded ? '收起' : '展开'"
/>
</view>
</SkyCard>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, nextTick, watch, onUnmounted } from 'vue'
import { CalendarCardDay } from '@nutui/nutui-taro'
import { useReady } from '@tarojs/taro'
import images from '~/assets/icon-image/images'
import IconImageName from '~/assets/icon-image/const'
import { SkyCard } from '~/components'
// ==================== 类型定义 ====================
/** 日期范围接口 */
interface DateRange {
firstDay: Date
lastDay: Date
}
/** 组件 Props 接口 */
interface Props {
/** 默认展开状态 */
defaultExpanded?: boolean
/** 是否显示加载状态 */
showLoading?: boolean
/** 自定义禁用规则 */
customDisableDay?: (day: CalendarCardDay) => boolean
}
/** 组件 Emits 接口 */
interface Emits {
/** 日期变化事件 */
(e: 'change', date: Date): void
/** 展开状态变化事件 */
(e: 'toggle', isExpanded: boolean): void
}
// ==================== Props & Emits ====================
const props = withDefaults(defineProps<Props>(), {
defaultExpanded: false,
showLoading: false,
})
const emit = defineEmits<Emits>()
// ==================== 响应式数据定义 ====================
/** 当前选中的日期值 */
const value = ref<Date | null>(null)
/** 控制日历展开/收缩状态 */
const isExpanded = ref(props.defaultExpanded)
/** 所有日期元素的总高度(展开模式) */
const allDaysHeight = ref(0)
/** 单行日期元素的高度(收缩模式) */
const oneDayHeight = ref(60)
/** 加载状态 */
const isLoading = ref(false)
/** 日历组件引用 */
const calendarRef = ref()
/** 防抖定时器 */
let debounceTimer: NodeJS.Timeout | null = null
// ==================== 计算属性 ====================
/** 当前日期范围(缓存优化) */
const currentDateRange = computed<DateRange>(() => {
return isExpanded.value ? getMonthRange() : getWeekRange()
})
/** 切换按钮的 ARIA 标签 */
const toggleButtonAriaLabel = computed(() => {
return isExpanded.value ? '收起日历视图' : '展开日历视图'
})
// ==================== 工具函数 ====================
/**
* 防抖函数 - 优化频繁操作
* @param fn 要执行的函数
* @param delay 延迟时间
*/
const debounce = <T extends (...args: any[]) => any>(
fn: T,
delay: number
): T => {
return ((...args: any[]) => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => fn(...args), delay)
}) as T
}
/**
* 安全获取 DOM 元素
* @param selector 选择器
* @param index 索引
* @returns DOM 元素或 null
*/
const safeQuerySelector = (
selector: string,
index: number = 0
): HTMLElement | null => {
try {
const elements = document.querySelectorAll(selector)
return (elements[index] as HTMLElement) || null
} catch (error) {
console.warn(`Failed to query selector: ${selector}`, error)
return null
}
}
/**
* 获取本周的日期范围(周日到周六)
* 用于收缩模式下的日期范围控制
*
* @returns 包含本周开始和结束日期的对象
*/
function getWeekRange(): DateRange {
const today = new Date()
// 计算本周日的日期(一周的开始)
// getDay() 返回 0-6,0 表示周日,需要特殊处理
const dayOfWeek = today.getDay() === 0 ? 7 : today.getDay()
const firstDay = new Date(today)
firstDay.setDate(today.getDate() - dayOfWeek) // 回退到本周日
firstDay.setHours(0, 0, 0, 0) // 设置为当天开始时间
// 计算本周六的日期(一周的结束)
const lastDay = new Date(firstDay)
lastDay.setDate(firstDay.getDate() + 6) // 前进6天到周六
lastDay.setHours(23, 59, 59, 999) // 设置为当天结束时间
return { firstDay, lastDay }
}
/**
* 获取本月的日期范围
* 用于展开模式下的日期范围控制
*
* @returns 包含本月开始和结束日期的对象
*/
function getMonthRange(): DateRange {
const today = new Date()
const month = today.getMonth()
const year = today.getFullYear()
// 本月第一天
const firstDay = new Date(year, month, 1)
// 本月最后一天(通过设置下月第0天实现)
const lastDay = new Date(year, month + 1, 0)
return { firstDay, lastDay }
}
// ==================== 事件处理函数 ====================
/**
* 日期选择变化回调
* @param val 选中的日期对象
*/
const onChange = (val: Date) => {
console.log('日期选择变化:', val)
emit('change', val)
}
/**
* 切换模式处理函数(带防抖)
*/
const handleToggleMode = debounce(() => {
if (isLoading.value) return
isLoading.value = true
try {
toggleMode()
} finally {
// 延迟重置加载状态,确保动画完成
setTimeout(() => {
isLoading.value = false
}, 300)
}
}, 100)
// ==================== 核心业务逻辑 ====================
/**
* 智能日期禁用规则
* 根据当前展开状态决定哪些日期可以被选择
*
* @param day 日历日期对象
* @returns true表示禁用,false表示可选
*/
const disableDay = (day: CalendarCardDay): boolean => {
// 如果有自定义禁用规则,优先使用
if (props.customDisableDay) {
return props.customDisableDay(day)
}
const { firstDay, lastDay } = currentDateRange.value
const current = new Date(day.year, day.month - 1, day.date)
return current < firstDay || current > lastDay
}
// ==================== 用户交互处理 ====================
/**
* 切换展开/收缩模式
* 核心功能:在两种显示模式间切换
*/
const toggleMode = () => {
// 切换展开状态
isExpanded.value = !isExpanded.value
// 触发事件
emit('toggle', isExpanded.value)
// 控制日历日期元素的展开/收缩
toggleCalendarDays()
}
/**
* 控制日历日期元素的展开/收缩
* 通过直接操作 DOM 实现平滑的视觉效果
*/
const toggleCalendarDays = () => {
nextTick(() => {
// 使用缓存的 DOM 查询结果
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found')
return
}
try {
if (isExpanded.value) {
// 展开模式:显示所有日期
calendarDays.style.height = `${allDaysHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 恢复所有日期的显示
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
dayElement.style.display = ''
})
} else {
// 收缩模式:只显示当前周
calendarDays.style.height = `${oneDayHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 隐藏非当前周的日期(通过禁用状态判断)
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
if (dayElement.classList.contains('disabled')) {
dayElement.style.display = 'none'
} else {
dayElement.style.display = ''
}
})
}
} catch (error) {
console.error('Error toggling calendar days:', error)
}
})
}
/**
* 初始化日历尺寸信息
*/
const initializeCalendarSizes = async () => {
try {
await nextTick()
// 获取日期容器元素
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found during initialization')
return
}
// 获取展开模式下的总高度
const height = calendarDays.getBoundingClientRect().height
allDaysHeight.value = height
// 获取单个日期元素的高度
const oneDay = safeQuerySelector('.nut-calendarcard-day', 0)
if (oneDay) {
oneDayHeight.value = oneDay.getBoundingClientRect().height
}
// 初始化日历的展开/收缩状态
toggleCalendarDays()
} catch (error) {
console.error('Error initializing calendar sizes:', error)
}
}
// ==================== 生命周期管理 ====================
/**
* 组件挂载时的初始化
*/
onMounted(() => {
// 设置当前日期为默认选中值
value.value = new Date()
})
/**
* 页面准备就绪时的初始化
* 获取必要的 DOM 尺寸信息并初始化展开/收缩状态
*/
useReady(() => {
// 延迟初始化,确保 DOM 完全渲染
setTimeout(() => {
initializeCalendarSizes()
}, 100)
})
/**
* 组件卸载时的清理
*/
onUnmounted(() => {
// 清理防抖定时器
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
// ==================== 监听器 ====================
/**
* 监听展开状态变化,更新 ARIA 属性
*/
watch(isExpanded, (newValue) => {
// 可以在这里添加额外的状态变化处理逻辑
console.log('Calendar expanded state changed:', newValue)
})
/**
* 监听加载状态变化
*/
watch(isLoading, (newValue) => {
if (newValue) {
console.log('Calendar is loading...')
}
})
</script>
<style lang="less" scoped>
/* ==================== 容器样式 ==================== */
.calendar-container {
position: relative;
}
/* ==================== 日历包装器样式 ==================== */
.calendar-wrapper {
position: relative;
transition: all 0.3s ease;
overflow-y: auto; /* 添加垂直滚动支持 */
/* 展开状态样式 */
&.expanded {
/* 可以添加展开状态的特殊样式 */
}
}
/* ==================== 加载状态样式 ==================== */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f3f3f3;
border-top: 2px solid #fa6c21;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* ==================== 切换按钮样式 ==================== */
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
cursor: pointer;
transition: all 0.3s ease;
user-select: none; /* 防止文本选择 */
/* 加载状态样式 */
&--loading {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* 点击反馈效果 */
&:active:not(&--loading) {
opacity: 0.8;
transform: scale(0.95);
}
/* 焦点状态(无障碍访问) */
&:focus-visible {
outline: 2px solid #3498db;
outline-offset: 2px;
}
/* 箭头图标样式 */
.toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
pointer-events: none; /* 防止图标干扰点击事件 */
/* 展开状态下的旋转效果 */
&.rotate {
transform: rotate(180deg);
}
}
}
/* ==================== 全局样式覆盖 ==================== */
/* 控制日历日期元素的展开/收缩过渡效果 */
:global(.nut-calendarcard-days) {
transition: max-height 0.3s ease;
}
/* ==================== 响应式设计 ==================== */
@media (max-width: 768px) {
.toggle-button {
padding: 10px 12px;
.toggle-icon {
width: 14px;
height: 14px;
}
}
.loading-spinner {
width: 20px;
height: 20px;
}
}
/* ==================== 高对比度模式支持 ==================== */
@media (prefers-contrast: high) {
.toggle-button {
border: 1px solid currentColor;
&:focus-visible {
outline: 3px solid currentColor;
}
}
.loading-overlay {
background: rgba(0, 0, 0, 0.8);
}
}
/* ==================== 减少动画模式支持 ==================== */
@media (prefers-reduced-motion: reduce) {
.calendar-wrapper,
.toggle-button,
.toggle-icon,
:global(.nut-calendarcard-days) {
transition: none !important;
}
.loading-spinner {
animation: none;
}
}
</style>
本文档持续更新中,如有问题或建议,欢迎反馈。