开篇

功能介绍

  • 表格点选和框选功能(没有点击ctrl键的情况下)
  • 表格点选和框选功能的互斥功能,意思是当现在选择了该单元格后,按住ctrl键再次点击,就会取消选择
  • 当前日期高亮
  • 周、双周、月份模式的切换以及自定义日期的选择
  • 对于可点选和框选功能的范围判断(表头和名称不可选择)

代码实现

index.vue

<template>
  <div class="container">
    <!-- 顶部功能栏区域 -->
    <data class="top-bar">
      <!-- 切换模式按钮 -->
      <el-button size="mini" type="primary" @click="turnWeek"></el-button>
      <el-button size="mini" type="primary" @click="turnTwoWeeks"
        >两周</el-button
      >
      <el-button size="mini" type="primary" @click="turnMonth"></el-button>
      <!-- 自定义时间范围选择器 -->
      <div class="time-range-picker">
        <span class="arrow-icon">
          <el-icon><ArrowLeftBold /></el-icon>
        </span>
        <!-- 起始日期 -->
        <el-date-picker
          class="time-picker"
          v-model="dateForm.startDate"
          type="datetime"
          :shortcuts="shortcuts"
          @change="(value) => handleDateChange('startDate', value)"
        />
        <span style="padding: 0 10px">-</span>
        <!--结束日期 -->
        <el-date-picker
          class="time-picker"
          v-model="dateForm.endDate"
          type="datetime"
          :shortcuts="shortcuts"
          @change="(value) => handleDateChange('endDate', value)"
        />
        <span class="arrow-icon">
          <el-icon><ArrowRightBold /></el-icon>
        </span>
      </div>
    </data>
    <!-- 表格区域 -->
    <div class="table-container">
      <!-- 月份选择 -->
      <month-schedule-table
        :table-data="tableData"
        :switch-model="switchModel"
        :dateForm="dateForm"
        :customDateFlag="customDateFlag"
        @updateDateForm="handleUpdateDateForm"
        :table-width="tableWidth"
      ></month-schedule-table>
    </div>
  </div>
</template>

<script>
import {
  defineComponent,
  onMounted,
  reactive,
  toRefs,
  ref,
  watchEffect,
  onUpdated,
  onUnmounted,
  nextTick,
} from "vue";
import MonthScheduleTable from "./components/MonthScheduleTable.vue";
export default defineComponent({
  name: "DepartmentScheduleConfig",
  components: {
    MonthScheduleTable,
  },
  setup() {
    const state = reactive({
      tableData: [],
      switchModel: "month",
      // 自定义日期选择相关
      dateForm: {
        startDate: "",
        endDate: "",
      },
      customDateFlag: false, // 是否在自定义切换日期的标识
    });

    const tableWidth = ref(0);
    onMounted(() => {
      // 先初始化一些假数据用着
      initData();

      nextTick(() => {
        getTableWidth();
      });
    });

    const getTableWidth = () => {
      // 求表格宽度
      let container = document.querySelector(".container");
      if (container) {
        const observer = new ResizeObserver(() => {
          tableWidth.value = container.clientWidth;
        });

        observer.observe(container);

        // 初始获取一次宽度
        tableWidth.value = container.clientWidth;
      }
    };

    // 每次更新表格时,也要重新获取表格宽度
    onUpdated(() => {
      getTableWidth();
    });

    window.addEventListener("resize", getTableWidth);

    // 页面关闭时,移除事件监听器
    onUnmounted(() => {
      window.removeEventListener("resize", getTableWidth);
    });

    // 切换成周模式
    const turnWeek = () => {
      state.switchModel = "week";
      state.customDateFlag = false;
    };

    // 切换成两周模式
    const turnTwoWeeks = () => {
      state.switchModel = "twoWeeks";
      state.customDateFlag = false;
    };

    // 切换成月模式
    const turnMonth = () => {
      state.switchModel = "month";
      state.customDateFlag = false;
    };

    // 初始化数据
    const initData = () => {
      const obj = {};

      for (let i = 0; i <= 31; i++) {
        obj[`date${i}`] = null;
      }

      const arr = [];
      for (let j = 1; j < 10; j++) {
        const tmpObj = { ...obj };
        tmpObj.id = j;
        tmpObj.name = `zzz${j}`;
        arr.push(tmpObj);
      }
      console.log("arr", arr);
      arr[0].date1 = "X";
      arr[3].date3 = "Y";
      state.tableData = arr;
    };

    /**
     * 自定义日期相关
     */
    // 日期选择面板扩展
    const shortcuts = [
      {
        text: "今天",
        value: new Date(),
      },
      {
        text: "昨天",
        value: () => {
          const date = new Date();
          date.setDate(date.getDate() - 1);
          return date;
        },
      },
      {
        text: "一周前",
        value: () => {
          const date = new Date();
          date.setDate(date.getDate() - 7);
          return date;
        },
      },
    ];

    // 日期change方法
    const handleDateChange = (key, value) => {
      state.dateForm[key] = new Date(value);
      console.log("dateForm", state.dateForm);
      state.customDateFlag = true;
    };

    // 更新日期选择器
    const handleUpdateDateForm = (val) => {
      console.log("val", val);
      state.dateForm["startDate"] = val.startDate;
      state.dateForm["endDate"] = val.endDate;
    };

    return {
      ...toRefs(state),
      turnWeek,
      turnTwoWeeks,
      turnMonth,
      tableWidth,
      shortcuts,
      handleDateChange,
      handleUpdateDateForm,
    };
  },
});
</script>

