使用 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 组件库的强大功能,能够高效地处理房间预订的需求。希望这篇博客能对你的项目开发有所帮助!

相关推荐
轻口味2 分钟前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王37 分钟前
React Hooks
前端·javascript·react.js
迷途小码农零零发1 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀1 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪2 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef3 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6414 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻4 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云4 小时前
npm淘宝镜像
前端·npm·node.js