[vuejs]写一个时间块表timetable

291 阅读7分钟

这个是我个人项目的时间表,做一个思路总结。因为只是思路总结,所以在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个数据:

  1. left : 直接根据当天日期的column来绝对定位,所以left可以设置为0
  2. width: 暂且可以设置为当天column的100%宽度就好。
  3. top: 我们要做的时间表起始点是0点,那么假设一个条目是中午12点开始,那么很明显它的top是时间表的50%(不算表头)。所以根据这个思路,我们可以把当天的开始时间到0点的之间的距离换算成毫秒(或者其他单位),然后除以一天的毫秒数,就可以得到它的top值。
  4. 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)
               }
           }

目前为止页面长这样,下滑后会出现下一天的表格 image.png

写出时间块的定位规则

这里加入行内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 + '%'
          },
 

经过这步,我们的时间块已经很开心地正确显示出来啦!

image.png

考虑多个平行时间块的情况

接下来要考虑的是,如果两个时间块中有重叠部分,例如像下面蓝色和黄色小块。在此时,两个小块布局上不变的数据是height和top,但是width和left会发生改变。我们把这两个小块称为是share时间块的block。

image.png

思路:我们重点需要思考的就是新的width和left如何计算。

  1. width用100% 除以一起参与share的个数,比如有两个小块,那么每个是50%;
  2. 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 + '%'
          }

现在有重叠时间的时间块又继续开心地正确显示啦!

image.png

image.png

把响应式大屏幕的样式加上

需要做的事:

  1. 每天数据前的时刻线设置为d-lg-none
  2. 每天的列col-lg
  3. 在最前面加上一个大屏幕时显示的时刻线 d-lg-block d-none col-lg-auto(添加上auto的话,时刻线宽度能遵循我们css里设置的宽度)

其他就是一些细节上的调整

最终完成样子如下,暂时就做到这个程度,因为最主要目的是理一下自己之前做时间表的思路,所以其他细节就先不考虑。

image.png

完整代码

<!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重叠。例如

image.png (还可以有更复杂的嵌套情况) 这里也可以选在排版时把第二个和第三个纵向并排,但这样的处理逻辑会更复杂。所以这里选择把它们作为sharenumber=3来对待。

要解决这个问题,我的思路是: 对每个时间块,遍历它后面的时间块,如果它后面的时间的开始时间小于它的结束时间,那么就把它后面的时间块设定为overlapPrev = true