FullCalendar主题切换功能实现:暗模式与自定义主题动态加载

FullCalendar主题切换功能实现:暗模式与自定义主题动态加载

【免费下载链接】fullcalendar Full-sized drag & drop event calendar in JavaScript 【免费下载链接】fullcalendar 项目地址: https://gitcode.com/gh_mirrors/fu/fullcalendar

一、主题切换痛点与解决方案

在现代Web应用开发中,用户对界面个性化需求日益增长,尤其是主题切换功能。你是否还在为FullCalendar日历组件的主题定制而烦恼?是否需要在页面刷新后才能应用新主题?本文将详细介绍如何实现FullCalendar的动态主题切换功能,包括暗模式与自定义主题的无缝加载,无需页面刷新即可实时生效。

读完本文,你将获得:

  • FullCalendar主题系统(Theme System)的核心原理
  • 实现明/暗模式一键切换的完整方案
  • 自定义主题动态加载的优化策略
  • 多主题系统(Bootstrap/Standard)的集成方法
  • 生产环境中的性能优化与缓存处理

二、FullCalendar主题系统架构解析

2.1 主题系统核心组件

FullCalendar的主题系统基于模块化设计,主要由以下组件构成:

mermaid

2.2 主题切换工作流程

主题切换的核心流程包括主题检测、资源加载、样式应用和状态保存四个阶段:

mermaid

三、基础实现:主题系统切换

3.1 主题选择器UI实现

首先创建主题选择器界面,允许用户选择不同的主题系统和具体主题:

<div id='theme-controls'>
  <div class='selector'>
    主题系统:
    <select id='theme-system-selector'>
      <option value='bootstrap5' selected>Bootstrap 5</option>
      <option value='bootstrap'>Bootstrap 4</option>
      <option value='standard'>默认样式</option>
    </select>
  </div>
  
  <div id='bootstrap-themes' class='selector' style='display:inline-block'>
    主题名称:
    <select id='bootstrap-theme-selector'>
      <option value='' selected>默认</option>
      <option value='darkly'>Darkly (暗模式)</option>
      <option value='cyborg'>Cyborg</option>
      <option value='slate'>Slate</option>
      <option value='solar'>Solar</option>
      <option value='superhero'>Superhero</option>
    </select>
  </div>
  
  <span id='loading-indicator' style='display:none'>加载中...</span>
</div>

3.2 主题切换核心代码

实现主题切换的核心逻辑,包括样式加载、主题应用和日历重渲染:

class ThemeManager {
  constructor(calendar) {
    this.calendar = calendar;
    this.currentThemeSystem = 'bootstrap5';
    this.currentThemeName = '';
    this.stylesheetElement = null;
    
    // 初始化事件监听
    this.initEventListeners();
  }
  
  initEventListeners() {
    // 主题系统选择变化
    document.getElementById('theme-system-selector').addEventListener('change', (e) => {
      this.switchThemeSystem(e.target.value);
    });
    
    // Bootstrap主题选择变化
    document.getElementById('bootstrap-theme-selector').addEventListener('change', (e) => {
      this.switchBootstrapTheme(e.target.value);
    });
  }
  
  switchThemeSystem(themeSystem) {
    this.currentThemeSystem = themeSystem;
    
    // 显示/隐藏对应主题选择器
    if (themeSystem.startsWith('bootstrap')) {
      document.getElementById('bootstrap-themes').style.display = 'inline-block';
      // 应用当前选择的Bootstrap主题
      this.switchBootstrapTheme(this.currentThemeName);
    } else {
      document.getElementById('bootstrap-themes').style.display = 'none';
      // 应用标准主题
      this.applyStandardTheme();
    }
  }
  
  switchBootstrapTheme(themeName) {
    this.currentThemeName = themeName;
    const stylesheetUrl = this.generateBootstrapStylesheetUrl(themeName);
    
    this.loadStylesheet(stylesheetUrl).then(() => {
      // 更新日历主题系统
      this.calendar.setOption('themeSystem', this.currentThemeSystem);
      // 保存用户偏好
      this.saveThemePreference();
    });
  }
  
