使用 Vue 3 和 Element Plus 构建动态酒店日历组件

简单的酒店日程日历展示

在本篇博客中,我将分享如何使用 Vue 3、 Element Plus、 date-fns 创建一个动态日历组件,该组件支持房间预订管理功能。这个组件不仅美观,而且用户友好,能够直观地展示每个房间在不同日期的可用性和预订情况。

项目简览

概述

我们构建的这个日历组件具有以下功能:

  • 用户可以选择日期,并查看相应日期的房间可用性。
  • 每个房间在特定日期可以被预订,用户可以输入客户姓名进行预订。
  • 组件支持月份的前后切换,以便于用户快速浏览不同月份的房间状态。
  • 在表格中,已预订的房间会显示客户的名字,而可用房间则提示"可用"。
  • 在编辑状态下,用户可以输入客户信息并保存预订。

概览

初始化展示当天所在月的预定情况

可选定日期进行跳转 并高亮与居中选中日期

可以通过按钮跳转上个月与下个月 来查看预定信息

点击预定信息已预定的房间 可切换为可用 再次点击输入用户信息来进行预定

组件结构

模板部分

在组件的模板中,我们使用了 Element Plus 的表格组件 (el-table) 来展示房间信息和预订状态。下面是组件的核心结构:

ini 复制代码
<template>
  <div class="calendar">
    <div class="controls">
      <el-button @click="previousMonth">上个月</el-button>
      <el-date-picker
          v-model="selectedDate"
          type="date"
          format="YYYY-MM-DD"
          placeholder="选择日期"
          @change="handleDateChange"
      />
      <el-button @click="nextMonth">下个月</el-button>
    </div>
    <el-table
        ref="tableRef"
        :data="tableData"
        stripe
        border
        style="width: 100%"
        :cell-style="columnStyle"
        :header-cell-style="columnStyle"
    >
      <el-table-column
          fixed
          label="房间类型"
          prop="roomType"
          width="150"
          align="center"
      ></el-table-column>
      <el-table-column
          v-for="(day, index) in days"
          :key="index"
          align="center"
          :label="day.label"
          :prop="day.prop"
          :width="150"
      >
        <template #default="scope">
          <div v-if="isEditing(scope.row, day.prop)" class="editing-cell">
            <el-input
                v-model="scope.row[day.prop].customerName"
                placeholder="输入用户名"
                @blur="saveBooking(scope.row, day.prop)"
                @keyup.enter="saveBooking(scope.row, day.prop)"
            />
          </div>
          <div
              v-else
              :class="['cell-content', { 'non-clickable': scope.row.roomType === '剩几间房' }]"
              @click="handleCellClick(scope.row, day.prop)"
          >
            <span v-if="scope.row.roomType === '剩几间房'">
              {{ scope.row[day.prop] }}
            </span>
            <span v-else-if="isBooked(scope.row, day.prop)">
              已预订<br>{{ scope.row[day.prop].customerName }}
            </span>
            <span v-else>
              可用
            </span>
          </div>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

状态管理

我们使用了 Vue 的响应式特性来管理组件状态,包括选定的日期、当前月份的开始日期、天数和表格数据。以下是部分状态定义代码:

ini 复制代码
const selectedDate = ref(new Date());
const currentMonthStartDate = ref(startOfMonth(selectedDate.value));
const days = ref([]);
const tableData = ref([]);

生成月份天数

通过 generateMonthDays 函数,我们动态生成当前月份的天数,并为每一天设置标签和属性:

ini 复制代码
const generateMonthDays = () => {
  const daysArray = [];
  const start = startOfMonth(currentMonthStartDate.value);
  const daysInMonth = getDaysInMonth(start);
  for (let i = 0; i < daysInMonth; i++) {
    const date = addDays(start, i);
    daysArray.push({
      label: format(date, 'dd\nEEE', {locale: zhCN}),
      prop: `day${i + 1}`
    });
  }
  return daysArray;
};

更新表格数据

表格数据的更新通过 updateTableData 函数来实现,我们根据选定的月份和房间的预订状态来填充表格数据:

ini 复制代码
const updateTableData = () => {
  // ...更新逻辑
  const roomData = Array.from({length: 5}, (_, rowIndex) => {
    const row = {roomType: `房间${rowIndex + 1}`};
    // 随机生成房间状态
    return row;
  });

  // 添加剩余房间数的行
  const availableRoomsRow = {roomType: '剩几间房'};
  // ...计算剩余房间数
  tableData.value = [availableRoomsRow, ...roomData];
};

处理用户交互

我们通过事件处理函数如handleCellClick和saveBooking来处理用户的点击和保存操作。这些函数确保用户能够轻松地输入预订信息,并实时更新房间的状态。

ini 复制代码
// 处理单元格点击事件
const handleCellClick = (row, prop) => {
  if (row.roomType === '剩几间房') return;
  if (row[prop].status === '可用') {
    editingCell.value = {rowProp: row.roomType, dayProp: prop};
    row[prop].customerName = '';
  } else {
    row[prop] = {status: '可用', customerName: ''};
    updateAvailableRooms(prop);
  }
};