<style style="scss" scoped>
.container {
  width: 100%;
  .top-bar {
    display: flex;
    .time-range-picker {
      margin-left: 5px;
      .arrow-icon {
        cursor: pointer;
        padding: 8px;
      }
    }
  }
  .table-container {
    width: 100%;
    margin-top: 5px;
  }
}
</style>

MonthScheduleTable.vue

<template>
    <div class="container">
        <!-- 表格区域 -->
        <data 
            class="wrap"
            @mousedown="handleMouseDown"
            @mousemove="handleMouseMove"
            @mouseup="handleMouseUp"
        >
            <!-- 框选矩形 -->
            <div 
                v-if="rectVisible" 
                class="select-rect"
                :style="{ left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px' }"
            ></div>

            <!-- 表格 -->
            <div class="con">
                <el-table 
                    :data="tableData" 
                    border 
                    style="width: 100%" 
                    :cell-style="CellStyle"
                    @cell-click="handleCellClick">
                    <!-- 姓名 -->
                    <el-table-column prop="name" label="姓名" :width="getColumnWidth()"> </el-table-column>
                    <!-- 
                        日期 
                        这一块日期应该根据月、周、两周三种模式来动态显示,不应该写死
                    -->
                    <el-table-column
                        class="headerSelect"
                        :label-class-name="isWeekend(item.date, item.day)"
                        v-for="(item, index) in currDateArr" 
                            :key="index" 
                            :label="item.date"
                        >
                        <el-table-column 
                            :label-class-name="isWeekend(item.date, item.day)"
                            :class-name="isWeekend(item.date, item.day)"
                            :prop="getProp(index)" 
                            :label="item.day" 
                            :class="{'headerSelect': true}"
                            :width="getColumnWidth()">
                            <template #default="scope">
                                <div :data-row="scope.row.name" :data-column="getProp(index)" class="cell-content">
                                    {{ scope.row[getProp(index)] }}
                                </div>
                            </template>
                        </el-table-column>
                    </el-table-column>
                </el-table>
            </div>
        </data>
    </div>
</template>

