我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

纯前端单会议室周日历管理系统:HTML+JavaScript从零实现

纯前端单会议室周日历管理系统:HTML+JavaScript从零实现 这周已经是第N次了。同事站在会议室门口问我下午三点有没有空房我打开Excel查了半天发现记录里的预约和墙上的实际安排完全对不上。这种尴尬我受够了最后花了一个周末用 HTML JavaScript 写了一套单会议室周日历管理系统。纯前端、零依赖一个 index.html 就能跑不装环境、不配服务同事拿U盘拷走就能用。这套系统的定位很明确管一间会议室按周查看所有预约在日历上直接新增、拖拽、修改、删除。如果你也是行政、IT运维或者项目助理想把会议室从Excel里解放出来这篇文章能给你一个能直接上手的参考包括数据怎么设计、网格怎么画、预约块怎么定位、冲突检测怎么写以及我踩过的那些坑。1. 单会议室周日历到底要解决什么问题1.1 为什么纯前端方案就够用先泼盆冷水不是所有系统都需要前后端分离加数据库。单会议室的管理场景数据量撑死就是一天几个预约、一周几十条记录。把数据存在浏览器的 localStorage 里关闭页面再打开数据还在这已经能满足90%的使用需求了。我见过不少同事一上来就想做登录、做权限、做后端存储。但你品一下会议室预约这种场景使用频率不高并发极低使用的人就那么几个后端复杂度完全是多余的。纯前端方案最大的好处是部署成本几乎为零一个文件拷到任何电脑上双击打开浏览器就能用。团队里如果确实需要多人共享后续再在存储层接一个JSON接口或者轻量后端也不迟那是后话。用 HTML JavaScript 实现这个系统本质是在做三件事定义一个可靠的预约数据结构把时间映射到页面坐标渲染出周日历网格和预约块处理预约的增删改查重点是冲突检测这三件事想清楚整个系统的骨架就立住了。1.2 预约数据怎么设计才不容易出bug数据模型是整个系统最容易被忽略、但最值得花时间的部分。我第一版用的是日期字符串加时间对象结果渲染和比较的时候各种类型转换逼疯自己。后来老老实实把所有时间都换算成从当天0点起的分钟数整个世界清爽了。一条预约记录我最终定为这样{ id: mtg_1708247123456, // 唯一ID用Date.now生成 title: 需求评审会, // 预约标题 date: 2025-02-17, // 日期格式YYYY-MM-DD start: 9 * 60 30, // 开始时间9:30 570 end: 10 * 60 30, // 结束时间10:30 630 color: #4e8cff // 显示颜色可分主题 }为什么强调分钟数而不是直接塞一个new Date()对象进去因为在这个场景里我们只需要关心某天的某个时间段并不需要处理时区、夏令时这些复杂概念。用9 * 60 30这种整数比较大小、计算跨度、冲突检测都变成了小学数学完全没有类型转换的烦恼。当然date字段用YYYY-MM-DD字符串而不是时间戳也是有讲究的。它对应哪一天和分钟数组合起来就是一个完整的预约时间段。如果我把日期也塞进时间戳那判断两个预约是否在同一天还需要额外转换没有必要。1.3 理解坐标映射日历的本质是一个二维坐标系周日历看起来是个日历本质上是个二维坐标系。横轴是一周七天纵轴是当天的工作时间。每一个预约就是坐标系里的一个矩形从星期几开始纵向上从第几分钟开始、占多少分钟。这个思想一旦建立渲染就变得极其简单。假设会议室可用时间是 8:00 到 18:00总共 600 分钟那么一个预约块用百分比的定位方式可以这样算const workDayStart 8 * 60; // 480 const workDayEnd 18 * 60; // 1080 const totalMinutes workDayEnd - workDayStart; // 600 // 预约从9:30到10:30共60分钟 const top (570 - workDayStart) / totalMinutes * 100 %; const height (630 - 570) / totalMinutes * 100 %;这里top和height都直接用百分比好处是窗口缩放时日历会自适应不用响应像素值。很多人习惯用position: relative的容器加绝对定位最初的直觉是对的但对高度的百分比换算理解不透导致预约块怎么都对不齐刻度。其实核心就一句话预约的高度占比等于预约时长占全天工作时间的比例。明白了这一点后面所有功能都是在这个坐标系里做加减乘除。2. 日历网格的渲染把一天的时间切出骨架2.1 页面结构与CSS Grid布局网格怎么画决定了后面预约块好不好定位。我试过table布局也试过float布局都别扭。最后还是 CSS Grid 最顺手语义清晰、收放自如。整体页面就三块顶部的操作栏和星期表头中间的滚动体左侧是时间刻度右侧是横向7列的网格覆盖在网格上的一层绝对定位容器用来放预约块核心的 HTML 结构长这样div classcalendar-wrapper div classweek-header div classtime-gutter/div !-- 7个星期列头 -- /div div classscroll-body div classtime-axis !-- 8:00 到 18:00 的刻度 -- /div div classgrid-layer !-- 背景网格线 -- /div div idmeetingLayer classmeeting-layer !-- 绝对定位的预约块 -- /div /div /divscroll-body是滚动容器meeting-layer是绝对定位的覆盖层它必须和grid-layer、time-axis共享同一个坐标系。这里最容易犯的错是让meeting-layer不是scroll-body的子元素导致滚动时预约块和网格错位。我第一版就因为这个白调了半小时。2.2 时间刻度和背景网格的生成工作时间的刻度建议用半小时一个格子太密了视觉上会挤一个小时又不够精细。生成刻度的逻辑不复杂就是从 8:00 循环到 18:00function generateTimeAxis() { const axis document.getElementById(timeAxis); axis.innerHTML ; for (let m workDayStart; m workDayEnd; m 30) { const hour Math.floor(m / 60); const minute m % 60; const label ${String(hour).padStart(2, 0)}:${String(minute).padStart(2, 0)}; const div document.createElement(div); div.className time-label; div.style.top (m - workDayStart) / totalMinutes * 100 %; div.textContent label; axis.appendChild(div); } }背景网格线我用 CSS 的linear-gradient画而不是生成一堆 DOM 节点。一条渐变就能画出一组横线高效又不占内存.grid-layer { position: absolute; inset: 0; background-image: linear-gradient( to bottom, #e5e7eb 1px, transparent 1px ); background-size: 100% calc(100% / 20); }这个calc(100% / 20)的意思是全天共 20 个半小时格子每一格的背景高度就是100% / 20。如果你调整了工作时间段或者刻度密度这里的 20 也要同步改。手动把数字写死容易忘我的习惯是直接用calc(100% / ${gridRows})动态生成内联样式保证 UI 和数据逻辑永远一致。2.3 渲染预约块位置和颜色的逻辑渲染预约块的核心函数就是前面坐标映射那一套的落地。遍历预约数组单条渲染重点是给颜色做个稳定的分类而不是每次都随机挑。function renderMeetings() { const layer document.getElementById(meetingLayer); layer.innerHTML ; const colorPalette [#4e8cff, #27ae60, #e67e22, #9b59b6, #e74c3c]; meetings.slice().sort((a, b) a.start - b.start).forEach((m, index) { const col dayToCol(m.date); const top (m.start - workDayStart) / totalMinutes * 100; const height (m.end - m.start) / totalMinutes * 100; const el document.createElement(div); el.className meeting-block; el.dataset.id m.id; el.style.left calc(${col * (100 / 7)}% 4px); el.style.width calc(${100 / 7}% - 8px); el.style.top top %; el.style.height height %; el.style.backgroundColor colorPalette[hashId(m.id) % colorPalette.length]; el.textContent ${m.title} (${formatTime(m.start)}-${formatTime(m.end)}); el.addEventListener(mousedown, startDrag); el.addEventListener(dblclick, editMeeting); layer.appendChild(el); }); }dayToCol负责把YYYY-MM-DD的日期转换成星期几对应的列索引2025-02-17是周一就返回0周二返回1以此类推。这个转换用内置的DateAPI 算就可以了。这里有一个细节我强调一下left和width使用calc拼接目的之一是给预约块左右留 4px 的边距这样相邻的预约在视觉上不会贴在一起目的之二是避免横向7列中块的左边缘刚好压在网格线上看起来总是差半像素。这个小的视觉细节对整体美观度的影响比想象中大得多。3. 预约管理的核心操作与冲突检测3.1 在空白处双击新建预约日历能看只是第一步能用才是重点。我给日历容器绑定了双击事件在空白位置双击时会自动把鼠标所在的坐标换算成时间段并弹出新建表单。遍历7列判断鼠标落在了星期几的某一列上再根据点击的纵坐标反推出对应的分钟数gridLayer.addEventListener(dblclick, function (e) { const rect gridLayer.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; const col Math.floor(x / (rect.width / 7)); const minutes Math.round((y / rect.height) * totalMinutes) workDayStart; // 按30分钟对齐 const start Math.floor(minutes / 30) * 30; openCreateForm(colToDate(col), start, start 30); });注意这里我做了Math.round再做Math.floor的对齐处理。直接Math.floor的话在刻度线附近点击会计算出和肉眼预期不一致的结果——你点在10:00线下方一点结果生成了9:30的预约体验很差。先四舍五入到最近值再向下取整到30分钟整能规避大部分边界情况。弹窗里的表单字段不必多有标题、开始时间、结束时间、备注就足够了。设计原则是让用户填最少的信息完成最多的表达。标题是刚需时间默认就取双击生成的那段用户一般只需要改个标题点保存完事。3.2 冲突检测的边界条件我差点被自己坑死冲突检测是会议室系统最核心的逻辑这个写不对整个系统就是摆设。先说结论两个预约在同一日期存在重叠的判断公式是function isConflict(a, b) { return a.date b.date a.start b.end a.end b.start; }这个公式看起来很简短但里面藏着很多细节。我第一次写的时候用的是一个更直觉的版本// 错误示范 if (a.start b.start a.start b.end) { /* 冲突 */ } if (a.end b.start a.end b.end) { /* 冲突 */ }这个版本在大多数场景确实能工作但对四种边界情况会产生误判A 从9:00到10:30B 从10:00到11:30此时 A.start 在 B 的时间段内A.end 不在直觉法靠第一个判断能捕获A 从8:30到10:00B 从9:00到9:30A.start 不在 B 范围内但 A.end 在直觉法也能捕获A 完全包裹了 B比如 A 从8:00到12:00B 从9:00到10:00此时 A.start 和 A.end 都不在 B 的范围内直觉法漏判A 和 B 之间的间隔正好是0分钟比如 A 到10:00结束B 从10:00开始这其实不算冲突但如果你用而不是就会被误判为冲突所以我最终坚定地使用a.start b.end a.end b.start这个区间重叠判定。它天然规避了上述四种情况的三种剩下那种完全包裹也被正确识别为冲突。保存的时候addMeeting里先遍历现有预约做全量检查function addMeeting(newMtg) { const conflicted meetings.some(m isConflict(m, newMtg) m.id ! newMtg.id); if (conflicted) { alert(该时间段已被其他预约占用请调整时间); return false; } // 排序并生成ID meetings.push(newMtg); renderMeetings(); saveToStorage(); return true; }3.3 修改、拖拽与删除的完整交互双击预约块可以进入编辑右击弹出删除菜单。拖拽是让这套系统真正好用的关键功能毕竟在实际使用中会议时间往后推半小时是再常见不过的操作。拖拽的实现逻辑是mousedown 时记录预约块的起始坐标和预约的原始起止时间mousemove 时计算鼠标的垂直位移换算出时间偏移量实时更新预览位置注意此时不写入数据只改视觉mouseup 时计算最终时间重新检测冲突并落库关键代码如下let dragState null; function startDrag(e) { const el e.currentTarget; const id el.dataset.id; const mtg meetings.find(m m.id id); dragState { startClientY: e.clientY, originalStart: mtg.start, originalEnd: mtg.end, id }; e.preventDefault(); } document.addEventListener(mousemove, e { if (!dragState) return; const dy e.clientY - dragState.startClientY; const offsetMinutes Math.round(dy / gridHeight * totalMinutes / 30) * 30; // 更新预览 updatePreview(dragState.id, dragState.originalStart offsetMinutes); }); document.addEventListener(mouseup, () { if (!dragState) return; // 计算并检测冲突 commitDrag(); dragState null; });这里有个非常关键的点mousemove 必须绑定在 document 上而不是预约块自身。你如果绑在块上鼠标一移出块的范围就会失焦拖拽体验很糟糕。绑定 document 后哪怕鼠标移出日历区域拖拽状态依然能持续。另外offsetMinutes的计算一定要用Math.round而不是Math.floor。用户拖拽往下移动了45分钟如果用Math.floor对齐到半小时实际只移动了30分钟视觉上永远慢半拍。只有用四舍五入指针停留在哪个半小时区间就归到哪个区间才符合直觉。4. 实测中遇到的坑有些细节只有被坑过才知道4.1 浮点运算和时间显示的噩梦离职的前同事给我留过一个旧版日历代码里充斥着parseFloat((top).toFixed(2))这类操作。computed 出来的 top 值本身是对的但经过一次toFixed(2)字符串转换和再解析在某些浏览器上就会引入千分之一的误差预约块底部渐渐和网格线错位几周下来整个日历看起来像是歪的。我后来立了个规矩凡涉及时间和定位一律使用整数分钟运算只在最后输出百分比时才允许除法产生小数。不要为了显示好看提前处理小数百分比本身就允许带小数。唯一需要注意的是在渲染标题和弹窗时把几分钟转换成09:30这种格式用padStart补零function formatTime(minutes) { const h Math.floor(minutes / 60); const m minutes % 60; return ${String(h).padStart(2, 0)}:${String(m).padStart(2, 0)}; }如果你确实需要保留两位小数——比如计算某个统计值——也请使用toFixed(2)但它是给文本展示用的不要拿它的结果继续参与坐标计算。这是新手最容易犯的展示层污染计算层的错误。4.2 滚动容器里的固定定位偏移日历的工作区从早到晚有600分钟高度超出屏幕是必然的所以外层必然是滚动容器。预约块在滚动容器里用绝对定位理论上只要父容器的坐标对齐就没问题但我被一个隐蔽的坑咬过getBoundingClientRect()返回的是相对视口的坐标而绝对定位的top值是在父级定位元素内部的坐标系。在双击新建预约时我用e.clientY - rect.top计算相对坐标。但如果滚动容器已经向下滚了200像素rect.top是负数这个差值算出来的分钟数就会偏大。解决办法是改用e.clientY - rect.top scrollBody.scrollTop把滚动偏移加回来或者干脆用e.offsetY——但要小心offsetY在不同浏览器的基准不一样踩过坑之后我还是老老实实手动加scrollTop稳得多。4.3 浏览器兼容性和运行时报错我最初写的这个系统只在 Chrome 上测后来发现同事用旧版的 Edge打开页面直接白屏控制台一堆SyntaxError。排查半天是模板字符串里嵌套了padStart而那个版本的 Edge 对 ES2017 的padStart支持不完整。这里给后来人一个建议目标环境不明确的纯前端项目写代码时尽量少用太新鲜的特性。let、const、箭头函数、模板字符串这些已经非常普及问题不大但padStart、Object.fromEntries、可选链?.这些还是保守点好。如果实在想用建议在 HTML 里补一个polyfill.io的脚本兜底。另一个让我记忆犹新的运行时报错是在预约块里绑定了双击事件但双击时却触发了 mousedown → mouseup → click → dblclick 的完整链路导致每次编辑后预约时间都被拖拽偏移了一格。原因是 mousedown 和双击同时触发了拖拽逻辑。解决方法是在 mousedown 里记录初始坐标mouseup 时如果位移小于3个像素认为这是单击/双击而不是拖拽直接放弃 commit。这个3个像素的容差处理是很多交互库都在用的成熟方案。4.4 从Excel迁移数据的清洗把现有的 Excel 预约数据导入系统最痛苦的不是写导入代码而是数据本身的脏乱。同事会在同一个格子写下午3点到4点也有人写3:00-4:30 讨论评审这些自然语言经过正则解析十有八九会有意外。我最终的做法是Excel 导入只作为可选功能提供一个importFromJSON函数要求数据结构严格符合预约模型。至于自然语言转结构化那不是这个纯前端系统该承担的职责。导入数据前我会先跑一遍数据清洗所有时间统一转成分钟整数日期统一转成YYYY-MM-DD字符串过滤掉跨周的预约或者把它们单独标记出来检查结束时间不能小于开始时间否则丢弃并提示清洗逻辑虽然枯燥但比在渲染层反复兜底要省心得多。数据入口干净后面所有功能都轻松。5. 从能用走向好用持久化、导出和扩展5.1 localStorage的保存与恢复数据不持久化刷新页面就全没了这个系统就只是个玩具。用 localStorage 保存预约数组几行代码就搞定const STORAGE_KEY meetingCalendar_v1; function saveToStorage() { localStorage.setItem(STORAGE_KEY, JSON.stringify(meetings)); } function loadFromStorage() { const raw localStorage.getItem(STORAGE_KEY); if (raw) { try { meetings JSON.parse(raw); } catch (e) { console.error(本地数据解析失败, e); meetings []; } } }版本号一定要带v1的作用是将来数据结构升级时可以根据版本号做迁移而不是暴力读取然后崩溃。数据量小每次增删改查后全量写入即可完全不用担心性能问题。5.2 导出和导入让数据真正属于你localStorage 的数据绑定在当前浏览器里换个电脑就没了所以导出导入功能是刚需。导出我做了两个层次一键导出 JSON 文件包含所有预约数据一键导出图片把当前周视图截图保存。截图功能用html2canvas这个库就可以引一个 CDN 文件十几行代码就能把日历转成 PNG。开会的时候领导问这周会议室安排怎么样我直接发个截图比打开网页方便。导入 JSON 文件时用input typefile读取文件内容解析后同样跑一遍数据清洗再覆盖或合并进内存数组。5.3 后续扩展方向的实操想法写完之后我实际用了一段日子又给它加了不少功能。几个我认为最值得扩展的方向是快速回到当前时间周历默认停留在本周但如果要翻到下周翻了很久之后想找回当前时间需要一个回到今天按钮。实现思路很简单算出今天在周列表里的偏移把滚动条scrollTop设置到当前时间的百分比位置再横滚到对应的列。地图模式的多会议室支持数据结构不需要大变只需给每条预约加一个roomId渲染时先按roomId分组再在每一组里各自渲染7列画网格。消息提醒很多会议系统最需要的不是一个花哨的日历而是在会议开始前15分钟提醒与会者。纯前端可以用Notification API加setTimeout实现但要注意浏览器限制。数据导入的容错如果有人改坏了 JSON 文件解析失败时别直接白屏用try...catch兜住回退到空数组并提示本地数据解析失败已重置。这些功能加完之后我自己最大的感受是系统不再是一个玩具是真的能应对日常工作流了。最后再分享一个小技巧。如果你将来想把这个系统打包成桌面应用给非技术同事用推荐用 Electron 或者 Tauri 简单包一层核心逻辑一行都不用改HTMLJavaScript 的代码完全复用只是从一个浏览器标签页变成了桌面窗口。我在本地试过把整个项目塞进 Electron半小时就打包出了 Windows 的可执行文件同事双击就能用连地址栏都省了。单会议室周日历管理系统这种规模的项目正好是前端最舒服的发挥空间——不需要重型框架不需要复杂工程链一个文件、一份清晰的逻辑就能解决一个真实的日常问题。
返回列表