开篇
功能介绍
- 表格点选和框选功能(没有点击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>
效果演示