// 保存预订信息
const saveBooking = (row, prop) => {
  const customerName = row[prop].customerName.trim();
  if (customerName !== '') {
    row[prop].status = '已预订';
  } else {
    row[prop].status = '可用';
  }
  editingCell.value = {rowProp: null, dayProp: null};
  updateAvailableRooms(prop);
};

优化用户体验

通过scrollToSelectedDate和columnStyle来处理选定日期居中和高亮

ini 复制代码
const scrollToSelectedDate = (date) => {
  const selectedDay = format(date, 'dd');
  const columnIndex = parseInt(selectedDay) - 1; // 获取列索引,假设日期为 01 - 31 对应索引 0 - 30
  const columnWidth = 150; // 根据你的列宽设置
  const tableWidth = tableRef.value.$el.offsetWidth;

  // 计算居中位置
  const scrollLeft = (columnIndex + 1) * columnWidth - (tableWidth / 2) + (columnWidth / 2);

  // 调用 setScrollLeft 滚动到对应列
  if (tableRef.value) {
    tableRef.value.setScrollLeft(scrollLeft);
  }
};

const columnStyle = ({row, column}) => {
  const dayProp = column.property;
  if (selectedDate.value && isCurrentDay(dayProp)) {
    return {backgroundColor: '#1C86EE', color: '#fff'}; // 高亮的样式
  }
  return {};
};

样式

我们为组件添加了一些基本样式,使其更加美观和易于使用:

css 复制代码
.calendar {
  max-width: 100%;
  margin: 0 auto;
}

.controls {
  display: flex;
  justify-content: space-between;
  margin-bottom: 10px;
}

