☰
纯HTML+JavaScript手写单会议室周日历管理:冲突检测与数据持久化
2026/10/9 10:54:27 网站建设 项目流程

说实话,会议室管理系统这种需求,我在不同公司见过不下十版。有用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-DD
startstring开始时间,格式 HH:mm
endstring结束时间,格式 HH:mm
ownerstring预订人姓名

实际实现时我建议把 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 拉到 endA,B 绳从 startB 拉到 endB。只要 A 的起点在 B 的终点之前,同时 B 的起点在 A 的终点之前,这两段绳子就一定在某处交叠。这个判断对字符串形式的“14:30”同样成立,因为按字典序比较时间字符串恰好跟时间先后一致——前提是统一采用 HH:mm 的补零格式。

边界情况的处理要注意:我默认会议结束时间不参与占用。比如一场 10:00 到 11:00 的会议,11:00 整是可以开下一场的。上面的公式也满足这个约定,因为用的是小于而不是小于等于。如果你希望结束时间也占用,把小于改成小于等于即可,但现实中绝大多数场景不需要这种严格隔离。

写成业务层的完整检查时,需要先过滤出同一天的记录再做重叠判断。因为不同日期的会议永远不可能冲突,如果不先过滤,比较次数会平白多出六倍。我实现的提交函数里就是先判断 m.date === date,再调用 isOverlap,这样既快又直观。

2.3 本地存储与数据持久化

纯前端页面没有后端,数据存在哪里?我用的是 localStorage。它的容量一般是 5MB 左右,存几千条会议记录完全够用。

关于持久化,有三个实际操作层面的要点要分享:

  1. 写入之前必须做序列化。localStorage 只能存字符串,所以要用 JSON.stringify 把会议数组转成字符串再存。
  2. 读取的时候要处理空值。第一次访问页面时 localStorage 里什么都没有,JSON.parse 解析空字符串会报错,需要提前判断。
  3. 每次增删改之后立即同步写入,不要等页面关闭再写,因为浏览器什么时候关闭是不可控的。

我的写入封装大概是这样的:

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:00(480 分钟)的偏移量,除以 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 和 owner,date 和 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 使用 type="time",浏览器会按 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 charset="utf-8",否则中文标题和姓名在部分浏览器里会乱码。CSS 里也要注意 flex 和 grid 的兼容性,现阶段 Chrome 和 Edge 已经完全支持,但如果有同事还在用老旧的浏览器,纯 Grid 方案可能直接失效,需要加降级策略。

我的做法是检测到不支持 Grid 布局时,退回使用最简单的表格布局。这个降级不值得作为卖点去宣传,纯粹是因为实际团队里总有一两个用旧设备的同事。多写一套 fallback,比天天被问“为什么我打不开”要好得多。

6. 延伸扩展与个人实操体会

做这个单会议室周日历管理系统,我最深的感受是:很多看似复杂的功能,拆开之后核心只剩下数据结构设计和冲突判断算法这两个点。所有界面上的按钮、弹窗、动画,都是围绕它们展开的。如果一开始就把数据模型定清楚,后面写代码几乎一马平川。

另一个体会是,内部工具的价值不在于技术多先进,而在于贴合实际场景。同事并不关心页面是原生 JavaScript 写的还是用了最新框架,他们只想快速知道“明天下午有没有空”“我要预订周四上午 10 点的会议室”。用最简单的 HTML + JavaScript 解决一个真实问题,比追逐框架潮流有价值得多。

最后分享一个继续扩展的方向:如果后续需要多会议室版本,只需要把数据模型从“单会议室的会议记录”扩展成“会议室 id + 会议记录”,渲染时做一层分组,核心的冲突检测逻辑完全可以复用。从单会议室到多会议室,本质上只是遍历会议室列表各跑一遍同样的流程。如果你也在考虑类似需求,我建议先跑通单会议室版本,确认流程没问题后再加 roomId 字段,避免一上来就陷入多会议室架构的复杂度里。

我个人的建议是:这种小工具不要贪大,先把最简单可用的版本交付出去,让真实使用场景来催你迭代。开会撞车了,自然有人来提需求;时间显示不直观,用两天自然有人提意见。让工具长在需求上,而不是让需求去适配工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询