  generateBootstrapStylesheetUrl(themeName) {
    // 根据Bootstrap版本和主题名称生成CDN链接
    if (this.currentThemeSystem === 'bootstrap5') {
      return themeName 
        ? `https://cdn.bootcdn.net/ajax/libs/bootswatch/5.1.3/${themeName}/bootstrap.min.css`
        : 'https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css';
    } else { // bootstrap 4
      return themeName
        ? `https://cdn.bootcdn.net/ajax/libs/bootswatch/4.6.2/${themeName}/bootstrap.min.css`
        : 'https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.6.2/css/bootstrap.min.css';
    }
  }
  
  loadStylesheet(url) {
    return new Promise((resolve, reject) => {
      const loadingIndicator = document.getElementById('loading-indicator');
      loadingIndicator.style.display = 'inline';
      
      // 创建新的link元素
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = url;
      
      // 监听加载完成事件
      link.onload = () => {
        // 移除旧样式表
        if (this.stylesheetElement) {
          this.stylesheetElement.remove();
        }
        this.stylesheetElement = link;
        loadingIndicator.style.display = 'none';
        resolve();
      };
      
      // 监听加载错误事件
      link.onerror = () => {
        loadingIndicator.style.display = 'none';
        reject(new Error(`Failed to load stylesheet: ${url}`));
      };
      
      // 添加到文档头部
      document.head.appendChild(link);
    });
  }
  
  // 其他方法...
}

3.3 初始化日历与主题管理器

在页面加载完成后,初始化日历实例和主题管理器:

document.addEventListener('DOMContentLoaded', function() {
  // 初始化日历
  const calendarEl = document.getElementById('calendar');
  const calendar = new FullCalendar.Calendar(calendarEl, {
    themeSystem: 'bootstrap5',
    headerToolbar: {
      left: 'prev,next today',
      center: 'title',
      right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
    },
    initialDate: '2023-07-01',
    editable: true,
    selectable: true,
    events: [
      // 事件数据...
    ]
  });
  
  calendar.render();
  
  // 初始化主题管理器
  const themeManager = new ThemeManager(calendar);
  
  // 尝试从本地存储加载保存的主题偏好
  themeManager.loadSavedThemePreference();
});

四、高级实现:暗模式与自定义主题

4.1 标准主题暗模式实现

FullCalendar的标准主题(Standard Theme)支持通过CSS变量自定义样式,实现暗模式非常简单:

/* 自定义标准主题的暗模式变量 */
.fc-theme-standard.dark-mode {
  --fc-page-bg-color: #1a1a1a;
  --fc-neutral-bg-color: #2d2d2d;
  --fc-neutral-text-color: #e0e0e0;
  --fc-border-color: #444;
  --fc-event-bg-color: #3788d8;
  --fc-event-border-color: #2a6daf;
  --fc-event-text-color: #fff;
  --fc-now-indicator-color: #ff6b6b;
}

对应的JavaScript实现:

// 在ThemeManager类中添加暗模式切换方法
toggleDarkMode(enabled) {
  const calendarEl = this.calendar.el;
  
  if (this.currentThemeSystem === 'standard') {
    if (enabled) {
      calendarEl.classList.add('dark-mode');
    } else {
      calendarEl.classList.remove('dark-mode');
    }
    this.saveThemePreference();
  } else {
    // 对于Bootstrap主题,切换到预设的暗模式主题
    if (enabled) {
      this.switchBootstrapTheme('darkly');
    } else {
      this.switchBootstrapTheme(''); // 恢复默认主题
    }
  }
}

4.2 自定义主题变量

通过CSS变量自定义标准主题的外观,实现个性化定制:

// 自定义主题变量
customizeThemeVariables(customVars) {
  if (this.currentThemeSystem !== 'standard') {
    console.warn('自定义变量仅支持标准主题');
    return;
  }
  
  const calendarEl = this.calendar.el;
  
  // 应用自定义CSS变量
  Object.keys(customVars).forEach(key => {
    calendarEl.style.setProperty(`--${key}`, customVars[key]);
  });
  
  // 保存自定义配置
  this.saveCustomThemeVariables(customVars);
}