<script>
import { computed } from '@vue/reactivity';
import { defineComponent, onMounted, reactive, toRefs, watchEffect, ref, onUpdated } from 'vue'
export default defineComponent({
    name: 'MonthScheduleTable',
    emits: ['updateDateForm'],
    props: {
        tableData: {
            type: Array,
            default: () => []
        },
        switchModel: {
            type: String,
            default: 'month'
        },
        tableWidth: {
            type: Number,
            default: 0
        },
        dateForm: {
            type: Object,
            default: () => ({})
        },
        customDateFlag: {
            type: Boolean,
            default: false
        }
    },
    setup(props, { emit }) {
        const state = reactive({
            tableData: computed(() => props.tableData), // 数组数据源
            // 鼠标框选功能相关
            selectedCells: [], // 选择的单元格
            rectVisible: false, // 是否显示框选矩形
            rect: {left: 0, top: 0, width: 0, height: 0}, // 矩形的坐标信息
            downX: 0, // 鼠标按下时的X坐标
            downY: 0, // 鼠标按下时的Y坐标
            upX: 0, // 鼠标松开时的X坐标
            upY: 0, // 鼠标松开时的Y坐标
            isMouseDown: false, // 鼠标是否移动
            isCellClick: false,// 是否是单元格被点击了
        })

        // 给当前的单元格分配prop
        const getProp = idx => {
            return `date${idx}`
        }

        // 判断鼠标是否在可选区域内
        const isInSelectableArea = event => {
            const target = event.target;
            // 查找最近的表头元素(th 或 thead)
            const headerElement = target.closest('th, thead');
            // 如果目标元素位于表头中,返回 false
            if (headerElement) return false;

            const headerSelect = document.querySelector('.con')
            if (!headerSelect) return false;

            const headerRect = headerSelect.getBoundingClientRect()
            const isInHeader = 
                event.clientX >= headerRect.left && 
                event.clientX <= headerRect.right &&
                event.clientY >= headerRect.top && 
                event.clientY <= headerRect.bottom;

            const cell = target.closest('td, th');
            const columnIndex = cell ? cell.cellIndex : undefined;
            
            return isInHeader && columnIndex > 0; // 从第二列开始
        }


        // 判断当前是否只点击了一个单元格
        // 表格单元格点击事件
        const handleCellClick = (row, column, cell, event) => {
            if (!isInSelectableArea(event)) return;

            state.isCellClick = true

            if (event.ctrlKey) { // 判断是否按下了Ctrl键
                // 当鼠标左键+ctrl同时按下时,实现多选功能
                /**
                 * 若当前的cell.classList包含highlight类,则移除,并把该单元格的数据移除出数组;
                 * 若不包含,证明之前并未选择过,则添加hightlight类,并把数据push进数组
                 */
                if (cell.classList.contains('highlight')) {
                    cell.classList.remove('highlight')
                    // 将该单元格的数据移出数组
                    const index = state.selectedCells.findIndex(item => item.row === row.name && item.column === column.property)
                    if (index > -1) {
                        state.selectedCells.splice(index, 1)
                    }
                } else {
                    cell.classList.add('highlight')
                    // 将数据加入数组
                    state.selectedCells.push({ row: row.name, column: column.property, value: row[column.property] });
                }
            } else {
                // 普通高亮的逻辑
                // 清除所有已高亮的单元格
                const highlightedCells = document.querySelectorAll('.highlight')
                highlightedCells.forEach(cell => cell.classList.remove('highlight'))
                // 清空当前已选择的数组
                state.selectedCells = []
                // 将当前单元格高亮
                cell.classList.add('highlight')
                // 将数据加入数组
                state.selectedCells.push({ row: row.name, column: column.property, value: row[column.property] });
            }

            // 将单元格点击标识和鼠标移动标识置为false
            state.isCellClick = false
            state.isMouseDown = false
        }

        // 当鼠标落下时
        const handleMouseDown = event => {
            if (!isInSelectableArea(event)) return;// 判断是否在可选区域内

            state.isMouseDown = true
            /**
             * 在鼠标落下时,应当判断是ctrl+鼠标左键触发的事件,还是直接由鼠标左键触发的事件
             * 若是直接由鼠标左键点击触发的事件,则应该清空当前的selectedCells数组,并移除所有单元格的的高亮
             */
            if (!event.ctrlKey) {
                const highlightedCells = document.querySelectorAll('.highlight')
                highlightedCells.forEach(cell => cell.classList.remove('highlight'))

                state.selectedCells = []
            }
            state.rectVisible = true
            state.downX = event.clientX
            state.downY = event.clientY
            state.upX = event.clientX
            state.upY = event.clientY

            state.rect.left = document.querySelector('.wrap').getBoundingClientRect().left
            state.rect.top = document.querySelector('.wrap').getBoundingClientRect().top
            state.rect.width = 0
            state.rect.height = 0
        }

        // 当鼠标移动时
        const handleMouseMove = event => {
            if (!state.rectVisible || !isInSelectableArea(event)) return;// 判断是否在可选区域内

            if (state.rectVisible) {
                const moveX = event.clientX
                const moveY = event.clientY
                // 计算框选矩形的宽高
                state.rect.width = Math.abs(moveX - state.downX)
                state.rect.height = Math.abs(moveY - state.downY)
                state.rect.left = Math.min(moveX, state.downX) - event.currentTarget.getBoundingClientRect().left
                state.rect.top = Math.min(moveY, state.downY) - event.currentTarget.getBoundingClientRect().top
            }
        }

        // 当鼠标抬起时
        const handleMouseUp = (event) => {
            if (!state.rectVisible || !isInSelectableArea(event)) return;// 判断是否在可选区域内

            if (state.rectVisible) {
                state.rectVisible = false

                // 获取所有单元格
                const cells = document.querySelectorAll('.el-table__body-wrapper td')
                const rect = state.rect

                // 判断是否有一些单元格已经高亮
                let anyHighlighted = false

                // 用于存放被框选的单元格
                const selectedCells = []

                cells.forEach(cell => {
                    const cellRect = cell.getBoundingClientRect()
                    const tableRect = document.querySelector('.wrap').getBoundingClientRect()

                    // 计算相对位置
                    const cellLeft = cellRect.left - tableRect.left
                    const cellTop = cellRect.top - tableRect.top

                    // 判断单元格是否在框选区域内
                    const cellInSelection = (
                        cellLeft < rect.left + rect.width &&
                        cellLeft + cellRect.width > rect.left &&
                        cellTop < rect.top + rect.height &&
                        cellTop + cellRect.height > rect.top
                    )

                    if (cellInSelection) {
                        selectedCells.push(cell)
                    }
                })

                
                if (selectedCells.length > 1) {
                    selectedCells.forEach(sltCell => {
                        // 判断单元格是否已经高亮
                        const isHighlighted = sltCell.classList.contains('highlight')
                        if (isHighlighted) {
                            anyHighlighted = true
                        }
                        // 若使用ctrl+鼠标左键
                        if (event.ctrlKey) {
                            // 若被框选的单元格全都没有高亮,则将这些单元格高亮,并将数据push到数组中
                            if (!anyHighlighted) {
                                sltCell.classList.add('highlight')
                                state.selectedCells.push(getCellData(sltCell))
                            } else {
                                /**
                                 * 若被框选的单元格中,有已经高亮的单元格,则需要把其中高亮的单元格取消高亮,并把这些被取消高亮
                                 * 的单元格的数据从数组中移除
                                 * 同时,把没有高亮的单元格高亮,并将数据push到数组中
                                 */
                                if (isHighlighted) {
                                    sltCell.classList.remove('highlight')
                                    const idxToRemove = state.selectedCells.findIndex(sc => sc.row === getCellData(sltCell).row && sc.column === getCellData(sltCell).column)
                                    if (idxToRemove > -1) {
                                        state.selectedCells.splice(idxToRemove, 1)
                                    }
                                } else {
                                    // 若当前没有高亮的,则高亮,并把数据添加到数组中
                                    sltCell.classList.add('highlight')
                                    state.selectedCells.push(getCellData(sltCell))
                                }
                            }
                        } else {
                            // 普通点击框选事件
                            sltCell.classList.add('highlight')
                            state.selectedCells.push(getCellData(sltCell))
                        }                            
                    })
                }
            }
        }

        // 获取单元格数据
        const getCellData = cell => {
            const cellContent = cell.querySelector('.cell-content')
            if (cellContent) {
                const row = cellContent.dataset.row
                const column = cellContent.dataset.column
                const value = cellContent.textContent

                return { row, column, value }
            }
        }

        // 根据当前的模式,动态获取数据
        const daysOfWeek = ['日', '一', '二', '三', '四', '五', '六' ]

        // 月份模式
        const getMonthDays = () => {
            const days = []

            let currYear = new Date().getFullYear()
            let currMonth = new Date().getMonth()
            // 获取当前月的第一天
            const startDate  = new Date(currYear, currMonth, 2)
            startDate.setHours(0, 0, 0, 0)  // 确保是当天的0点

            // 获取当前月的最后一天
            const endDate = new Date(currYear, currMonth + 1, 0)
            endDate.setHours(23, 59, 59, 999)  // 确保是当天的最后一毫秒

            const date = new Date(new Date(currYear, currMonth, 1))
            while(date.getMonth() === currMonth) {
                days.push({
                    day: date.getDate().toString(),
                    date: daysOfWeek[date.getDay()]
                })
                date.setDate(date.getDate() + 1)
            }

            // 转化为时间选择器可以使用的格式
            const minDateFormatted = startDate.toISOString().slice(0, 10)
            const maxDateFormatted = endDate.toISOString().slice(0, 10)

            emit('updateDateForm', { startDate: minDateFormatted, endDate: maxDateFormatted })

            return days
        }

        // 一周模式
        const getWeekDays = () => {
            const days = []

            const currDay = new Date().getDay()
            const startDate = new Date(new Date()) 
            // 当选择了这些模式之后,应该把开始日期和结束日期传给父组件,以便父组件上的时间选择器来展示
            // 找到最小和最大的日期
            startDate.setDate(new Date().getDate() - currDay + 1) // 获取当前周的周一
            // 获取当前格式的当前周的周日
            const endDate = new Date(startDate)
            endDate.setDate(startDate.getDate() + 6)
            
            for (let i = 0; i < 7; i++) {
                const d = new Date(startDate)
                d.setDate(startDate.getDate() + i)
                days.push({
                    day: d.getDate().toString(),
                    date: daysOfWeek[d.getDay()]
                })
            }

            // 转化为时间选择器可以使用的格式
            const minDateFormatted = startDate.toISOString().slice(0, 10)
            const maxDateFormatted = endDate.toISOString().slice(0, 10)

            emit('updateDateForm', { startDate: minDateFormatted, endDate: maxDateFormatted })

            return days
        }

        // 两周模式
        const getTwoWeeksDays = () => {
            const days = []

            const currDay = new Date().getDay()
            const startDate = new Date(new Date()) 
            startDate.setDate(new Date().getDate() - currDay + 1) // 获取当前周的周一
            // 获取当前格式的当前周的周日
            const endDate = new Date(startDate)
            endDate.setDate(startDate.getDate() + 13)
            
            for (let i = 0; i < 14; i++) {
                const d = new Date(startDate)
                d.setDate(startDate.getDate() + i)
                days.push({
                    day: d.getDate().toString(),
                    date: daysOfWeek[d.getDay()]
                })
            }

            // 转化为时间选择器可以使用的格式
            const minDateFormatted = startDate.toISOString().slice(0, 10)
            const maxDateFormatted = endDate.toISOString().slice(0, 10)

            emit('updateDateForm', { startDate: minDateFormatted, endDate: maxDateFormatted })

            return days
        }

        // 自定义选择日期模式
        const getCustomDateRange = (startDate, endDate) => {
            const days = []

            const start = new Date(startDate)
            const end = new Date(endDate)
            const date = new Date(start)

            while (date <= end) {
                days.push({
                    day: date.getDate().toString(),
                    date: daysOfWeek[date.getDay()]
                })
                date.setDate(date.getDate() + 1)
            }

            return days
        }

        // 获取当前日期
        const isCrrentDay = () => {
            let d = new Date().getDate()
            return d.toString()
        }

        // 判断是否是周末
        const isWeekend = (date, day) => {
            if (day === isCrrentDay()) return 'currDay';
            if (date === '六' || date === '日') return 'weekend';
            else return ''
        }

        

        const headerCellStyle = (row, column, rowIndex, columnIndex) => {
            // console.log('row', row);
        }

        const CellStyle = (row, column) => { 
            // console.log('row', row);
            if (row.column.className === 'weekend') {
                return {
                    backgroundColor: 'rgb(116, 107, 230)'
                }
            }
            
        }

        const currDateArr = computed(() => {
            if (!props.customDateFlag && props.switchModel === 'month') {
                return getMonthDays()
            } else if (!props.customDateFlag && props.switchModel === 'week') {
                return getWeekDays()
            } else if (!props.customDateFlag && props.switchModel === 'twoWeeks') {
                return getTwoWeeksDays()
            } else if (props.customDateFlag) {
                return getCustomDateRange(props.dateForm.startDate, props.dateForm.endDate)
            }
        })

        var currWidth = ref(0)
        watchEffect(() => {
            currWidth.value = computed(() => props.tableWidth).value

            // 根据当前日期,给表头设置背景
            if (currDateArr.value.length > 0) {
                const ths = document.querySelectorAll('.el-table__header .el-table__cell')
                if (ths.length > 0) {
                    // 获取当前日期
                    let date = new Date().getDay()
                    console.log('date', date);
                }
            }
        })

        onUpdated(() => {
            // 根据当前日期,给表头设置背景
            if (currDateArr.value.length > 0) {
                const ths = document.querySelectorAll('.el-table__header .el-table__cell')
                if (ths.length > 0) {
                    // 获取当前日期
                    let date = new Date().getDay()
                }
            }
        })

        // 动态设置列的宽度
        const getColumnWidth = () => {
            const containerWidth = currWidth.value // 减去name列的值
            const columnCount = currDateArr.value.length + 1
            return `${Math.floor(containerWidth / columnCount)}px`
        }

        return {
            ...toRefs(state),
            handleCellClick,
            currDateArr,
            getProp,
            handleMouseDown,
            handleMouseMove,
            handleMouseUp,
            getColumnWidth,
            isWeekend,
            headerCellStyle,
            CellStyle
        }
    }
})
</script>

<style style="scss" scoped>
/* 当单元格被选择时,高亮 */
::v-deep .el-table td.highlight {
  background-color: yellow!important;
  color: red;
}

::v-deep .el-table thead th.weekend {
    background-color: rgb(116, 107, 230)!important;
}

::v-deep .el-table th.currDay {
    background-color: green!important;
}

::v-deep .el-table .headerSelect {
  background-color: green!important;
}

.container {
    width: 100%;
    .wrap {
        width: 100%;
        height: 100vh;
        position: relative;
        display: flex;
        /* 子项超出容器宽度时自动换行 */
        flex-wrap: wrap;
        /* 禁止用户复制文本 */
        user-select: none;
        .select-rect {
            position: absolute;
            border: 1px dashed #999;
            background-color: rgba(0,0,0,0.1);
            z-index: 1000;
            pointer-events: none;
        }

        .con {
            max-width: 100%;
        }
    }
}
</style>

效果演示

基于el-table的表格点选和框选功能-LMLPHP
基于el-table的表格点选和框选功能-LMLPHP
基于el-table的表格点选和框选功能-LMLPHP

08-05 02:49