这个是我个人项目的时间表,做一个思路总结。因为只是思路总结,所以在css方面会装饰性内容比较少,最主要目的是实现时间块生成。
每个时间条目的基本结构包括:
{
分类名称和颜色(会影响时间块的颜色)
条目描述
起始时间
}
而最终传入时间表的数据就是一个数组,数组基本结构是
[ [星期一的条目],
[星期二的条目],
[...],
...
]
数据准备
先准备好测试数据(因为这个数据也供其他组件使用,所以可能有一些多余数据)。这个数据比较简单,一周内只有一天有两条数据。
weekEntries: [{
"date": "2022-11-21 ",
"timeEntries": [],
"weekday": "MON",
}, {
"date": "2022-11-22",
"weekday": "TUE", // 当周内每一天的日期
"timeEntries": [{ // 当天的时间条目
"id": "8a66daac-74ac-4278-a346-aeff3ddf8e7a ",
"desc": "练习册2页 ",
"cat ": "数学 ",
"color": "#2C9AF4 ", // 时间条目的颜色
"duration ": "1h 0m 0s ",
"durationRaw ": 3600000, // 以毫秒数表示的经历时间
"startRaw ": 1669339740000, // 以毫秒数表示的开始时间
"endRaw ": 1669343340000, // 以毫秒数表示的开始时间
"start": "09:29 ",
"end": "10:29 ",
"time": "09:29- 10:29 ",
}, {
"id": "e732d624-1fc9-4207-a896-ac9a7ec98506 ",
"desc": "语文作业 ",
"cat": "语文 ",
"color": "#EE6363 ",
"duration": "1h 28m 0s ",
"durationRaw": 5280000,
"startRaw": 1669330800000,
"endRaw": 1669336080000,
"start": "07:00 ",
"end": " 08:28 ",
"time": "07:00- 08:28 ",
}],
"weekday": "WED",
}, {
"date": "2022-11-23",
"timeEntries": [],
"weekday": "THU ",
}, {
"date": "2022-11-24",
"timeEntries": [],
"weekday": "FIR",
}, {
"date": "2022-11-25",
"timeEntries": [],
"weekday": "SAT",
}, {
"date": "2022-11-26",
"timeEntries": [],
"weekday": "SUN",
}, ]
}
考虑思路
先不考虑数据生成,而是假设时间表不用自动生成,而是死数据,那可以怎么写?
我的思路会是:直接先把表格做出来,然后上面的时间块用绝对定位来实现。至于绝对定位的位置可以这样分析:需要的其实就是4个数据:
left: 直接根据当天日期的column来绝对定位,所以left可以设置为0width: 暂且可以设置为当天column的100%宽度就好。top: 我们要做的时间表起始点是0点,那么假设一个条目是中午12点开始,那么很明显它的top是时间表的50%(不算表头)。所以根据这个思路,我们可以把当天的开始时间到0点的之间的距离换算成毫秒(或者其他单位),然后除以一天的毫秒数,就可以得到它的top值。height:跟前面思路相同,用时间条目的duration的毫秒数除以一天总毫秒数,就是它所占column这个容器高度的百分比。
还要考虑的一点是: 我们想做的responsive的表格,所以当窗口缩小时,时间表每列也会缩小,窗口缩小到某种程度时,会只显示一天的数据。 响应式这点借助bootstrap来完成。数据中每天的数据生成两列,一列是时间刻度线,一列是实际的时间表和时间块。小屏幕时这两列占满容器宽度。当屏幕大到一定程度时,每天数据中的时间刻度线消失,只显示一个总的刻度线。每天数据水平排列。
先写小屏幕布局,不考虑时间块生成
因为小屏幕要生成的内容更多,每天的内容前都要有一个刻度表,所以先做小屏幕的。
基本思路是每天的内容是一个flex布局,左边是时刻,右边是表格。然后上方都有一个表头。时刻用一个data生成,避免手写。
<body>
<div id="app">
<div class="timetable container-fluid">
<div class="row">
<!-- 每日部分 -->
<div class="col-12 daily" v-for="(dailyData, index) in weekEntries" :key=index>
<!-- 每天数据前面的时间刻度线(小屏幕时出现) -->
<div class="time-marks">
<div class="mark-header table-header time-mark">时刻</div>
<div class="hour-mark table-cell time-mark" v-for="(h,index) in hourMarks" :key="index ">
{{h}}
</div>
</div>
<!-- 每天数据正式内容列-->
<div class="time-content">
<!-- 表头 -->
<div class="time-content-header table-header time-content-cell">{{dailyData.date}} {{dailyData.weekday}}</div>
<!-- 包裹表格线和时间块的wrapper,主要为定位服务 -->
<div class="time-content-wrapper">
<!-- 表格线 -->
<div class="time-content-cell table-cell ti" v-for="(h,index) in hourMarks" :key="index "></div>
<!-- 时间块 -->
<div class="time-content-block" v-for="(entry,index) in dailyData.timeEntries" :key="index">
{{entry.desc}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
css 如下:
.daily {
display: flex;
}
.time-marks {
margin-bottom: 100px;
}
.time-mark {
padding: 0 10px;
background-color: rgb(204, 196, 241);
}
.table-header {
height: 40px;
}
.table-cell {
height: 60px;
border-top: 1px solid rgb(131, 131, 131);
}
.time-content {
width: 100%;
}
.time-content-wrapper {
position: relative;
}
.time-content-header {
background-color: rgb(250, 236, 230);
}
.time-content-cell {
background-color: rgb(246, 246, 246);
padding: 0 20px;
}
.time-content-block {
position: absolute;
top: 0
}
然后data中还会准备一个hourMarks空数组。每次组件mounted之后给它赋值。在我实际项目中,这里判定条件会多一些,例如用户如果设置是12小时制,那么时间会显示成am、pm格式。
generateHourMarks() {
let hourMark
for (let i = 0; i < 24; i++) {
hourMark = i + ':00';
hourMark = i < 10 ? '0' + hourMark : hourMark
this.hourMarks.push(hourMark)
}
}
目前为止页面长这样,下滑后会出现下一天的表格
写出时间块的定位规则
这里加入行内v-bind:style,然后计算高度和top,left暂且设置为0, width设置为100%
<div class="time-content-block" v-for="(entry,index) in dailyData.timeEntries" :key="index" :style="{top: calcBlockTop(entry.start), height: calcBlockHeight(entry.durationRaw), background:entry.color, width: '100%'}">
{{entry.desc}}
</div>
然后是计算top和高度的函数
// 计算每个时间块距离0点线的top值。传入的是类似09:30这样的数据,我们要把它转化成距离0点已过的分钟数
calcBlockTop(start) {
const hourAndMinutes = start.split(':')
let hour
let minutes
hour = Number(hourAndMinutes[0])
minutes = Number(hourAndMinutes[1])
minPast = hour * 60 + minutes
const ratio = minPast / this.minutesPerday
return ratio * 100 + '%'
},
// 计算每个时间块
calcBlockHeight(durationRaw) {
// durationRaw是毫秒数,所以这里把除数也变成毫秒数
const ratio = durationRaw / (this.minutesPerday * 60 * 1000)
console.log(ratio) // 测试用
return ratio * 100 + '%'
},
经过这步,我们的时间块已经很开心地正确显示出来啦!
考虑多个平行时间块的情况
接下来要考虑的是,如果两个时间块中有重叠部分,例如像下面蓝色和黄色小块。在此时,两个小块布局上不变的数据是height和top,但是width和left会发生改变。我们把这两个小块称为是share时间块的block。
思路:我们重点需要思考的就是新的width和left如何计算。
- width用100% 除以一起参与share的个数,比如有两个小块,那么每个是50%;
- left是每个小块的宽度百分比* 它的顺序。假设有3个小块。每个占据33%,那么它们的left分别是:0 * 33%, 1 * 33%, 2 * 33%
由上可得,为了计算width和left,我们需要给每个entry标记两个属性,分别是shareNumber和shareOrder.
以下是标记属性的部分:
// 标记share(有时间上重叠的时间块共用一个表格cell)相关的信息
addShareInfo() {
this.weekEntries.forEach(dailyEntries => {
if (!dailyEntries.timeEntries.length) {
return
}
const timeEntries = dailyEntries.timeEntries
let prev
let curr
let left // 标记开始参与连续share的最左边的index
let right // 标记开始参与连续share的最右边的index
let i = 1
while (i < timeEntries.length) {
console.log(i) //222
curr = timeEntries[i]
prev = timeEntries[i - 1]
if (this.hasOverLap(prev, curr)) {
left = i - 1
while (timeEntries[i] && this.hasOverLap(timeEntries[i - 1], timeEntries[i])) {
i++
}
right = i - 1
// 开始给left到right部分的元素设置share属性
let order = 0 // 最左边元素的order(也就是初始order)为0
for (let j = left; j <= right; j++) {
timeEntries[j].shareNumber = right - left + 1
timeEntries[j].shareOrder = order
order++
}
} else {
i++
}
}
});
},
// 判断两个时间块时间是否有重叠
hasOverLap(prev, curr) {
return prev.endRaw > curr.startRaw
},
html
<div class="time-content-block" v-for="(entry,index) in dailyData.timeEntries" :key="index" :style="{top: calcBlockTop(entry.start), height: calcBlockHeight(entry.durationRaw), background:entry.color, width: calcBlockWidth(entry.shareNumber), left: calcBlockLeft(entry.shareNumber,entry.shareOrder)}">
{{entry.desc}}
</div>
计算宽度和left
// 计算时间块宽度
calcBlockWidth(shareNumber) {
if (!shareNumber) {
return '100%'
}
return 100 / shareNumber + '%';
},
// 计算时间块left值
calcBlockLeft(shareNumber, shareOrder) {
if (!shareNumber || !shareOrder) {
return '0'
}
return 100 / shareNumber * shareOrder + '%'
}
现在有重叠时间的时间块又继续开心地正确显示啦!
把响应式大屏幕的样式加上
需要做的事:
- 每天数据前的时刻线设置为d-lg-none
- 每天的列col-lg
- 在最前面加上一个大屏幕时显示的时刻线 d-lg-block d-none col-lg-auto(添加上auto的话,时刻线宽度能遵循我们css里设置的宽度)
其他就是一些细节上的调整
最终完成样子如下,暂时就做到这个程度,因为最主要目的是理一下自己之前做时间表的思路,所以其他细节就先不考虑。
完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<title>Document</title>
<style>
.timetable {
font-size: 14px;
}
.daily {
display: flex;
border-right: 1px solid rgb(131, 131, 131);
}
.time-marks {
margin-bottom: 100px;
border-left: 1px solid rgb(131, 131, 131);
;
}
.time-mark {
padding: 0 10px;
background-color: rgb(204, 196, 241);
}
.table-header {
height: 40px;
}
.table-cell {
height: 60px;
border-top: 1px solid rgb(131, 131, 131);
}
.time-content {
width: 100%;
}
.time-content-wrapper {
position: relative;
}
.time-content-header {
background-color: rgb(250, 236, 230);
}
.time-content-cell {
background-color: rgb(246, 246, 246);
padding: 0 20px;
}
.time-content-block {
position: absolute;
top: 0
}
</style>
</head>
<body>
<div id="app">
<div class="timetable container-fluid">
<div class="row g-0">
<div class="time-marks d-lg-block d-none col-lg-auto">
<div class="mark-header table-header time-mark">时刻</div>
<div class="hour-mark table-cell time-mark" v-for="(h,index) in hourMarks" :key="index ">
{{h}}
</div>
</div>
<!-- 每日生成一列 -->
<div class="col-12 col-lg daily" v-for="(dailyData, index) in weekEntries" :key=index>
<!-- 每天数据前面的时间刻度线(小屏幕时出现) -->
<div class="time-marks d-lg-none">
<div class="mark-header table-header time-mark">时刻</div>
<div class="hour-mark table-cell time-mark" v-for="(h,index) in hourMarks" :key="index ">
{{h}}
</div>
</div>
<!-- 每天数据正式内容列-->
<div class="time-content">
<!-- 表头 -->
<div class="time-content-header table-header time-content-cell">{{dailyData.date}} {{dailyData.weekday}}</div>
<!-- 包裹表格线和时间块的wrapper,主要为定位服务 -->
<div class="time-content-wrapper">
<!-- 表格线 -->
<div class="time-content-cell table-cell ti" v-for="(h,index) in hourMarks" :key="index "></div>
<!-- 时间块 -->
<div class="time-content-block" v-for="(entry,index) in dailyData.timeEntries" :key="index" :style="{top: calcBlockTop(entry.start), height: calcBlockHeight(entry.durationRaw), background:entry.color, width: calcBlockWidth(entry.shareNumber), left: calcBlockLeft(entry.shareNumber,entry.shareOrder)}">
{{entry.desc}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
<script>
const {
createApp
} = Vue
createApp({
data() {
return {
// 存储时间刻度线数据
minutesPerday: 24 * 60,
hourMarks: [],
weekEntries: [{
"date": "2022-11-21 ",
"timeEntries": [],
"weekday": "MON",
}, {
"date": "2022-11-22",
"weekday": "TUE", // 当周内每一天的日期
"timeEntries": [{ // 当天的时间条目
"desc": "练习册2页",
"cat ": "数学",
"color": "#2C9AF4", // 时间条目的颜色
"duration ": "1h 0m 0s ",
"durationRaw": 3600000, // 以毫秒数表示的经历时间
"startRaw": 1669339740000, // 以毫秒数表示的开始时间
"endRaw ": 1669343340000, // 以毫秒数表示的开始时间
"start": "09:29",
"end": "10:29",
"time": "09:29 - 10:29",
}, {
"desc": "语文作业 ",
"cat": "语文 ",
"color": "#EE6363 ",
"duration": "1h 28m 0s ",
"durationRaw": 5280000,
"startRaw": 1669330800000,
"endRaw": 1669336080000,
"start": "07:00",
"end": "08:28",
"time": "07:00 - 08:28",
},
{
"desc": "数学听课 ",
"cat": "数学",
"color": "#2C9AF4",
"duration": "1h 28m 0s ",
"durationRaw": 5280000,
"startRaw": 1669330800000,
"endRaw": 1669336080000,
"start": "07:00",
"end": "08:28",
"time": "07:00 - 08:28",
}, {
"desc": "英语听课 ",
"cat": "数学",
"color": "#2C9AF4",
"duration": "1h 28m 0s ",
"durationRaw": 5280000,
"startRaw": 1669330800000,
"endRaw": 1669336080000,
"start": "06:00",
"end": "09:28",
"time": "07:00 - 08:28",
}
],
"weekday": "WED",
}, {
"date": "2022-11-23",
"timeEntries": [],
"weekday": "THU ",
}, {
"date": "2022-11-24",
"timeEntries": [],
"weekday": "FIR",
}, {
"date": "2022-11-25",
"timeEntries": [],
"weekday": "SAT",
}, {
"date": "2022-11-26",
"timeEntries": [],
"weekday": "SUN",
}, ]
}
},
methods: {
// 生成时间刻度线数据
generateHourMarks() {
let hourMark
for (let i = 0; i < 24; i++) {
hourMark = i + ':00';
hourMark = i < 10 ? '0' + hourMark : hourMark
this.hourMarks.push(hourMark)
}
},
// 计算每个时间块距离0点线的top值。传入的是类似09:30这样的数据,我们要把它转化成距离0点已过的时间
calcBlockTop(start) {
const hourAndMinutes = start.split(':')
let hour
let minutes
hour = Number(hourAndMinutes[0])
minutes = Number(hourAndMinutes[1])
let minPast = hour * 60 + minutes
const ratio = minPast / this.minutesPerday
return ratio * 100 + '%'
},
// 计算每个时间块高度
calcBlockHeight(durationRaw) {
// durationRaw是毫秒数,所以这里把除数也变成毫秒数
const ratio = durationRaw / (this.minutesPerday * 60 * 1000)
console.log(ratio)
return ratio * 100 + '%'
},
// 标记share(有时间上重叠的时间块共用一个表格cell)相关的信息
addShareInfo() {
this.weekEntries.forEach(dailyEntries => {
if (!dailyEntries.timeEntries.length) {
return
}
const timeEntries = dailyEntries.timeEntries
let prev
let curr
let left // 标记开始参与连续share的最左边的index
let right // 标记开始参与连续share的最右边的index
let i = 1
while (i < timeEntries.length) {
console.log(i) //222
curr = timeEntries[i]
prev = timeEntries[i - 1]
if (this.hasOverLap(prev, curr)) {
left = i - 1
while (timeEntries[i] && this.hasOverLap(timeEntries[i - 1], timeEntries[i])) {
i++
}
right = i - 1
// 开始给left到right部分的元素设置share属性
let order = 0 // 最左边元素的order(也就是初始order)为0
for (let j = left; j <= right; j++) {
timeEntries[j].shareNumber = right - left + 1
timeEntries[j].shareOrder = order
order++
}
} else {
i++
}
}
});
},
// 判断两个时间块时间是否有重叠
hasOverLap(prev, curr) {
return prev.endRaw > curr.startRaw
},
// 计算时间块宽度
calcBlockWidth(shareNumber) {
if (!shareNumber) {
return '100%'
}
return 100 / shareNumber + '%';
},
// 计算时间块left值
calcBlockLeft(shareNumber, shareOrder) {
if (!shareNumber || !shareOrder) {
return '0'
}
return 100 / shareNumber * shareOrder + '%'
}
},
mounted() {
// 给entry增加share相关属性
this.addShareInfo()
console.log(this.weekEntries)
// 生成时间刻度线数值
this.generateHourMarks();
}
}).mount('#app')
</script>
</html>
12.9修正
上面对时间块重叠的判定存在问题,因为时间块重叠有好几种情况。假设时间块都按开始时间排序,我上面只考虑的是相邻时间块重叠的情况,但是有可能出现的情况是:
- 1和2重叠,3和2不重叠,但是和1重叠。例如
(还可以有更复杂的嵌套情况)
这里也可以选在排版时把第二个和第三个纵向并排,但这样的处理逻辑会更复杂。所以这里选择把它们作为sharenumber=3来对待。
要解决这个问题,我的思路是: 对每个时间块,遍历它后面的时间块,如果它后面的时间的开始时间小于它的结束时间,那么就把它后面的时间块设定为overlapPrev = true