纯HTML+JavaScript手写单会议室周日历管理:冲突检测与数据持久化

发布时间:2026/10/9 10:54:29
纯HTML+JavaScript手写单会议室周日历管理:冲突检测与数据持久化 说实话会议室管理系统这种需求我在不同公司见过不下十版。有用Excel排的有用付费系统抢的更多的还是拿着一本纸质登记本翻来翻去。今年年初我们部门分配了一间专属会议室人不多但每周的周会、评审会、客户远程交流全部挤在这一间里纸质登记彻底撑不住了。我顺手用纯 HTML JavaScript 写了一个单会议室周日历管理页面前后加起来大概三百行代码但从此再也没人问我“这周还有哪个时间段空着”。这类工具的本质是把一周七天、每天的工作时间展开成一张二维表然后让会议记录落在对应的格子里。听起来不复杂真做起来有几个坑相当隐蔽比如跨周时间的归属、会议结束时间和下一场开始的边界判断、刷新浏览器后数据还能不能回来。这篇文章从需求拆解说起把每一块逻辑、每一段关键代码都摊开讲清楚适合想自己动手解决同类问题的前端初学者也适合需要快速搭内部工具的运维或开发同学参考。1. 需求拆解单会议室周日历到底要管什么1.1 为什么选纯 HTML JavaScript先说选型。市面上会议室预订系统多得很企业微信、钉钉、Outlook 都有会议室功能但对我们这种“一间专属会议室 十来个固定成员”的轻量场景引入一个平台反而更麻烦——成员不统一、要申请权限、后台配置复杂。用纯 HTML JavaScript 实现的优势是零依赖随便一个浏览器打开 HTML 文件就能用不需要安装 Node、不需要启动服务器甚至可以直接放在共享目录里让同事双击打开。如果公司有内网服务器把文件扔进去就是一个网页服务连构建步骤都省了。我实际选择时的判断标准有三条第一维护成本要低接手的人哪怕只有一点前端基础也能改第二部署要快从开始写到同事用上不超过半天第三数据不能轻易丢至少要能通过 localStorage 在本地浏览器里持久化。这三点纯 HTML JavaScript 恰好全部满足。你可能想问为什么不直接用 Vue 或 React当然可以但对这个规模的工具来说引入框架反而增加心智负担。纯原生的 HTML JavaScript 没有构建步骤改完保存、刷新就看效果排错链路极短。我在后面写代码时也刻意避开了一切需要编译的语法就是为了让这份代码能原样保存下来当个能长期用的工具文件。1.2 页面的核心信息结构这个日历系统在视觉上要保持“一周七天 时间轴”的经典结构因为会议室预订最常问的问题是“明天下午三点有没有空”而不是“下个月三号上午有没有空”。用户的心智模型是周。整个页面从上到下分成三个区顶部操作区当前周标题、上一周/下一周/回到本周按钮以及“预订会议室”按钮。主体日历区左侧时间轴 右侧 7 天列每个会议按时间段放置在对应的位置。弹窗区域预订表单包含会议标题、日期、开始时间、结束时间、预订人。比较容易被忽略的是“当前周”的判定。我在系统里用周一作为一周的起始日周日作为最后一天这与国内大多数办公场景的习惯一致。实现时先取当天零点再算当天是本周第几天往前偏移得到周一往后偏移得到周日。这里用到 Date 对象的 getDay()它返回 0 到 6其中 0 表示周日所以需要做一个映射把周日当成一周的末尾。关于“一周从哪一天开始”看起来是小问题其实影响很大。如果按周日开始生成的周一列会跑到最后同事看的时候第一眼就看到周末体验怪怪的。所以这个细节虽然只有几行代码但对整体观感的影响非常大。2. 数据设计与核心算法2.1 会议记录的数据结构数据模型是整个系统的地基我一开始就定下了这几个字段字段类型说明idstring唯一标识用 Date.now() 拼接随机数生成titlestring会议标题比如“周会”datestring会议日期格式 YYYY-MM-DDstartstring开始时间格式 HH:mmendstring结束时间格式 HH:mmownerstring预订人姓名实际实现时我建议把 date、start、end 分开存放。有人喜欢合并成一个 ISO 字符串比如 2025-06-17T14:00:00做时间运算方便但表单回显和人类阅读都麻烦。分开存的代价只是在判断冲突时多一次拼接代码清晰度远高于收益。这里要重点说一下 id 字段。有人觉得数组下标就能定位记录何必再加一个 id问题出在取消预订的场景如果直接用数组下标定位一旦多条记录在同一个页面会话里被增删下标就会错位。我用 id 做定位配合 localStorage逻辑会稳很多也为以后多会议室扩展留了一条路。另外我还预留了一个 color 字段用来存会议类型的颜色。单会议室场景下这个字段看起来是多余的但实际用起来不同团队、不同会议类型用颜色区分后视觉辨识度会明显提升。这个字段加到结构里成本极低后续想扩展界面的时候却非常有用。2.2 时间冲突检测的核心逻辑这是会议室系统最核心的逻辑没有之一。两个会议冲突的定义是在同一个日期下A 的开始时间小于 B 的结束时间且 B 的开始时间小于 A 的结束时间。写成通用判断函数就是这样function isOverlap(startA, endA, startB, endB) { return startA endB startB endA; }这个公式第一次看觉得绕我用生活化的方式解释一下想象两段绳子A 绳从 startA 拉到 endAB 绳从 startB 拉到 endB。只要 A 的起点在 B 的终点之前同时 B 的起点在 A 的终点之前这两段绳子就一定在某处交叠。这个判断对字符串形式的“14:30”同样成立因为按字典序比较时间字符串恰好跟时间先后一致——前提是统一采用 HH:mm 的补零格式。边界情况的处理要注意我默认会议结束时间不参与占用。比如一场 10:00 到 11:00 的会议11:00 整是可以开下一场的。上面的公式也满足这个约定因为用的是小于而不是小于等于。如果你希望结束时间也占用把小于改成小于等于即可但现实中绝大多数场景不需要这种严格隔离。写成业务层的完整检查时需要先过滤出同一天的记录再做重叠判断。因为不同日期的会议永远不可能冲突如果不先过滤比较次数会平白多出六倍。我实现的提交函数里就是先判断 m.date date再调用 isOverlap这样既快又直观。2.3 本地存储与数据持久化纯前端页面没有后端数据存在哪里我用的是 localStorage。它的容量一般是 5MB 左右存几千条会议记录完全够用。关于持久化有三个实际操作层面的要点要分享写入之前必须做序列化。localStorage 只能存字符串所以要用 JSON.stringify 把会议数组转成字符串再存。读取的时候要处理空值。第一次访问页面时 localStorage 里什么都没有JSON.parse 解析空字符串会报错需要提前判断。每次增删改之后立即同步写入不要等页面关闭再写因为浏览器什么时候关闭是不可控的。我的写入封装大概是这样的const STORAGE_KEY meeting-room-calendar; function loadMeetings() { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return []; try { return JSON.parse(raw); } catch (e) { console.error(数据解析失败, e); return []; } } function saveMeetings(meetings) { localStorage.setItem(STORAGE_KEY, JSON.stringify(meetings)); }还有一个我踩过的坑localStorage 是按域名和浏览器隔离的同事用 Chrome 添加的会议换成 Edge 打开就看不到。所以我后来在页面上加了一个简单的导出备份功能把 JSON 下载下来万一浏览器缓存被清还能导回来。这个功能多加十行代码但关键时刻能救命。3. 周视图渲染与页面骨架3.1 页面整体布局设计布局我采用的是经典的“时间轴 网格”方案最左边一列是 08:00 到 20:00 的时间刻度右边七列对应周一至周日。网格使用 CSS Grid 实现每一行代表半小时。这样做的原因是半小时是会议预订中最常用的最小粒度按 30 分钟一格渲染既不会太密看不清也不会太粗导致时间模糊。这里有一个布局上的教训最初我把整个页面宽度固定成 1200px想着适配笔记本后来发现同事用宽屏显示器看时两侧空白太多会议标题又挤又小。后来改成相对单位让日历区域至少占满容器宽度同时给时间轴固定 60px 宽度其余的按比例分配给七天。用 CSS Grid 的写法是.calendar-grid { display: grid; grid-template-columns: 60px repeat(7, 1fr); }整个页面高度则需要处理。08:00 到 20:00 有 12 个小时就算每半小时 48px总高度也要 576px小屏笔记本会放不下。所以日历容器设置了 overflow-y: auto让用户可以滚动查看下午时段。3.2 周日期条生成逻辑顶部日期条要显示“周一 6/16”这样的格式同时需要把周一的具体日期算出来。这一段代码是整个系统里最容易出错的地方我单独写了一个函数function getWeekDates(baseDate) { const day baseDate.getDay(); // getDay(): 0周日, 1周一, ..., 6周六 // 把周一作为一周第一天 const diffToMonday (day 0) ? -6 : 1 - day; const monday new Date(baseDate); monday.setDate(baseDate.getDate() diffToMonday); const dates []; for (let i 0; i 7; i) { const d new Date(monday); d.setDate(monday.getDate() i); dates.push(d); } return dates; }我在这个函数上栽过一个大跟头直接用 baseDate 的 setDate 去修改原始对象再继续取其他日期就会互相污染。比如先计算周一又基于被修改后的 baseDate 计算周二得到的结果就乱套了。稳妥的做法是每次 new 一个 Date 再 setDate避免污染原始对象。格式化日期我统一用一个函数处理输出“6月16日”这种人类友好的格式同时保留一个 YYYY-MM-DD 格式用于数据关联。这里有个坑是 Date 对象 getMonth() 返回的月份从 0 开始格式化时要加 1。常见的错误结果就是页面显示 6 月但数据里关联到 7 月。3.3 会议块渲染的定位算法这一步是视觉呈现的关键。日历的每个格子是一个 30 分钟的时隙我需要把一条会议记录渲染成一块带背景色、标题、时间段的小卡片并且精确落在对应日期和时间内。定位算法是这样的先将会议的开始和结束时间解析为分钟数比如 14:30 就是 870 分钟。然后计算距离当天 08:00480 分钟的偏移量除以 30 得到行索引再乘上每行高度 48px 就是 top 值。高度由时长决定1 小时就是 96px。代码大概是function parseTimeToMinutes(timeStr) { const [h, m] timeStr.split(:).map(Number); return h * 60 m; } // 假设 dayStartMinutes 8 * 60 480 // rowHeight 48 表示每半小时的像素高度 function getMeetingStyle(meeting) { const startMin parseTimeToMinutes(meeting.start); const endMin parseTimeToMinutes(meeting.end); const top ((startMin - dayStartMinutes) / 30) * rowHeight; const height ((endMin - startMin) / 30) * rowHeight; return { top: top px, height: height px }; }渲染时用绝对定位把会议块放进对应日期的容器里。这里有个重要的层级问题必须把会议块放在对应日期的格子容器内而不是整个日历网格里否则跨列定位会乱套。我用的是在 7 个日期列容器内各自维护一个相对定位的 layer会议块绝对定位于这个 layer 内。会议块内的文字还要做截断处理。标题太长、时间段一长一个只有 48px 高的块根本放不下。我用了 white-space: nowrap、overflow: hidden、text-overflow: ellipsis 三件套再给会议块加 title 属性展示完整信息。这块如果不在 CSS 里处理会议标题一长就把布局撑破页面直接变形。4. 预订与取消预订的完整实现4.1 新建会议的表单校验点击“预订会议室”按钮会弹出一个模态框里面是表单。上线前我觉得表单没什么好写的实际用起来才发现校验才是糟点最多的地方。表单字段有五个标题、日期、开始时间、结束时间、预订人。校验逻辑我分了三个层级第一层是必填校验没有填的字段用红色提示标出来focus 时清除提示。第二层是时间逻辑校验结束时间必须大于开始时间开始时间和结束时间必须落在 08:00 到 20:00 的工作时间范围内早于 8 点和晚于 20 点的预订直接拒绝避免有人把会议排到凌晨。第三层是冲突校验提交的时候遍历当天所有已有会议逐个用 isOverlap 判断。三层校验全部通过才允许写入数据并刷新视图。关于校验顺序我的经验是“先格式后业务”。先判断时间格式是不是 HH:mm再判断大小关系最后判断重叠。如果一开始就做重叠判断用户可能收到一个奇怪的“与其他会议冲突”的提示但问题其实是时间大小关系都没满足。分层的做法让用户能一步步改对。4.2 冲突检测的完整流程冲突检测的代码不难但流程设计要细心。我在提交时的处理逻辑是这样function handleSubmit(e) { e.preventDefault(); // 1. 收集表单值 const title document.getElementById(title).value.trim(); const date document.getElementById(date).value; const start document.getElementById(start).value; const end document.getElementById(end).value; const owner document.getElementById(owner).value.trim(); // 2. 基础校验 if (!title || !date || !start || !end || !owner) { showError(所有字段都不能为空); return; } if (start end) { showError(结束时间必须晚于开始时间); return; } // 3. 冲突检测 const meetings loadMeetings(); const newMeeting { id: Date.now() Math.floor(Math.random() * 1000), title, date, start, end, owner }; for (const m of meetings) { if (m.date date isOverlap(m.start, m.end, start, end)) { showError(与《${m.title}》冲突该会议 ${m.start}-${m.end}); return; } } // 4. 写入并刷新 meetings.push(newMeeting); saveMeetings(meetings); renderCalendar(); closeModal(); }这里比较微妙的是第一步的 trim()。用户很容易在输入标题或姓名时首尾带上空格导致看起来一样的“张三”和“ 张三 ”被判定为不同的人。如果后续要加“显示我的预订”这类功能空格就是一个隐患。我习惯所有文本输入统一 trim 之后再处理包括 title 和 ownerdate 和 start 由浏览器原生控件保证格式风险较小。在冲突提示文案里加上对方的会议标题和时间段这个细节非常重要。用户看到“与《产品评审》冲突该会议 14:00-15:30”比看到干巴巴的“时间冲突请重新选择”要友好得多。做内部工具时这类体验改进几乎零成本但同事满意度提升非常明显。4.3 取消预订与操作反馈取消预订有两种方案直接删除记录或者标记一个 cancelled 状态。我选择了直接删除因为单会议室场景下记录一旦取消就没有保留价值留着反而干扰后续判断。第一次做完我说“能删了”同事们实际使用后发现一个问题直接点击删除按钮就会弹确认框手快时容易点错确认框和取消按钮的位置也容易弄混。后来我把删除交互改成一个更稳的方案点击会议块弹出详情浮层浮层里有两个按钮一个是“关闭”一个是“删除会议”。删除是红色按钮且需要二次确认。这个交互多写大约三十行代码但能避免手滑删掉重要会议。反馈机制上增删成功后我都会在页面显示一个几秒后自动消失的 toast 提示内容类似“已预订成功周三 6/18 14:00-15:30”。用户对操作是否成功需要一个即时确认可视化反馈比任何代码逻辑都更能提升信任感。这个问题如果不处理用户点了提交按钮发现页面没反应会下意识再点一次结果就重复添加了两条破坏力很强。5. 常见问题与排查技巧实录5.1 日期边界问题我在开发和测试过程中最常踩的坑就是一周跨月、跨年时的日期计算。比如 6 月 29 日周日点击“下一周”后日期跳到 7 月 6 日周日这本身没错但很多实现里因为复用了同一个 Date 对象做增减导致显示的是 6 月 6 日之类混乱结果。排查时我总结出一个经验把所有日期计算全部基于“毫秒时间戳”这个中间量最后展示时才格式化成字符串。比如一周中的任意一天用 monday.getTime() i * 86400000 来计算而不是连续 setDate。这样写起来没有 setDate 直观但跨月跨年绝对安全尤其遇到闰年、月末、年底这种特殊日期时不会被直觉误导。5.2 时间格式与解析坑时间字符串“9:00”和“09:00”对用户来说都是 9 点但对冲突检测影响很大。“9:00”和“10:00”比较时按字符串字典序“9”比“1”大会被错误判断成 9 点晚于 10 点。这个 Bug 一旦出现表现非常隐蔽因为大部分时间都是两位数只有 8 点和 9 点会漏补零。我的解决办法是双管齐下表单的 input 使用 typetime浏览器会按 HH:mm 返回在数据清洗时写一个 normalizeTime 函数自动把“9:00”转成“09:00”这样存储层所有时间都是统一格式冲突检测才可靠。这也是强调数据模型规范化的一个典型例子。5.3 刷新后数据丢失问题最典型的场景是添加了几条会议刷新浏览器后全部消失。排查思路是先看 localStorage 是否写入成功再看读取时有没有解析异常最后看渲染时会不会因为日期格式问题把记录过滤掉了。我遇到过一个诡异情况记录明明存在渲染时通过 date 字段匹配当前周日期结果显示不出来。排查后发现保存时我把 date 存成了 Date 对象JSON.stringify 之后变成 ISO 字符串比如“2025-06-17T00:00:00.000Z”而当前周日期格式化出来的是“2025-06-17”两者字符串不相等导致匹配失败。这就是前面强调统一格式的活生生案例后来我在保存前强制转成 YYYY-MM-DD 字符串这个问题彻底消失。另外如果直接用 file:// 协议双击打开 HTML部分浏览器对 localStorage 的支持有限制数据可能写入失败。我遇到这种情况时第一反应是检查浏览器控制台有没有报错第二是确认不是隐私模式。隐私模式下 localStorage 不会持久化关掉浏览器就没了。安全的方案是建议有条件的团队把文件挂到内网静态服务器上用 http 访问。5.4 编码与兼容性提示发给同事用的时候要注意 HTML 文件用 UTF-8 编码保存并在 head 里声明 meta charsetutf-8否则中文标题和姓名在部分浏览器里会乱码。CSS 里也要注意 flex 和 grid 的兼容性现阶段 Chrome 和 Edge 已经完全支持但如果有同事还在用老旧的浏览器纯 Grid 方案可能直接失效需要加降级策略。我的做法是检测到不支持 Grid 布局时退回使用最简单的表格布局。这个降级不值得作为卖点去宣传纯粹是因为实际团队里总有一两个用旧设备的同事。多写一套 fallback比天天被问“为什么我打不开”要好得多。6. 延伸扩展与个人实操体会做这个单会议室周日历管理系统我最深的感受是很多看似复杂的功能拆开之后核心只剩下数据结构设计和冲突判断算法这两个点。所有界面上的按钮、弹窗、动画都是围绕它们展开的。如果一开始就把数据模型定清楚后面写代码几乎一马平川。另一个体会是内部工具的价值不在于技术多先进而在于贴合实际场景。同事并不关心页面是原生 JavaScript 写的还是用了最新框架他们只想快速知道“明天下午有没有空”“我要预订周四上午 10 点的会议室”。用最简单的 HTML JavaScript 解决一个真实问题比追逐框架潮流有价值得多。最后分享一个继续扩展的方向如果后续需要多会议室版本只需要把数据模型从“单会议室的会议记录”扩展成“会议室 id 会议记录”渲染时做一层分组核心的冲突检测逻辑完全可以复用。从单会议室到多会议室本质上只是遍历会议室列表各跑一遍同样的流程。如果你也在考虑类似需求我建议先跑通单会议室版本确认流程没问题后再加 roomId 字段避免一上来就陷入多会议室架构的复杂度里。我个人的建议是这种小工具不要贪大先把最简单可用的版本交付出去让真实使用场景来催你迭代。开会撞车了自然有人来提需求时间显示不直观用两天自然有人提意见。让工具长在需求上而不是让需求去适配工具。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询