Vue 3 NutUI日历组件:展开/收缩功能的实现逻辑详解

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>

🚀 扩展思路

  1. 多选模式: 支持日期范围选择
  2. 事件标记: 在日期上显示事件标记
  3. 农历支持: 显示农历日期
  4. 主题切换: 支持多种视觉主题
  5. 国际化: 支持多语言和不同日期格式

🎯 总结

这个展开/收缩功能的实现核心在于:

  1. 状态管理: 通过 isExpanded 控制显示模式
  2. DOM 操作: 直接操作容器高度和元素显示状态
  3. 日期计算: 精确计算当前周和当前月的日期范围
  4. 动画效果: 使用 CSS transition 实现平滑过渡
  5. 性能优化: 防抖处理和缓存机制

通过这种实现方式,既保证了功能的完整性,又确保了良好的用户体验和性能表现。这种设计模式可以应用到其他需要展开/收缩功能的组件中,具有很好的复用价值。

全部代码

<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>


本文档持续更新中,如有问题或建议,欢迎反馈。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容