发布于 ,更新于 

项目: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 中尽量不要用数组,尤其是嵌套的数组。

比如,要标记 CS1102CS1302 的科目为 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'"> <!-- tab 切换 -->
<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}}:00row 是当前行的值,也就是小时数。因此,第一列显示的是 9:0010:0011:00 等。
  • 第二列开始是用 v-for 生成的多列模板。
  • 用每个星期生成一列,每个小时生成一行。因此除头行头列外,表格中的每个单元格都是一个小时。
  • 对每个小时,遍历 timetableList,如果有一门课的 daystartHour 与当前小时相同,就显示这门课的信息。
  • 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 和响应式,在手机上看表格会很难受。

如果有大佬看到这篇文章,希望能给我一些指导。

感谢阅读!