1. 从一张空白课表说起为什么我的课表值得认真做很多人看到我的课表这四个字第一反应是这有什么难的不就是把课程名字填进格子里吗我一开始也这么想。直到真正动手去做一个能用的课表工具才发现这件事远比想象中复杂——它牵扯到时间轴建模、冲突检测、数据持久化、界面响应式布局甚至还要考虑单双周这种只有真实校园场景才会出现的特殊规则。我的课表本质上是一个个人时间资源管理工具。它要解决的核心问题是把一周内分散在不同时间段、不同地点、不同周次生效的课程信息用一种直观、可查询、可修改的方式组织起来让使用者一眼看清我什么时候、在哪里、上什么课。听起来简单但当你真正去设计数据结构、处理边界情况时会发现每一个细节都藏着坑。这篇内容适合两类人看一类是正在学习前端或全栈开发、想找一个小而完整的项目练手的同学另一类是已经有一定基础、想看看别人怎么把一个小需求做扎实的开发者。我会从需求拆解、数据结构设计、核心逻辑实现、界面布局、踩坑记录几个维度把我的课表这个项目从头到尾讲透。文中涉及的技术选型会给出理由涉及的操作会给出可复现的步骤涉及的经验都是实际动手之后总结出来的。需要提前说明的是课表类项目看似是填格子但它对数据建模能力的要求其实不低。一周七天、每天若干节课、每节课有起止时间、课程可能跨节次、可能只在某些周次出现——这些约束条件叠加在一起如果一开始数据结构没设计好后面改起来会非常痛苦。所以我会花比较大的篇幅讲清楚为什么这样设计而不是直接甩一段代码让你抄。2. 需求拆解一张课表到底要装下多少信息2.1 显性需求与隐性需求的区分表面上看课表需要展示的信息很明确课程名称、上课时间、上课地点、授课教师。这是任何人拿到一张纸质课表都能看到的四要素。但如果只按这四要素去建模做出来的东西大概率是个能看不能用的静态页面。隐性需求才是真正决定项目质量的部分。我梳理了一下至少包括这些课程可能连续占两节甚至三节课需要合并显示而不是重复填格同一门课可能一周上两次时间地点都不同有些课程只在单周或双周出现需要按周次过滤用户可能想给不同课程配不同颜色以便快速识别课程时间可能临时调整需要支持编辑而不是重新录入。把这些隐性需求列出来之后你会发现我的课表其实是一个带时间维度约束的日程管理系统的简化版。它的数据模型必须同时表达空间位置第几周、星期几、第几节和时间跨度从第几节到第几节、从第几周到第几周。这个认知很关键它直接决定了后面数据结构的设计方向。2.2 用表格把需求钉死我习惯在动手写代码之前先用一张表格把需求固化下来避免做到一半又冒出新想法导致返工。下面是我为我的课表整理的需求清单需求类别具体描述优先级实现难度课程录入输入课程名、教师、地点、星期、节次范围、周次范围高低课表展示按星期和节次二维网格展示跨节次课程合并单元格高中周次切换支持查看第1到第N周的不同课表高中冲突检测同一时间同一节次不能录入两门课中中颜色区分不同课程自动分配不同背景色中低本地持久化刷新页面后数据不丢失高低课程编辑删除支持修改和删除已录入课程中低当前周高亮自动识别当前是第几周并高亮低低这张表的价值在于它让我清楚地知道哪些是必须做的、哪些是可以后补的。比如冲突检测虽然优先级标了中但它是保证数据质量的关键如果跳过用户很可能录出两张重叠的课表还浑然不觉。而当前周高亮属于锦上添花第一版完全可以不做。2.3 一个容易被忽略的约束节次与时间的映射很多课表项目会把第几节直接当成一个序号处理但真实场景里每节课对应的是具体的时间段。比如第1节是8:00到8:45第2节是8:55到9:40中间还有课间休息。如果只存节次序号展示时就没法告诉用户这门课几点开始。我的处理方式是维护一张节次时间映射表把节次序号和起止时间绑定起来。这张表可以是硬编码的常量也可以做成可配置的。硬编码的好处是简单直接缺点是不同学校的作息时间不一样通用性差。考虑到这是个个人项目我选择了硬编码加注释说明的方式同时在代码结构上留出替换空间——把映射表抽成一个独立模块将来要改只需要动一个文件。// 节次时间映射按实际作息调整 const PERIOD_TIME_MAP { 1: { start: 08:00, end: 08:45 }, 2: { start: 08:55, end: 09:40 }, 3: { start: 10:00, end: 10:45 }, 4: { start: 10:55, end: 11:40 }, 5: { start: 14:00, end: 14:45 }, 6: { start: 14:55, end: 15:40 }, 7: { start: 16:00, end: 16:45 }, 8: { start: 16:55, end: 17:40 }, 9: { start: 19:00, end: 19:45 }, 10: { start: 19:55, end: 20:40 } };这段映射看起来不起眼但它是后面所有时间相关功能的基础。冲突检测要靠它判断两门课是否真的时间重叠展示时要靠它渲染每节课的具体时间甚至当前正在上哪节课这种功能也要靠它计算。所以这一步不能省。3. 数据结构设计课表项目的骨架怎么搭3.1 为什么不能用二维数组填格子的思路初学者做课表最容易想到的方案是搞一个 7×N 的二维数组数组每个位置存一门课。这个思路直观但很快就会撞墙一门连上两节的课在数组里要占两个格子存两份重复数据改一处还得同步改另一处一门只在单周出现的课在双周视图里那个格子应该是空的但数组里存了值就得在渲染时额外判断。问题的根源在于二维数组表达的是位置而课程的本质是时间段。一门课是一个带有时间跨度属性的实体它恰好覆盖了某些格子但它本身不等于格子。正确的做法是把课程作为独立实体存储渲染时再根据课程的时间属性去计算它该占哪些格子。这个思路转换很像数据库设计里的范式思想先建实体表再通过关系去推导视图而不是直接把视图当存储。想清楚这一点后面的设计就顺了。3.2 课程实体的字段设计基于上面的思路我把单门课程设计成这样一个对象{ id: c_1700000000000, // 唯一标识用时间戳生成 name: 高等数学, // 课程名称 teacher: 某老师, // 授课教师 location: 教学楼A-301, // 上课地点 dayOfWeek: 1, // 星期几1-7 startPeriod: 1, // 起始节次 endPeriod: 2, // 结束节次 startWeek: 1, // 起始周 endWeek: 16, // 结束周 weekType: all, // 周次类型all/odd/even color: #4A90D9 // 课程颜色 }这里有几个字段值得单独说明。startPeriod和endPeriod分开存而不是存一个长度是因为渲染时需要知道课程从哪一节开始合并单元格的起点依赖这个值。weekType用枚举而不是直接展开成周次数组是为了节省存储空间——一门上16周的课如果展开成数组要存16个数字用类型加起止周只需要三个字段。color存在课程实体里而不是渲染时随机生成是为了保证同一门课每次渲染颜色一致刷新页面不会变色。id用时间戳生成是个偷懒但够用的方案。严格来说应该用更可靠的唯一 ID 生成策略但在单用户本地存储的场景下时间戳碰撞的概率极低没必要过度设计。如果将来要做多用户同步这个字段需要换成服务端生成的 ID。3.3 周次过滤逻辑的推演weekType这个字段的引入带来一个必须想清楚的问题给定当前查看的周次如何判断一门课是否应该显示逻辑其实不复杂但要覆盖所有组合。我把它拆成三步先判断周次是否落在起止范围内再判断周次类型是否匹配。用代码表达就是function isCourseActiveInWeek(course, currentWeek) { // 第一步周次范围判断 if (currentWeek course.startWeek || currentWeek course.endWeek) { return false; } // 第二步周次类型判断 if (course.weekType odd currentWeek % 2 0) { return false; } if (course.weekType even currentWeek % 2 1) { return false; } return true; }这段逻辑看着简单但我第一次写的时候漏掉了周次范围和周次类型的先后关系导致一门只上单周、且只上前8周的课在第10周的单周视图里仍然显示。原因是当时只判断了奇偶没判断范围。这个 bug 提醒我多个过滤条件叠加时一定要把每个条件的语义想清楚不能想当然地合并判断。3.4 冲突检测的判定条件冲突检测的本质是判断两个时间段是否有交集。对于课表场景两个维度都要考虑星期维度上必须同一天节次维度上区间必须有重叠。周次维度上如果两门课的周次范围完全不相交那即使节次重叠也不算冲突。判定逻辑可以写成这样function hasConflict(courseA, courseB) { // 星期不同不冲突 if (courseA.dayOfWeek ! courseB.dayOfWeek) return false; // 节次区间无重叠不冲突 if (courseA.endPeriod courseB.startPeriod || courseB.endPeriod courseA.startPeriod) return false; // 周次区间无重叠不冲突 if (courseA.endWeek courseB.startWeek || courseB.endWeek courseA.startWeek) return false; // 周次类型互斥的情况需要进一步判断 // 这里简化处理只要范围有交集且类型不完全互斥就视为潜在冲突 return true; }这里我做了简化处理周次类型单双周的互斥判断没有做到最细。严格来说一门单周课和一门双周课在节次重叠时其实不冲突因为它们在任意一周都不会同时出现。但实际场景中这种情况很少而且做细了代码复杂度上升明显。我的取舍是先保证不漏报宁可多提示让用户自己确认也不放过真正的冲突。这个取舍在个人项目里是合理的如果是面向大量用户的产品就需要把单双周互斥也纳入判断。4. 界面布局从网格到可交互课表4.1 用 CSS Grid 还是 Table课表天然是个二维网格所以布局方案的第一反应往往是table。表格确实能表达行列关系但它在处理跨行合并和响应式时比较别扭。跨节次的课程需要rowspan而rowspan的值依赖课程跨越的节次数动态计算起来容易出错。更麻烦的是移动端适配表格在小屏幕上要么横向滚动要么挤压变形体验很差。我最终选择了CSS Grid。Grid 的优势在于它把网格抽象成了布局原语每个格子通过grid-row和grid-column精确定位跨节次的课程只需要设置grid-row: span 2就能自动占两行不需要关心具体的行号计算。而且 Grid 的响应式处理更灵活可以通过媒体查询调整列宽甚至改变布局结构。具体到实现我把课表容器设成display: grid列定义为时间列 7个星期列行定义为表头行 N个节次行。课程卡片作为 Grid 的子项通过内联样式指定它占据的行列范围。这种容器定网格、子项定位置的模式比表格的合并单元格清晰得多。4.2 课程卡片的定位计算课程卡片要放到正确的格子里需要把星期几、起始节次、结束节次翻译成 Grid 的行列坐标。这里有个细节Grid 的行列编号从1开始而我的节次数据也从1开始看起来可以直接对应但表头占了一行所以实际行号要加偏移。function getCardGridStyle(course) { return { gridColumn: course.dayOfWeek 1, // 1 是因为第1列是时间列 gridRow: ${course.startPeriod 1} / ${course.endPeriod 2} }; }gridRow的写法是起始行 / 结束行结束行要加2而不是加1因为 Grid 的结束线是排他的——要占第1到第2行结束线得写3。这个细节我第一次写的时候搞错了导致所有课程都少占一行排查了半天才发现是结束线的语义理解偏差。这种差一问题在网格布局里非常常见建议写完先用一门课验证确认无误再批量渲染。4.3 空状态与视觉层次的打磨课表最容易做得能用但难看的地方是空状态的处理。一周七天、每天十节如果没有任何课程页面上就是一大片空白格子视觉上非常单调。我的处理是给空格子加一层极淡的交替背景色类似斑马纹让网格结构始终可见同时不喧宾夺主。课程卡片的视觉层次也值得花点心思。我用了三个层次背景色用课程专属色但降低饱和度保证文字可读课程名用较大字号加粗是视觉焦点教师和地点用小字号、次要颜色作为辅助信息。这样一眼扫过去先看到课程名需要时再看细节符合快速识别的使用场景。提示课程颜色不要用纯饱和色比如纯红纯蓝多个课程并排时非常刺眼。建议用 HSL 色彩空间固定饱和度和亮度只调整色相来区分课程这样整体色调统一不会花。5. 核心交互实现录入、编辑与持久化5.1 录入表单的字段联动课程录入表单看起来是几个输入框的堆砌但字段之间存在联动关系处理不好会让用户困惑。最典型的是起始节次和结束节次结束节次不能小于起始节次否则课程的时间跨度就是负数。我的做法是在结束节次的下拉选项里动态过滤掉小于起始节次的选项从源头上杜绝非法输入而不是等提交时再弹错误提示。周次字段也有类似问题。起始周不能大于结束周这个用同样的思路处理。另外当用户选择单周或双周类型时如果起止周范围里根本不包含对应的周次比如选了第2到第4周却选单周而这三周全是双周应该给出提示。这个校验稍微复杂一点我放在提交时做用一段小逻辑判断范围内是否存在匹配周次。function validateWeekRange(startWeek, endWeek, weekType) { if (startWeek endWeek) return 起始周不能大于结束周; if (weekType all) return null; const targetParity weekType odd ? 1 : 0; for (let w startWeek; w endWeek; w) { if (w % 2 targetParity) return null; } return 所选周次范围内没有符合条件的周次; }5.2 本地持久化的选型与陷阱个人项目没有后端数据持久化最直接的方案是localStorage。它简单、同步、容量够用课表数据量很小缺点是只能存字符串需要手动序列化和反序列化。const STORAGE_KEY my_timetable_courses; function saveCourses(courses) { localStorage.setItem(STORAGE_KEY, JSON.stringify(courses)); } function loadCourses() { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return []; try { return JSON.parse(raw); } catch (e) { console.error(课表数据解析失败, e); return []; } }这里有个容易被忽略的陷阱JSON.parse可能抛异常。如果存储的数据被手动改坏或者版本升级导致结构不兼容直接 parse 会让整个页面白屏。所以一定要用 try-catch 包住解析失败时回退到空数组保证应用至少能启动。这个防御性处理在开发阶段看不出价值但一旦出问题就是救命稻草。另一个陷阱是数据结构变更时的兼容。假设第一版课程对象没有color字段第二版加上了老用户本地存的数据里就没有这个字段渲染时取到undefined会导致样式异常。我的处理是在加载时做一次字段补全给缺失字段填默认值。这种数据迁移思维在本地存储场景同样适用。5.3 编辑与删除的交互设计编辑功能我复用了录入表单点击课程卡片时把该课程的数据回填到表单里提交时根据是否有id判断是新增还是更新。这个模式很常见但有个细节要注意编辑时冲突检测要排除课程自身否则一门课和自己比较必然冲突导致永远保存不了。function checkConflict(newCourse, allCourses) { return allCourses.some(c c.id ! newCourse.id hasConflict(c, newCourse) ); }删除操作我加了二次确认因为课表数据是用户一条条录进去的误删的代价不小。确认弹窗用原生confirm就够了个人项目没必要上自定义弹窗组件。这里的原则是破坏性操作必须有确认但确认方式可以简单。6. 踩坑实录那些文档里不会写的教训6.1 跨节次课程的渲染错位项目做到一半时遇到一个诡异问题连上两节的课程卡片总是比预期低半格和下一节课的格子重叠。排查过程比较曲折先说结论——问题出在 Grid 行号的偏移计算上。我一开始把表头也算作一个 Grid 行但表头其实是独立于网格之外的导致所有行号多加了1。这个坑的教训是Grid 布局里容器内所有直接子项都参与网格排布包括你以为的表头。如果表头要独立要么把它放在网格容器外面要么用grid-column: 1 / -1让它横跨整行并明确它的行位置。我最后选择了把表头移出网格容器用独立的 flex 行实现网格只负责课程区域行号计算一下子清爽了。6.2 单双周切换时的状态残留周次切换功能上线后测试时发现一个 bug从单周切到双周某些单周课程的卡片消失了但对应的格子还留着背景色残影。原因是渲染时我只移除了卡片 DOM没有清理格子的状态类。这个问题的本质是渲染逻辑没有做到幂等——每次渲染应该基于当前数据完全重建视图而不是在旧视图上做增量修改。修复方案很简单每次切换周次时先清空整个课程区域的子元素再根据当前周次重新渲染。虽然这样会丢失一些 DOM 复用带来的性能优势但课表数据量小完全感觉不到卡顿。这个取舍再次印证了一个原则在数据量不大的场景下正确性优先于性能优化。6.3 时间冲突检测的边界遗漏前面提到冲突检测我做了简化但简化也带来过实际 bug。有一次我录入了一门第1到8周的单周课又录入了一门第1到8周的双周课两门课节次完全重叠。按我的简化逻辑它们被判为冲突弹出了提示。但实际上它们永远不会同时出现这个提示是误报。误报本身不算严重用户忽略提示继续保存就行。但它暴露了我对冲突定义的理解不够精确。后来我补了一个判断如果两门课的周次类型分别是单周和双周且周次范围有交集则不算冲突。补上之后误报消失了。这件事让我意识到边界条件的处理不能靠大概没问题的直觉得把每种组合都列出来过一遍。6.4 移动端横向溢出的排查链路课表在桌面端显示正常但在手机上出现了横向滚动条右侧的星期列被截断。排查链路是这样的先确认是哪个元素撑宽了页面用开发者工具逐个检查发现是 Grid 容器的列宽用了固定像素值7列加起来超过了屏幕宽度。解决思路有两个方向一是把固定像素改成fr单位让列宽自适应二是给容器加overflow-x: auto允许横向滚动。我选了前者因为课表的核心价值是一眼看全一周横向滚动会破坏这个体验。改成fr之后列宽随屏幕缩放小屏幕上每列变窄但结构完整。如果课程名太长导致换行再用text-overflow: ellipsis截断加title属性显示完整名称。注意用fr单位时如果某一列内容特别长Grid 默认会按内容撑开列宽导致各列宽度不均。需要给列定义加minmax(0, 1fr)强制列宽均分内容溢出用省略号处理。7. 让课表更好用的几个进阶思路7.1 当前周自动识别每次打开课表都要手动切到当前周体验上差一口气。自动识别的思路是设定一个学期起始日期用当前日期减去起始日期算出第几周。这个功能实现不难但有个前提——用户得先告诉程序学期从哪天开始。我的做法是在设置里加一个学期起始日期的输入存到本地之后每次打开自动计算当前周并高亮。计算逻辑要注意跨年问题。如果学期起始在去年当前日期在今年直接用日期差算是没问题的因为Date对象的时间戳是连续的。但如果用第几周的字符串拼接去算就容易出错。所以统一用时间戳做减法再除以一周的毫秒数取整是最稳妥的方式。function getCurrentWeek(semesterStartDate) { const start new Date(semesterStartDate).getTime(); const now Date.now(); const diffDays Math.floor((now - start) / (1000 * 60 * 60 * 24)); return Math.floor(diffDays / 7) 1; }7.2 课程备注与待办关联课表用久了会自然产生给某门课加个备注的需求比如下周三交作业带计算器。这个需求可以做成课程卡片上的一个小角标点击展开备注内容。再进一步可以把备注和简单的待办状态绑定标记完成与否。这个扩展的价值在于它把课表从信息展示升级成了行动管理。但要注意控制复杂度备注功能如果做得太重比如支持富文本、附件就偏离了课表的核心定位。我的建议是备注只支持纯文本长度限制在几十字以内够用就好。7.3 数据导入导出的必要性本地存储有个天然风险换设备、清缓存、换浏览器数据就没了。所以导入导出功能不是锦上添花而是数据安全的底线。实现上很简单导出就是把课程数组转成 JSON 字符串让用户下载导入就是读文件解析后合并到现有数据。合并策略需要想清楚是覆盖还是追加我的选择是导入时询问用户默认追加但做冲突检测冲突的课程跳过并提示。这样既不会误删用户已有数据也不会产生重复课程。导出文件名带上日期方便用户区分不同时间点的备份。7.4 打印样式的适配学生场景里课表经常需要打印出来贴在墙上。网页直接打印往往排版错乱因为屏幕样式和打印样式差异很大。加一段media print的样式把背景色去掉省墨、把导航和按钮隐藏、调整字号和边距打印出来的效果会好很多。media print { .toolbar, .week-switcher { display: none; } .course-card { background: none !important; border: 1px solid #999; } .timetable-grid { page-break-inside: avoid; } }这段样式不长但体现了为真实使用场景做设计的思路。很多个人项目止步于屏幕上能看而真正好用的工具会考虑到用户在各种环境下的使用方式。8. 我在这个项目里最看重的两条经验做完整套课表下来技术上的收获不少但真正让我觉得值的是两个思维层面的体会。第一条是先想清楚数据再动手画界面。我见过太多课表项目一上来就写 HTML 结构格子画得漂漂亮亮结果一加单双周功能就全盘重构。数据模型是地基界面只是地基上的建筑地基没打牢装修再好看也白搭。判断数据模型是否合理有个简单标准能不能用几行代码回答这门课这周上不上这两门课冲不冲突这类问题。如果回答起来很别扭说明模型有问题。第二条是边界情况要穷举不能靠感觉。单双周、跨节次、周次范围、冲突检测每一个功能都有若干种组合情况。我踩的坑几乎都出在我以为这种情况不会发生上。后来我养成了一个习惯每写一个判断逻辑就在纸上把输入的所有可能组合列出来逐个过一遍。这个习惯看起来笨但省下的调试时间远超列清单的时间。课表这个项目本身不大但它麻雀虽小五脏俱全——数据建模、状态管理、布局计算、交互设计、边界处理一个都不少。如果你正在找练手项目它是个很好的选择如果你只是想要一个能用的课表工具照着上面的思路做两三天就能出一个可用的版本。真正花时间的从来不是写代码而是想清楚代码该写成什么样。
阅读完成 · 觉得有帮助?