// 使用示例
themeManager.customizeThemeVariables({
  'fc-event-bg-color': '#6a11cb',
  'fc-event-border-color': '#4a0d91',
  'fc-highlight-color': 'rgba(106, 17, 203, 0.2)',
  'fc-button-bg-color': '#6a11cb',
  'fc-button-text-color': '#fff'
});

4.3 主题预加载与性能优化

为提升用户体验,实现主题资源的预加载和缓存策略:

// 预加载热门主题
preloadPopularThemes() {
  const popularThemes = [
    { system: 'bootstrap5', name: '' },
    { system: 'bootstrap5', name: 'darkly' },
    { system: 'bootstrap5', name: 'solar' },
    { system: 'standard', name: 'light' },
    { system: 'standard', name: 'dark' }
  ];
  
  popularThemes.forEach(theme => {
    if (theme.system.startsWith('bootstrap')) {
      const url = this.generateBootstrapStylesheetUrl(theme.name);
      this.preloadStylesheet(url);
    }
  });
}

// 预加载样式表
preloadStylesheet(url) {
  const link = document.createElement('link');
  link.rel = 'preload';
  link.href = url;
  link.as = 'style';
  document.head.appendChild(link);
}

// 实现持久化缓存
saveThemePreference() {
  const preference = {
    themeSystem: this.currentThemeSystem,
    themeName: this.currentThemeName,
    darkMode: this.currentThemeSystem === 'standard' && 
              this.calendar.el.classList.contains('dark-mode')
  };
  
  localStorage.setItem('fullCalendarThemePreference', JSON.stringify(preference));
}

// 加载保存的主题偏好
loadSavedThemePreference() {
  const saved = localStorage.getItem('fullCalendarThemePreference');
  if (saved) {
    const preference = JSON.parse(saved);
    this.currentThemeSystem = preference.themeSystem;
    this.currentThemeName = preference.themeName;
    
    // 应用保存的主题设置
    this.switchThemeSystem(preference.themeSystem);
    
    // 如果是标准主题且开启了暗模式
    if (preference.themeSystem === 'standard' && preference.darkMode) {
      this.toggleDarkMode(true);
    }
  }
}

五、生产环境优化策略

5.1 主题加载性能对比

不同主题加载策略的性能对比:

加载策略首次加载时间切换时间带宽消耗实现复杂度
传统页面刷新800-1200ms800-1200ms高(完整页面)
动态link加载300-600ms200-400ms中(仅CSS)
预加载+动态切换300-600ms50-100ms高(预加载)
CSS变量切换50-100ms10-30ms低(无额外请求)

5.2 主题加载失败处理

实现健壮的错误处理机制,确保主题加载失败时应用能够优雅降级:

// 增强的样式加载方法
loadStylesheet(url) {
  return new Promise((resolve, reject) => {
    const loadingIndicator = document.getElementById('loading-indicator');
    loadingIndicator.style.display = 'inline';
    
    // 检查缓存中是否有此URL的失败记录
    const failedUrls = JSON.parse(localStorage.getItem('failedStylesheets') || '[]');
    if (failedUrls.includes(url)) {
      console.warn(`Skipping previously failed stylesheet: ${url}`);
      loadingIndicator.style.display = 'none';
      reject(new Error('Previously failed to load stylesheet'));
      return;
    }
    
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = url;
    
    // 设置超时处理
    const timeoutId = setTimeout(() => {
      link.onerror(); // 触发错误处理
    }, 10000); // 10秒超时
    
    link.onload = () => {
      clearTimeout(timeoutId);
      loadingIndicator.style.display = 'none';
      resolve();
    };
    
    link.onerror = () => {
      clearTimeout(timeoutId);
      loadingIndicator.style.display = 'none';
      
      // 记录失败的URL
      failedUrls.push(url);
      localStorage.setItem('failedStylesheets', JSON.stringify(failedUrls));
      
      reject(new Error(`Failed to load stylesheet: ${url}`));
    };
    
    document.head.appendChild(link);
  });
}

5.3 主题切换动画效果

为主题切换添加平滑过渡动画,提升用户体验:

/* 添加主题切换过渡动画 */
#calendar {
  transition: all 0.3s ease-in-out;
}

/* 加载状态动画 */
#loading-indicator {
  display: inline-block;
  width: 20px;
  height: 20px;
  border: 3px solid rgba(0, 0, 0, 0.1);
  border-radius: 50%;
  border-top-color: #3788d8;
  animation: spin 1s ease-in-out infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

六、完整代码示例与应用场景

6.1 完整主题切换组件

整合以上所有功能,实现一个功能完善的主题切换组件:

<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>FullCalendar 动态主题切换示例</title>
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' />

<!-- 基础样式 -->
<style>
  body {
    margin: 0;
    padding: 20px;
    font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
    font-size: 14px;
  }
  
  #theme-controls {
    margin-bottom: 20px;
    padding: 15px;
    background-color: #f5f5f5;
    border-radius: 4px;
  }
  
  .selector {
    margin-right: 15px;
  }
  
  #calendar {
    max-width: 1100px;
    margin: 0 auto;
  }
  
  /* 标准主题暗模式变量 */
  .fc-theme-standard.dark-mode {
    --fc-page-bg-color: #1a1a1a;
    --fc-neutral-bg-color: #2d2d2d;
    --fc-neutral-text-color: #e0e0e0;
    --fc-border-color: #444;
    --fc-event-bg-color: #3788d8;
    --fc-event-border-color: #2a6daf;
    --fc-event-text-color: #fff;
    --fc-now-indicator-color: #ff6b6b;
  }
  
  /* 加载动画 */
  #loading-indicator {
    display: none;
    width: 20px;
    height: 20px;
    border: 3px solid rgba(0, 0, 0, 0.1);
    border-radius: 50%;
    border-top-color: #3788d8;
    animation: spin 1s ease-in-out infinite;
  }
  
  @keyframes spin {
    to { transform: rotate(360deg); }
  }
</style>

<!-- 引入FullCalendar核心库 -->
<script src='https://cdn.bootcdn.net/ajax/libs/fullcalendar/5.11.3/core/main.min.js'></script>
<script src='https://cdn.bootcdn.net/ajax/libs/fullcalendar/5.11.3/interaction/main.min.js'></script>
<script src='https://cdn.bootcdn.net/ajax/libs/fullcalendar/5.11.3/daygrid/main.min.js'></script>
<script src='https://cdn.bootcdn.net/ajax/libs/fullcalendar/5.11.3/timegrid/main.min.js'></script>

<!-- 引入中文本地化 -->
<script src='https://cdn.bootcdn.net/ajax/libs/fullcalendar/5.11.3/core/locales/zh-cn.min.js'></script>
</head>
<body>

  <div id='theme-controls'>
    <div class='selector'>
      主题系统:
      <select id='theme-system-selector'>
        <option value='bootstrap5' selected>Bootstrap 5</option>
        <option value='bootstrap'>Bootstrap 4</option>
        <option value='standard'>标准主题</option>
      </select>
    </div>
    
    <div id='bootstrap-themes' class='selector' style='display:inline-block'>
      主题名称:
      <select id='bootstrap-theme-selector'>
        <option value='' selected>默认</option>
        <option value='darkly'>Darkly (暗模式)</option>
        <option value='cyborg'>Cyborg</option>
        <option value='slate'>Slate</option>
        <option value='solar'>Solar</option>
        <option value='superhero'>Superhero</option>
      </select>
    </div>
    
    <div id='standard-theme-options' class='selector' style='display:none'>
      <button id='toggle-dark-mode' class='btn btn-sm'>切换暗模式</button>
    </div>
    
    <span id='loading-indicator'></span>
  </div>

  <div id='calendar'></div>

  <script>
    document.addEventListener('DOMContentLoaded', function() {
      // 初始化日历
      const calendarEl = document.getElementById('calendar');
      const calendar = new FullCalendar.Calendar(calendarEl, {
        themeSystem: 'bootstrap5',
        locale: 'zh-cn',
        headerToolbar: {
          left: 'prev,next today',
          center: 'title',
          right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
        },
        initialDate: '2023-07-01',
        editable: true,
        selectable: true,
        nowIndicator: true,
        events: [
          {
            title: '全天事件',
            start: '2023-07-01'
          },
          {
            title: '长事件',
            start: '2023-07-07',
            end: '2023-07-10'
          },
          {
            groupId: 999,
            title: '重复事件',
            start: '2023-07-09T16:00:00',
            allDay: false
          },
          {
            title: '会议',
            start: '2023-07-12T10:30:00',
            end: '2023-07-12T12:30:00',
            allDay: false
          },
          {
            title: '午餐',
            start: '2023-07-12T12:00:00',
            allDay: false
          }
        ]
      });
      
      calendar.render();
      
      // 主题管理器实现
      class ThemeManager {
        constructor(calendar) {
          this.calendar = calendar;
          this.currentThemeSystem = 'bootstrap5';
          this.currentThemeName = '';
          this.stylesheetElement = null;
          
          this.initEventListeners();
          this.preloadPopularThemes();
          this.loadSavedThemePreference();
        }
        
        initEventListeners() {
          // 主题系统选择变化
          document.getElementById('theme-system-selector').addEventListener('change', (e) => {
            this.switchThemeSystem(e.target.value);
          });
          
          // Bootstrap主题选择变化
          document.getElementById('bootstrap-theme-selector').addEventListener('change', (e) => {
            this.switchBootstrapTheme(e.target.value);
          });
          
          // 标准主题暗模式切换
          document.getElementById('toggle-dark-mode').addEventListener('click', () => {
            this.toggleDarkMode();
          });
        }
        
        switchThemeSystem(themeSystem) {
          this.currentThemeSystem = themeSystem;
          
          // 显示/隐藏对应主题选项
          if (themeSystem.startsWith('bootstrap')) {
            document.getElementById('bootstrap-themes').style.display = 'inline-block';
            document.getElementById('standard-theme-options').style.display = 'none';
            this.switchBootstrapTheme(this.currentThemeName);
          } else { // standard
            document.getElementById('bootstrap-themes').style.display = 'none';
            document.getElementById('standard-theme-options').style.display = 'inline-block';
            
            // 移除Bootstrap样式
            if (this.stylesheetElement) {
              this.stylesheetElement.remove();
              this.stylesheetElement = null;
            }
            
            // 应用标准主题
            this.calendar.setOption('themeSystem', 'standard');
            this.saveThemePreference();
          }
        }
        
        switchBootstrapTheme(themeName) {
          this.currentThemeName = themeName;
          const stylesheetUrl = this.generateBootstrapStylesheetUrl(themeName);
          
          this.loadStylesheet(stylesheetUrl).then(() => {
            this.calendar.setOption('themeSystem', this.currentThemeSystem);
            this.saveThemePreference();
          }).catch(error => {
            console.error('主题加载失败:', error);
            alert('主题加载失败,请重试');
          });
        }
        
        generateBootstrapStylesheetUrl(themeName) {
          if (this.currentThemeSystem === 'bootstrap5') {
            return themeName 
              ? `https://cdn.bootcdn.net/ajax/libs/bootswatch/5.1.3/${themeName}/bootstrap.min.css`
              : 'https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css';
          } else { // bootstrap 4
            return themeName
              ? `https://cdn.bootcdn.net/ajax/libs/bootswatch/4.6.2/${themeName}/bootstrap.min.css`
              : 'https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.6.2/css/bootstrap.min.css';
          }
        }
        
        loadStylesheet(url) {
          return new Promise((resolve, reject) => {
            const loadingIndicator = document.getElementById('loading-indicator');
            loadingIndicator.style.display = 'inline-block';
            
            const link = document.createElement('link');
            link.rel = 'stylesheet';
            link.href = url;
            
            const timeoutId = setTimeout(() => {
              link.onerror();
            }, 10000);
            
            link.onload = () => {
              clearTimeout(timeoutId);
              loadingIndicator.style.display = 'none';
              
              if (this.stylesheetElement) {
                this.stylesheetElement.remove();
              }
              this.stylesheetElement = link;
              
              resolve();
            };
            
            link.onerror = () => {
              clearTimeout(timeoutId);
              loadingIndicator.style.display = 'none';
              reject(new Error(`加载样式表失败: ${url}`));
            };
            
            document.head.appendChild(link);
          });
        }
        
        toggleDarkMode() {
          const calendarEl = this.calendar.el;
          calendarEl.classList.toggle('dark-mode');
          this.saveThemePreference();
        }
        
        preloadPopularThemes() {
          const popularThemes = [
            { system: 'bootstrap5', name: '' },
            { system: 'bootstrap5', name: 'darkly' }
          ];
          
          popularThemes.forEach(theme => {
            const url = this.generateBootstrapStylesheetUrl(theme.name);
            this.preloadStylesheet(url);
          });
        }
        
        preloadStylesheet(url) {
          const link = document.createElement('link');
          link.rel = 'preload';
          link.href = url;
          link.as = 'style';
          document.head.appendChild(link);
        }
        
        saveThemePreference() {
          const preference = {
            themeSystem: this.currentThemeSystem,
            themeName: this.currentThemeName,
            darkMode: this.currentThemeSystem === 'standard' && 
                      this.calendar.el.classList.contains('dark-mode')
          };
          
          localStorage.setItem('fullCalendarThemePreference', JSON.stringify(preference));
        }
        
        loadSavedThemePreference() {
          const saved = localStorage.getItem('fullCalendarThemePreference');
          if (saved) {
            try {
              const preference = JSON.parse(saved);
              this.currentThemeSystem = preference.themeSystem;
              this.currentThemeName = preference.themeName;
              
              this.switchThemeSystem(preference.themeSystem);
              
              if (preference.themeSystem === 'standard' && preference.darkMode) {
                this.toggleDarkMode();
              }
            } catch (error) {
              console.error('加载主题偏好失败:', error);
              // 加载失败时使用默认设置
            }
          }
        }
      }
      
      // 初始化主题管理器
      const themeManager = new ThemeManager(calendar);
    });
  </script>

