高校课表多节次跨度渲染与三段式作息映射 (University Timetable Multi-Period Spanning and Schedule Mapping)
1. 问题场景与现象 (Occurrence & Symptoms)
在开发高校教务系统(如正方教务系统、强智教务等)移动端/Web端课表看板时,教务后台返回的课程节次字段通常为字符串形式(如 "1-4节", "3-5节", "6-7节", "6-9节", "10-13节")。
传统或初版的粗糙渲染逻辑通常假定一天均匀划分为固定 6 个两节课的槽位(Slot 0~5 对应 1-2节、3-4节、5-6节、7-8节、9-10节、11-12节),并通过 Math.floor((firstPeriod - 1) / 2) 直接取整分配槽位。这会导致两个严重缺陷:
- 多节连上跨度坍缩与下方空白断层:一门 4 节连上的大课(如周二上午 1-4 节)仅被塞入 Slot 0(1-2节),视觉高度仅占两节课,而本应被其实际占用的 Slot 1(3-4节)变成了一个空白单元格。
- 三段式高校真实作息时段错位:国内大多数本科院校实行三段式作息(上午 1-4/5 节,下午从第 6 节开始,晚上从第 10 节开始),中间无下午的“5-6节”。粗暴除以 2 会导致下午第一大节 6-7 节被错误映射进 5-6 节槽位,下午第二大节 8-9 节被映射进 7-8 节槽位,晚上 10-13 节大实验课直接发生越界错位。
2. 根本原因剖析 (Root Cause Analysis)
- 时段离散性与作息非线性的冲突:大学作息并非连续递增的时间轴,午休(12:20 - 13:30)切断了上午第 5 节与下午第 6 节;晚餐(17:00 - 18:00)切断了下午第 9 节与晚上第 10 节。因此节次编号在物理时段上存在断点,不能使用算术除法建立连续映射。
- 二维表格行高与跨行合并(Rowspan)的协同缺失:在 HTML
<table>或 CSS Grid 体系中,跨节大课必须显式建立跨行关联(rowspan="2"或grid-row: span 2),且在底层数据结构中必须标记被覆盖单元格(covered: true),避免在后续行中渲染多余单元格造成表格变形破坏。
3. 核心设计与解决架构 (Architecture & Implementation)
3.1 三段式大节标准槽位定义 (Session Slot Mapping)
将每日 13 节课映射至 6 个标准业务大节:
| 大节编号 (Slot) | 节次范围 | 标准作息时段 | 所属时段 | 涵盖特殊节次 |
|---|---|---|---|---|
| Slot 0 | 第 1-2 节 | 08:00 - 09:35 | 上午 | 1-2节, 1-4节(起点) |
| Slot 1 | 第 3-4 节 | 09:55 - 11:30 | 上午 | 3-4节, 3-5节(至12:20) |
| Slot 2 | 第 6-7 节 | 13:30 - 15:05 | 下午 | 6-7节, 6-9节(起点) |
| Slot 3 | 第 8-9 节 | 15:25 - 17:00 | 下午 | 8-9节 |
| Slot 4 | 第 10-11 节 | 18:00 - 19:35 | 晚上 | 10-11节, 10-13节(起点) |
| Slot 5 | 第 12-13 节 | 19:40 - 21:15 | 晚上 | 12-13节 |
3.2 节次解析器 (Period Parser)
实现支持任意多节大课、兼容三段式作息边界的精准槽位与跨度解析函数:
function getCourseSlotAndSpan(jcStr) {
if (!jcStr) return { slot: 0, span: 1, start: 1, end: 2 };
const matches = String(jcStr).match(/\d+/g);
if (!matches || matches.length === 0) return { slot: 0, span: 1, start: 1, end: 2 };
const start = parseInt(matches[0], 10);
const end = matches.length > 1 ? parseInt(matches[1], 10) : start;
function periodToSlot(p, isEnd = false) {
if (p <= 2) return 0;
if (p <= 5) {
// 兼容部分历史演示数据中 5-6 节标记为下午第一大节的情况
if (!isEnd && p === 5 && end >= 6) return 2;
return 1; // 3-4节或3-5节属于上午第2大节
}
if (p <= 7) return 2; // 下午第1大节 (6-7节)
if (p <= 9) return 3; // 下午第2大节 (8-9节)
if (p <= 11) return 4; // 晚上第1大节 (10-11节)
return 5; // 晚上第2大节 (12-13节)
}
const startSlot = periodToSlot(start, false);
const endSlot = periodToSlot(end, true);
const span = Math.max(1, endSlot - startSlot + 1);
return { slot: startSlot, span, start, end };
}3.3 弹性合并网格构建与冲突防穿透算法 (Grid Spanning Resolver)
在将课程数据填充入网格前,通过双阶段构建确保“跨节大课占满高度”与“全学期总览冲突课程不丢失”兼得:
function renderTimetableGrid() {
// 阶段 1: 归类至 6x7 原始槽位
const rawSlots = Array.from({ length: 6 }, () => Array.from({ length: 7 }, () => []));
for (const item of timetableRawList.value) {
if (!isCourseInWeek(item.zcd, timetableWeek.value)) continue;
const day = parseInt(item.xqj) - 1;
if (day < 0 || day >= 7) continue;
const info = getCourseSlotAndSpan(item.jc || item.jcs);
if (info.slot >= 0 && info.slot < 6) {
item._slot = info.slot;
item._span = info.span;
rawSlots[info.slot][day].push(item);
}
}
// 阶段 2: 构建结构化网格对象
const grid = Array.from({ length: 6 }, () => Array.from({ length: 7 }, () => ({
courses: [],
span: 1,
covered: false
})));
for (let s = 0; s < 6; s++) {
for (let d = 0; d < 7; d++) {
grid[s][d].courses = rawSlots[s][d];
}
}
// 阶段 3: 动态跨行解析 (Spanning & Coverage Resolution)
for (let d = 0; d < 7; d++) {
for (let s = 0; s < 6; s++) {
const cell = grid[s][d];
if (cell.covered) continue;
const requestedSpan = cell.courses.reduce((max, c) => Math.max(max, c._span || 1), 1);
if (requestedSpan > 1 && s + 1 < 6) {
// 关键防御:检查被合并的目标大节是否为空
// 若在“整学期总览”模式下目标大节存在其他周次的课程,则降级为单行显示,避免覆盖数据
let canSpan = true;
for (let k = 1; k < requestedSpan && (s + k) < 6; k++) {
if (grid[s + k][d].courses.length > 0) {
canSpan = false;
break;
}
}
if (canSpan) {
const actualSpan = Math.min(requestedSpan, 6 - s);
cell.span = actualSpan;
for (let k = 1; k < actualSpan; k++) {
grid[s + k][d].covered = true;
}
} else {
cell.span = 1;
}
} else {
cell.span = 1;
}
}
}
timetableGrid.value = grid;
}3.4 视图模板渲染 (HTML Table Template)
在 HTML <table> 中结合 table-fixed 与 rowspan:
<table class="w-full border-collapse border border-slate-200 text-xs table-fixed min-w-[780px]">
<thead>
<tr class="bg-slate-50/80">
<th class="border border-slate-200 p-2.5 w-24 text-slate-500 font-medium text-center">时段 / 节次</th>
<th v-for="(day, dIdx) in weekDays" :key="day"
class="border border-slate-200 p-2 text-center font-bold"
:class="dIdx === todayWeekdayIdx ? 'bg-indigo-50/80 text-indigo-950 border-indigo-200' : 'text-slate-700'">
{{ day }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, j) in timetableGrid" :key="j" class="border-b border-slate-200">
<!-- 左侧作息标签 -->
<td class="border border-slate-200 p-2 text-center text-slate-600 bg-slate-50/70 align-middle w-24">
<div class="text-[10px] text-indigo-600 font-semibold mb-0.5">{{ timeSlots[j]?.section }}</div>
<div class="font-bold text-xs text-slate-800 font-mono">{{ timeSlots[j]?.name }}</div>
<div class="text-[10px] text-slate-400 font-mono mt-0.5">{{ timeSlots[j]?.time }}</div>
</td>
<!-- 课表单元格 -->
<template v-for="(cell, d) in row" :key="d">
<td
v-if="!cell.covered"
:rowspan="cell.span"
class="border border-slate-200 p-1.5 align-top"
:class="[
d === todayWeekdayIdx ? 'bg-indigo-50/15' : 'bg-white',
cell.span > 1 ? 'bg-indigo-50/25' : ''
]">
<div v-if="cell.courses && cell.courses.length > 0" class="h-full flex flex-col justify-between space-y-1.5">
<div
v-for="(course, cIdx) in cell.courses"
:key="cIdx"
@click="detailCourse = course"
class="rounded-xl p-2.5 border shadow-xs transition cursor-pointer hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between"
:class="[
course.kklxdm === '05' ? 'bg-emerald-50/90 border-emerald-200 text-emerald-950' : 'bg-indigo-50/90 border-indigo-200 text-indigo-950',
cell.span > 1 ? 'min-h-[168px] py-3 ring-1 ring-indigo-300/40' : 'min-h-[78px]'
]">
<!-- 课程卡片内容与 4节大课徽标 -->
<div>
<div class="flex items-start justify-between gap-1">
<div class="font-bold text-xs leading-snug break-words">{{ course.kcmc }}</div>
<span v-if="cell.span > 1" class="text-[9px] font-bold px-1.5 py-0.5 rounded bg-indigo-200/90 text-indigo-900 font-mono">
4节大课
</span>
</div>
<div class="text-[11px] text-slate-500 mt-1 flex items-center justify-between">
<span>{{ course.xm || '任课教师' }}</span>
<span class="text-[9px] font-mono px-1 py-0.5 rounded font-semibold bg-indigo-200/70 text-indigo-800">
{{ course.jc }}
</span>
</div>
</div>
<div class="mt-2 pt-1.5 border-t border-black/5 text-[11px] font-medium flex items-center justify-between">
<span class="truncate">{{ course.cdmc || '校内场地' }}</span>
<span v-if="course.xf" class="text-[10px] opacity-75 font-mono">{{ course.xf }}学分</span>
</div>
</div>
</div>
<div v-else class="h-full min-h-[78px]"></div>
</td>
</template>
</tr>
</tbody>
</table>4. 验证与回归要点 (Verification & Regression)
- 行高拉伸验证:在 Headless 自动化测试中,通过
bounding_box().height检查跨 2 槽位的单元格高度需达到> 150px(实际渲染为249px),符合两行合并的标准视觉感知。 - DOM 结构防塌陷:当
cell.covered === true时必须从 DOM 中完全剔除(v-if="!cell.covered"),不能仅仅display: none,否则 HTML 原生表格引擎会多算一列造成排版撕裂错位。 - 学期总览防遮挡:在全学期总览(全部周次叠加模式)下,若上午 1-2 节为 A 课(1-4 节),3-4 节为非同周的 B 课,合并逻辑必须自动降级为
span: 1,确保 A、B 两门课均可在各自所属槽位正常展示。
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend