项目:Vue 实现课程表和选课工具
背景
暑假实习期间写的项目。
虽然 CityU 有一个历史悠久的第三方选课工具 Festival Jog,但是同个作者正在开发中的 新版 令人一言难尽(尽管我知道还在开发中,但是交互体验下降了很多),我就想着自己写一个也不是很难。
环境和工具
- 环境:Node.js v16.17.0
- 框架:Vue 3.3.4 + Vite 4.4.6
- UI 库:Element Plus 2.0.6
- IDE:WebStorm 2023.1
- 部署:GitHub Pages
使用组合式 API (Composition API) 编写。
使用 Event Bus 进行组件间通信。
组合式 API 更符合我的习惯,但是网上大部分教程都是基于选项式 API (Options API) 的(因为 Vue 2.x 默认使用选项式 API),所以有很多地方都是自己摸索的。
准备工作
课程数据
从 AIMS 获取课程数据。
要想直接从 HTML 中获取课程数据是不可能的,因为学校网站都强制 2FA 登录。
模拟浏览器的 Selenium 是可行的,但是因为效率低下,只作为不得已的方案。
不过,通过 F12 的 Network 面板,可以找到获取数据的 Fetch/XHR 请求,提取 API 地址,这样就能不登录直接获取数据了。
可以参考这则问答。
处理课程数据
爬取完成后,得到上千个 .json
文件,每个文件包含一个课程的具体信息。
为了方便后续处理,需要将这些 .json
文件合并成一个大的 .json
文件。
这里我犯了很大的错误,导致后面不得不反复写 python 代码修改文件结构。
结论而言,在 json
中尽量不要用数组,尤其是嵌套的数组。
比如,要标记 CS1102
和 CS1302
的科目为 CS
,不应该这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| { "subjects": { "CS": [ { "code": "CS1102", "name": "Introduction to Computer Studies" }, { "code": "CS1302", "name": "Introduction to Computer Programming" } ] } }
|
这样 CS
就是一个数组,而不是一个对象,会导致后续处理非常麻烦。
正确的作法是把 CS
作为各个 course 对象的属性,这样就能直接用 for...in
遍历了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| { "courses": { "CS1102": { "code": "CS1102", "name": "Introduction to Computer Studies", "subject": "CS" }, "CS1302": { "code": "CS1302", "name": "Introduction to Computer Programming", "subject": "CS" } } }
|
数据
经过准备工作,得到两个 .json
文件:
CourseList.json
:所有课程的基础信息,包括科目代码、科目名称、学分、是否支持线上选课。
CourseDetail.json
:所有课程的具体信息,包括各个 session 的 CRN、上课时间、上课地点、选课限制等。
CourseList.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "subject": [ "AC","ADSE", ],"course": [ { "courseCode": "AC4161", "courseTitle": "Accounting Information Systems and Emerging Technologies", "creditUnits": "3", "webEnabled": "Y", "courseLevel": ["B"], "subject":"AC" }, ] }
|
subject
:所有学科代码的数组。这个数组让页面直接知道有哪些学科,方便筛选。实际生产中,这个数组应该是动态生成的。
course
:所有课程基础信息的数组。
CourseDetail.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| { "AC3202": { "subjectCode": "AC", "courseCode": "AC3202", "courseTitle": "Corporate Accounting I", "creditUnits": "3", "courseLevel": ["B"], "activities": [ { "crn": "10011", "day": "T", "startTime": "09:00", "endTime": "11:50", "bldg": "YEUNG", "room": "G4702", "section": "S01", "web": true, "category": "0" }, ], "category": [ {"0": ["only for Major: AC"]}, {"A": ["only for Minor: AC"]}, {"B": ["only for College: CB","not for Major: AC"]} ] }, }
|
- 每个课程作为一个对象,对象的 key 是课程代码。
- 有部分信息是和课程基础信息重复的,因为传参时只有一个
courseCode
,直接通过 CourseDetail[courseCode]
就能获取到所有信息。
activities
:所有 session 的数组。
category
:每个 category 的选课限制。和 activities[]
中的 category
对应。在原始数据中,每个 session 都有独立的选课限制。由于 category 相同的 session 一般都是同一种限制,并且下面的课程详情表是按照 category 分组的,所以这里把 category 作为 key,把限制信息作为 value,放在一个数组中。
功能
课程列表
显示所有课程的表格,包括课程代码、课程名称、学分、是否支持线上选课,和查阅官网、查看 session 的两个按钮。
- 支持用课程代码、课程名称、学科、课程级别进行搜索和筛选。
- 支持分页、快速跳转、自定义每页数量。
- 在输入框中回车或点击搜索按钮时,会触发搜索。
<template>
1 2 3 4 5 6 7 8 9 10 11
| <div class="search-container"> <el-input class="search-input" placeholder="Course Code or Title" v-model="queryText" clearable @keyup.enter.native="search"></el-input> <el-select class="search-select" v-model="querySubject" placeholder="Subject" clearable> <el-option v-for="item in subjectList" :key="item.value" :label="item.label" :value="item.value"></el-option> </el-select> <el-select class="search-select" v-model="queryLevel" placeholder="Level" clearable> <el-option v-for="item in levelList" :key="item.value" :label="item.label" :value="item.value"></el-option> </el-select> <el-button class="search-button" type="primary" @click="search">Search</el-button> </div>
|
<script>
上面的 HTML 代码中出现了很多变量,先定义一下:
1 2 3
| const queryText = ref(''); const querySubject = ref(''); const queryLevel = ref('');
|
这里就要说到 Vue 一个很重要的概念:响应式。虽然变量是用 const
定义的,但是它们实质是 Vue 提供的一个 reference,类似于 C 中的 const int *
,其指向的对象不可变,但是对象本身是可变的。
当输入框中的文本发生变化时,queryText
会自动更新,而不需要像原生 JS 一样手动监听 input
事件。
搜索功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| function search() { const query = queryText.value.toLowerCase(); const subject = querySubject.value; const level = queryLevel.value; if (!query && !subject && !level) { resultList.value = fullList.value; total.value = resultList.value.length; updateCurrentList(); return; } resultList.value = fullList.value.filter(course => { if (query && !course.courseCode.toLowerCase().includes(query) && !course.courseTitle.toLowerCase().includes(query)) { return false; } if (subject && course.subject !== subject) { return false; } if (level && !course.courseLevel.includes(level)) { return false; } return true; });
total.value = resultList.value.length; updateCurrentList(); }
|
filter()
方法返回一个新数组,新数组中的元素是原数组中符合条件的元素。类似于 Java 中的 stream().filter()
。
分页功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const pagesize = ref(10); const currentPage = ref(1); const total = ref(0); function handleSizeChange(val) { pagesize.value = val; currentPage.value = 1; updateCurrentList(); } function handleCurPageChange(val) { currentPage.value = val; updateCurrentList(); } function updateCurrentList() { const start = (currentPage.value - 1) * pagesize.value; const end = start + pagesize.value; currentList.value = resultList.value.slice(start, end); }
|
分页的思路其实很简单:只要知道完整的数组,就能根据当前页码和每页数量计算出当前页首尾元素的下标。
当 end
下标超出数组长度时,slice()
方法会自动截断,所以不需要额外处理。
课程详情
点击课程列表中的查看按钮,下方的表格会显示该课程的所有 session。
- 根据 category 分组,使用 tab 切换。
- 显示每个 category 对应的选课限制。
<template>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| <div class="tab-container"> <el-tabs value="activeTab" @tab-click="handleTabClick" :type="'card'"> <el-tab-pane v-for="item in tabs" :key="item" :label="item" :name="item"></el-tab-pane> </el-tabs> <p v-if="restrictions[activeTab] !== undefined">Restriction:</p> <ul> <li v-for="item in restrictions[activeTab]" :key="item">{{item}}</li> </ul> <div class="table-container"> <el-table :data="tableItems[activeTab]" border stripe :header-cell-style="{background:'#d9e5fd', color:'black', fontWeight: 1000}" :row-style="{height: '0'}" :cell-style="{padding: '0'}" > <el-table-column class="table-short" prop="crn" label="CRN" min-width="1"></el-table-column> <el-table-column class="table-short" prop="section" label="Section" min-width="1"></el-table-column> <el-table-column class="table-medium" prop="dayTime" label="Time" min-width="2"></el-table-column> <el-table-column class="table-medium" prop="buildingRoom" label="Venue" min-width="2"></el-table-column> <el-table-column class="table-short" prop="webEnabled" label="Web" min-width="1"></el-table-column> <el-table-column clash="table-short" prop="status" label="Status" min-width="1"> <template #default="{row}"> <p>{{sectionStatus[row.crn]}}</p> </template> </el-table-column> <el-table-column class="table-medium" prop="add" label="Action" min-width="2"> <template #default="{row}"> <el-button type="text" @click="addToTimetable(row)" v-if="sectionStatus[row.crn] === 'OK'">Add</el-button> <el-button type="text" v-else-if="sectionStatus[row.crn] === 'Added'" @click="deleteFromDetail(row)">Remove</el-button> <el-button type="text" disabled v-else>{{sectionStatus[row.crn]}}</el-button> </template> </el-table-column> </el-table> </div> </div>
|
tableItems[]
是一个 key 为 category,value 为 session 数组的对象。例如,在上面 AC3202
的例子中,tableItems['0']
就是 AC3202
的所有 category 0
的 session。
- Vue 解析
el-table
中的 width: xx%
时有 bug,参考文章。解决方案是用 min-width
代替 width
, min-width
的值是几,就代表占几份宽度。
<template #default="{row}">
相当于一个匿名函数,参数是当前所在行 row
,返回值是一个 HTML 元素,用于渲染当前单元格的内容。
- 这里使用
{{sectionStatus[row.crn]}}
(直接访问值而不是调用函数)是因为最后一列的 action 按钮会根据 sectionStatus
显示不同的内容,所以必须要保存这个值而不是当场计算。
- 因此,只要在创建表格,增加、删除课程时更新所有
sectionStatus
的值,就能保证表格中的内容是最新的。
<script>
以下几个方法实现比较简单,就不贴代码了:
getDetail()
:在课程列表中点击查看按钮时调用,根据课程代码获取课程详情。
handleTabClick()
:点击 tab 时调用,改变当前 tab,显示对应的 session。
addToTimetable()
:点击添加按钮时调用,将 session 添加到课程表中。
deleteFromDetail()
:点击删除按钮时调用,将 session 从课程表中删除。
此处增删操作会调用 updateSectionStatus()
和 saveLocalStorage()
,更新各个 session 的状态,并保存修改到 LocalStorage。
更新所有 sectionStatus
的值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| function getAllStatus() { if (activeTab.value === '') { return; } for (let act of tableItems.value[activeTab.value]) { for (let added of timetableList.value) { if (added.crn === act.crn) { sectionStatus.value[act.crn] = 'Added'; break; } if (added.courseCode === act.courseCode && added.category !== act.category) { sectionStatus.value[act.crn] = 'Incompatible'; break; } if (added.day === act.day && added.endHour >= act.startHour && added.startHour <= act.endHour) { sectionStatus.value[act.crn] = 'Clash'; break; } } if (sectionStatus.value[act.crn] === undefined) { sectionStatus.value[act.crn] = 'OK'; } } }
|
这里要提一下 for ... of ...
和 for ... in ...
的区别。
for ... in ...
遍历对象的 key,for ... of ...
遍历对象的 value。
实质上,数组也是一种对象,下标是 key,元素是 value。例如:[1, 2, 3]
的对象表示为 {0: 1, 1: 2, 2: 3}
。
可以得到以下对应关系:
|
for ... of ... |
for ... in ... |
数组 |
元素 |
下标 |
对象 |
value |
key |
课程表
网上虽然有很多课程表的实现,但是全都不适合我,所以只能自己写。
大部分课程表都是固定时间点,然后课程作为二维数组的元素。
但我希望实现的是动态的课程表:
- 初始化时,只给定最早上课时间 09:00 和最晚下课时间 22:50,可能上课日期星期一到星期日。表格中的内容是空的。
- 添加课程时,将课程放到对应的时间段,如果已经有课了,就显示冲突,不允许添加。
- 自动合并单元格,用于显示 >1 小时的课程。
- 点选单元格时,弹出对话框,确认是否删除课程。
我不知道怎么设计。我要怎么确定每一行每一列是否有课?然后,是什么课?持续多久?这些信息要怎么保存?
但是如果忽略后面几个问题,只回答第一个问题,还是可以实现的。
<template>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <div class="timetable-container"> <el-table :data="hours" border :header-cell-style="{background:'#d9e5fd', color:'black', fontWeight: 1000, textAlign: 'center'}" :span-method="mergeTimetable" :row-style="{height: '70px'}" :cell-style="tableCellStyle" @cell-click="deleteFromTimetable" > <el-table-column label="Time" min-width="2"> <template #default="{row}"> <p>{{row}}:00</p> </template> </el-table-column> <el-table-column v-for="day in dayKeys" :key="day" :label="char2Day[day]" min-width="4"> <template #default="{row}"> <div v-for="act in timetableList" :key="act.crn" v-show="act.day === day && act.startHour === row" class="timetable-data"> <p class="timetable-data-title">{{act.courseCode}} - {{act.section}}</p> <p class="timetable-data-info">{{act.dayTime}}</p> <p class="timetable-data-info">{{act.buildingRoom}}</p> </div> </template> </el-table-column> </el-table> </div>
|
先说明一下几个常量:
hours = [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
char2Day = {'M': 'Mon', 'T': 'Tue', 'W': 'Wed', 'R': 'Thu', 'F': 'Fri', 'S': 'Sat', 'U': 'Sun'}
dayKeys = ['M', 'T', 'W', 'R', 'F', 'S', 'U']
- 整个
el-table
绑定的数据是 hours
,每个元素对应一行。
- 第一列是一个模板,内容为
{{row}}:00
,row
是当前行的值,也就是小时数。因此,第一列显示的是 9:00
、10:00
、11:00
等。
- 第二列开始是用
v-for
生成的多列模板。
- 用每个星期生成一列,每个小时生成一行。因此除头行头列外,表格中的每个单元格都是一个小时。
- 对每个小时,遍历
timetableList
,如果有一门课的 day
和 startHour
与当前小时相同,就显示这门课的信息。
v-show
是 Vue 的一个指令,类似于 v-if
,但是不会销毁元素,只是隐藏。这样,如果一个小时有多门课,就会显示多个元素,而不是覆盖。(v-if
会销毁元素,所以只会显示最后一门课。)
是的。判断是否有课的逻辑就是这么简单。只要星期和小时都对上了,就知道是哪门课了。
span-method
绑定 mergeTimetable
方法,用于合并单元格。
<script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function mergeTimetable({row, column, rowIndex, columnIndex}) { if (columnIndex === 0) { return {rowspan: 1, colspan: 1}; } for (let act of timetableList.value) { if (act.day === dayKeys[columnIndex - 1] && act.startHour === row) { return {rowspan: act.endHour - act.startHour + 1, colspan: 1}; } if (act.day === dayKeys[columnIndex - 1] && row > act.startHour && row <= act.endHour) { return {rowspan: 0, colspan: 0}; } } return {rowspan: 1, colspan: 1}; }
|
mergeTimetable
每创建一个单元格就会调用一次,参数是当前单元格的信息。
如果当前单元格是第一列(显示小时数),就不合并。
然后,仍然是遍历 timetableList
,寻找是否有课程占用当前单元格。
- 如果当前单元格是课程的第一个小时,就返回
rowspan
为课程持续小时数,colspan
为 1。
- 如果当前单元格是课程的第二个小时及以后,就隐藏该单元格,返回
rowspan
为 0,colspan
为 0。
- 如果当前单元格没有课程,就不合并。
为什么要隐藏呢?因为如果不隐藏,就会出现这样的情况:
假设 A 课程在星期一 9:00-11:50 (占用 9:00, 10:00, 11:00),B 课程在星期二 10:00-10:50 (占用 10:00)。
此处 RxCx 表示原本居于第 R 行第 C 列的单元格,Row X Col X 表示显示在第 R 行第 C 列的单元格。都是以 0 开始计数(第 0 行和第 0 列是表头)。
A 课程匹配到的是 星期一 9:00 (R1C1),rowspan
为 3,colspan
为 1,合并了 R1C1、R2C1、R3C1。
但是实际上 R2C1 和 R3C1 并没有消失,也并不像 Excel 那样合并了。
它们仍然要显示出来。Col 1 挤不下了,所以它们会向右平移到 Col 2。
|
Col 1 |
Col 2 |
Col 3 |
Row 1 |
R1C1 |
R1C2 |
R1C3 |
Row 2 |
R1C1 (2) |
R2C1 |
R2C2 |
Row 3 |
R1C1 (3) |
R3C1 |
R3C2 |
Row 4 |
R4C1 |
R4C2 |
R4C3 |
如此一来,原本应该占用 R2C2 的 B 课程就被挤到了 Row 3 Col 2,而原本应该显示的 Row 2 Col 2 就空出来了。
而 隐藏本应该被合并的单元格 就能解决这个问题。
|
Col 1 |
Col 2 |
Col 3 |
Row 1 |
R1C1 |
R1C2 |
R1C3 |
Row 2 |
R1C1 (2) |
R2C2 |
R2C3 |
Row 3 |
R1C1 (3) |
R3C2 |
R3C3 |
Row 4 |
R4C1 |
R4C2 |
R4C3 |
在处理完这个问题之后,课程表的基本功能就实现了。
点击单元格时,弹出对话框,确认是否删除课程是用 deleteFromTimetable()
实现的,并调用 updateSectionStatus()
和 saveLocalStorage()
。略过。
杂项
LocalStorage
1 2 3 4 5 6 7 8 9 10
| function saveLocalStorage() { localStorage.setItem('timetableList', JSON.stringify(timetableList.value)); }
function loadLocalStorage() { const data = localStorage.getItem('timetableList'); if (data) { timetableList.value = JSON.parse(data); } }
|
初始化
打开页面时,需要初始化课程列表——进行一次空搜索,显示所有课程。
此外,读取 LocalStorage 中的数据,更新课程表。
结语
作为 Vue 的练手项目,这个项目还是很让我满意的。
不过,代码组织、数据结构、规范等方面我是一概不懂的,所以写得很乱而且很难维护。
设计思路也很混乱,很多地方都是现学现卖,好在最后还是实现了想要的功能。
此外,CSS 非常简陋,没有考虑 UI/UX 和响应式,在手机上看表格会很难受。
如果有大佬看到这篇文章,希望能给我一些指导。
感谢阅读!