.cell-content {
  cursor: pointer;
  white-space: pre-line;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

完整的代码

ini 复制代码
<template>
  <div class="calendar">
    <div class="controls">
      <el-button @click="previousMonth">上个月</el-button>
      <el-date-picker
          v-model="selectedDate"
          type="date"
          format="YYYY-MM-DD"
          placeholder="选择日期"
          @change="handleDateChange"
      />
      <el-button @click="nextMonth">下个月</el-button>
    </div>
    <el-table
        ref="tableRef"
        :data="tableData"
        stripe
        border
        style="width: 100%"
        :cell-style="columnStyle"
        :header-cell-style="columnStyle"
    >
      <el-table-column
          fixed
          label=""
          prop="roomType"
          width="150"
          align="center"
      ></el-table-column>
      <el-table-column
          v-for="(day, index) in days"
          :key="index"
          align="center"
          :label="day.label"
          :prop="day.prop"
          :width="150"
          v-memo="[day, tableData]"
      >
        <template #default="scope">
          <div v-if="isEditing(scope.row, day.prop)" class="editing-cell">
            <el-input
                v-model="scope.row[day.prop].customerName"
                placeholder="输入用户名"
                @blur="saveBooking(scope.row, day.prop)"
                @keyup.enter="saveBooking(scope.row, day.prop)"
            />
          </div>
          <div
              v-else
              :class="['cell-content', { 'non-clickable': scope.row.roomType === '剩几间房' }]"
              @click="handleCellClick(scope.row, day.prop)"
          >
            <span v-if="scope.row.roomType === '剩几间房'">
              {{ scope.row[day.prop] }}
            </span>
            <span v-else-if="isBooked(scope.row, day.prop)">
              已预订<br>{{ scope.row[day.prop].customerName }}
            </span>
            <span v-else>
              可用
            </span>
          </div>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import {ref, watch, onMounted} from 'vue';
import {format, startOfMonth, addDays, subMonths, addMonths, getDaysInMonth} from 'date-fns';
import {zhCN} from 'date-fns/locale';

// 状态定义
const selectedDate = ref(new Date());
const currentMonthStartDate = ref(startOfMonth(selectedDate.value));
const days = ref([]);
const tableData = ref([]);
const editingCell = ref({rowProp: null, dayProp: null});
const dataCache = ref({});

// 创建 el-table 的引用
const tableRef = ref(null);

// 生成当前月的天数
const generateMonthDays = () => {
  const daysArray = [];
  const start = startOfMonth(currentMonthStartDate.value);
  const daysInMonth = getDaysInMonth(start);
  for (let i = 0; i < daysInMonth; i++) {
    const date = addDays(start, i);
    daysArray.push({
      label: format(date, 'dd\nEEE', {locale: zhCN}),
      prop: `day${i + 1}`
    });
  }
  return daysArray;
};

// 更新表格数据
const updateTableData = () => {
  if (!selectedDate.value) return;

  const monthKey = format(currentMonthStartDate.value, 'yyyy-MM');
  if (dataCache.value[monthKey]) {
    tableData.value = dataCache.value[monthKey];
    days.value = generateMonthDays();
    return;
  }

  days.value = generateMonthDays();
  const roomData = Array.from({length: 5}, (_, rowIndex) => {
    const row = {roomType: `房间${rowIndex + 1}`};
    days.value.forEach((day) => {
      const isBooked = Math.random() > 0.3;
      if (isBooked) {
        const customerName = `客户${Math.floor(Math.random() * 100) + 1}`;
        row[day.prop] = {status: "已预订", customerName};
      } else {
        row[day.prop] = {status: "可用", customerName: ""};
      }
    });
    return row;
  });

  const availableRoomsRow = {roomType: '剩几间房'};
  days.value.forEach((day) => {
    const availableCount = roomData.reduce((count, room) => {
      return room[day.prop].status === '可用' ? count + 1 : count;
    }, 0);
    availableRoomsRow[day.prop] = `剩余${availableCount}间`;
  });

  tableData.value = [availableRoomsRow, ...roomData];
  dataCache.value[monthKey] = tableData.value;
};

// 处理单元格点击事件
const handleCellClick = (row, prop) => {
  if (row.roomType === '剩几间房') return;
  if (row[prop].status === '可用') {
    editingCell.value = {rowProp: row.roomType, dayProp: prop};
    row[prop].customerName = '';
  } else {
    row[prop] = {status: '可用', customerName: ''};
    updateAvailableRooms(prop);
  }
};

// 保存预订信息
const saveBooking = (row, prop) => {
  const customerName = row[prop].customerName.trim();
  if (customerName !== '') {
    row[prop].status = '已预订';
  } else {
    row[prop].status = '可用';
  }
  editingCell.value = {rowProp: null, dayProp: null};
  updateAvailableRooms(prop);
};

// 更新剩余房间数
const updateAvailableRooms = (prop) => {
  const availableCount = tableData.value.slice(1).reduce((count, room) => {
    return room[prop].status === '可用' ? count + 1 : count;
  }, 0);
  tableData.value[0][prop] = `剩余${availableCount}间`;
};

// 判断单元格是否正在编辑
const isEditing = (row, prop) => {
  return editingCell.value.rowProp === row.roomType && editingCell.value.dayProp === prop;
};

// 判断单元格是否已预订
const isBooked = (row, prop) => {
  return row[prop].status === '已预订';
};

// 导航功能
const previousMonth = () => {
  currentMonthStartDate.value = subMonths(currentMonthStartDate.value, 1);
  selectedDate.value = currentMonthStartDate.value;
  resetScroll();
};

const nextMonth = () => {
  currentMonthStartDate.value = addMonths(currentMonthStartDate.value, 1);
  selectedDate.value = currentMonthStartDate.value;
  resetScroll();
};

// 重置滚动条
const resetScroll = () => {
  if (tableRef.value) {
    tableRef.value.setScrollLeft(0); // 将横向滚动条位置设置为 0
  }
};

// 处理日期变化
const handleDateChange = (date) => {
  if (!date) return
  selectedDate.value = date;
  currentMonthStartDate.value = startOfMonth(date);

  // 调用滚动函数
  scrollToSelectedDate(date);
};

const scrollToSelectedDate = (date) => {
  const selectedDay = format(date, 'dd');
  const columnIndex = parseInt(selectedDay) - 1; // 获取列索引,假设日期为 01 - 31 对应索引 0 - 30
  const columnWidth = 150; // 根据你的列宽设置
  const tableWidth = tableRef.value.$el.offsetWidth;

  // 计算居中位置
  const scrollLeft = (columnIndex + 1) * columnWidth - (tableWidth / 2) + (columnWidth / 2);

  // 调用 setScrollLeft 滚动到对应列
  if (tableRef.value) {
    tableRef.value.setScrollLeft(scrollLeft);
  }
};

// 判断当前列是否是选择的日期
const isCurrentDay = (dayProp) => {
  const selectedDay = format(selectedDate.value, 'dd');
  return dayProp === `day${selectedDay * 1}`;
};

const columnStyle = ({row, column}) => {
  const dayProp = column.property;
  if (selectedDate.value && isCurrentDay(dayProp)) {
    return {backgroundColor: '#1C86EE', color: '#fff'}; // 高亮的样式
  }
  return {};
};

// 监听月份变化,更新表格数据
watch(currentMonthStartDate, updateTableData);

// 初始化
onMounted(() => {
  updateTableData();
});
</script>

<style scoped>
.calendar {
  max-width: 100%;
  margin: 0 auto;
}

.controls {
  display: flex;
  justify-content: space-between;
  margin-bottom: 10px;
}

.header-cell {
  white-space: pre-line;
  text-align: center;
}

.cell-content {
  cursor: pointer;
  white-space: pre-line;
  display: flex;
  align-items: center;
  justify-content: center; /* 使内容居中 */
  height: 100%; /* 确保高度填充 */
}


.cell-content.non-clickable {
  cursor: default; /* 禁用点击手势 */
}

.editing-cell {
  padding: 5px;
}
</style>

总结

通过本次分享,我们学习了如何使用 Vue 3 和 Element Plus 创建一个功能完备的动态日历组件。这个组件展示了 Vue 的响应式能力和 Element Plus 组件库的强大功能,能够高效地处理房间预订的需求。希望这篇博客能对你的项目开发有所帮助!

相关推荐
我要洋人死42 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9151 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
逐·風6 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#