</body>
</html>

6.2 应用场景与最佳实践

企业级应用中的主题管理

在企业级应用中,主题切换功能可以进一步扩展为:

  1. 用户主题偏好同步:将主题偏好保存到用户账户,在不同设备间同步
  2. 主题定制平台:提供可视化主题编辑器,允许用户自定义颜色、字体等
  3. 主题权限控制:管理员可以为不同用户组配置不同的主题权限
  4. 定时主题切换:根据时间段自动切换明/暗模式,如白天使用明亮主题,晚上自动切换到暗模式
性能优化最佳实践
  1. 主题预加载策略

    • 预加载用户最可能使用的2-3个主题
    • 利用浏览器空闲时间(Idle Until Urgent)预加载其他主题
    • 使用<link rel="preload">而不是隐藏的<link>元素
  2. 缓存与离线支持

    • 使用Service Worker缓存已加载的主题样式
    • 实现主题资源的离线可用
    • 对失败的主题资源实现指数退避重试
  3. 渐进式主题应用

    • 先应用基础主题保证可用性
    • 异步加载并应用完整主题
    • 使用骨架屏减少加载过程中的视觉跳动

七、总结与展望

FullCalendar的动态主题切换功能通过ThemeSystem选项和动态样式加载实现,为用户提供了个性化的界面体验。本文详细介绍了从基础的主题系统切换到高级的暗模式实现,再到生产环境的性能优化策略,涵盖了主题切换功能开发的各个方面。

随着Web技术的发展,未来的主题系统可能会:

  • 更好地支持CSS自定义属性(CSS Variables)
  • 实现更细粒度的主题定制
  • 提供内置的暗色/浅色模式切换
  • 支持系统级主题偏好检测

通过本文介绍的方法,你可以为FullCalendar添加专业、流畅的主题切换功能,提升应用的用户体验和品牌形象。无论是企业级应用还是个人项目,动态主题切换都将成为提升用户满意度的重要功能。

最后,建议在实际项目中根据需求选择合适的主题实现方案,平衡功能、性能和开发复杂度,为用户提供最佳的使用体验。

【免费下载链接】fullcalendar Full-sized drag & drop event calendar in JavaScript 【免费下载链接】fullcalendar 项目地址: https://gitcode.com/gh_mirrors/fu/fullcalendar

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值