使用 HTML + JavaScript 实现多会议室甘特视图管理系统

文章目录

一、多会议室甘特视图管理系统

在现代企业办公环境中,会议室资源的有效管理是提升工作效率的重要环节。本文将详细介绍一个基于 HTML、CSS 和 JavaScript 实现的多会议室甘特视图管理系统,帮助用户直观地查看和管理会议室预订情况。

二、效果演示

该系统通过甘特图形式展示多个会议室在一天内的使用情况,用户可以选择不同日期查看会议室预订状态,并能申请新的会议。系统提供了清晰的时间轴和颜色编码来区分不同状态的会议。用户可以方便地查看会议室占用情况,并通过简单的界面提交新的会议室预订申请。


三、系统分析

1、页面结构

系统主要包含以下几个功能区域:

1.1 控制面板区域

控制面板位于页面顶部,提供日期选择和基本操作按钮。这个区域允许用户选择查看的日期,并提供了申请会议的按钮。

html 复制代码
<div class="controls">
  <input type="date" id="dateInp">
  <button onclick="search()">查询</button>
  <button onclick="openApplyModal()">申请会议</button>
</div>

1.2 图例说明区域

为了让用户更好理解不同颜色代表的含义,系统提供了图例说明。

html 复制代码
<div class="legend">
  <div class="legend-item">
    <div class="legend-color yellow"></div>
    <span>待审批</span>
  </div>
  <!-- 其他状态图例 -->
</div>

1.3 甘特图展示区域

这是系统的核心展示区域,以表格形式呈现各会议室在不同时段的使用情况。

html 复制代码
<div class="gantt-container">
  <div id="gantt" class="gantt"></div>
</div>

1.4 弹窗区域

系统包含两个主要弹窗:会议申请弹窗和会议详情弹窗,分别用于创建新会议和查看详情。

html 复制代码
<div id="applyModal" class="modal">...</div>
<div id="meetingModal" class="modal">...</div>

2、核心功能实现

2.1 数据模型设计

系统首先定义了基础数据结构,包括会议室列表、时间片数组和状态映射。

javascript 复制代码
var rooms = ['梅花厅','兰亭厅','竹苑厅','菊堂厅'];
var timeArr = ['08:30','09:00','09:30','10:00','10:30','11:00','11:30','12:00','12:30','13:00','13:30','14:00','14:30','15:00','15:30','16:00','16:30','17:00','17:30','18:00','18:30'];
var statusMap = {
  1: '待审批',
  3: '已批准',
  4: '进行中',
  5: '已完成'
};

2.2 甘特图渲染机制

甘特图渲染是系统最核心的功能,通过 renderGantt 函数实现:

  1. 首先创建时间标题行,显示各个时间点
  2. 然后为每个会议室创建一行,显示该会议室在各个时间段的状态
  3. 最后为每个会议条目绑定点击事件,用于显示详细信息
javascript 复制代码
function renderGantt(meetingsData) {
  var box = document.getElementById('gantt');
  box.innerHTML = '';

  // 创建时间标题行
  var hRow = document.createElement('div');
  hRow.className = 'row';
  hRow.innerHTML = '<div class="cell-time"></div>' +
    timeArr.map(t => `<div class="cell-time">${t}</div>`).join('');
  box.appendChild(hRow);

  // 渲染每行会议室
  rooms.forEach(room => {
    var row = document.createElement('div');
    row.className = 'row';

    var html = `<div class="cell room">${room}</div>`;
    var roomMeetings = meetingsData.filter(m => m.room === room)[0]?.map || {};

    timeArr.forEach(time => {
      if (roomMeetings[time]) {
        var [len, color, meeting] = roomMeetings[time];
        html += `<div class="cell">
                    <div class="meeting ${color}"
                         style="width:calc(${len*100}% + ${(len-1)*2}px)"
                         data-meeting='${JSON.stringify(meeting)}'>
                    </div>
                   </div>`;
      } else {
        html += '<div class="cell"></div>';
      }
    });

    row.innerHTML = html;
    box.appendChild(row);
  });

  // 绑定点击事件
  document.querySelectorAll('.meeting').forEach(el => {
    el.addEventListener('click', () => {
      var meetingData = JSON.parse(el.getAttribute('data-meeting'));
      showMeetingDetail(meetingData);
    });
  });
}

2.3 会议申请与冲突检测

系统支持用户提交新的申请,并具备冲突检测功能:

  1. 用户填写会议信息,包括主题、日期、会议室、时间等
  2. 系统检查所选时间段是否与现有会议冲突
  3. 如果没有冲突,则将新会议添加到用户会议列表中
javascript 复制代码
function submitMeeting() {
  // ... 获取用户填写信息
  var conflictingMeetings = getMeetingsByDate(date).filter(m =>
    m.room === room && m.date === date && isTimeOverlap(startTime, duration, m.start, m.time)
  );

  if (conflictingMeetings.length > 0) {
    alert('该时间段已有会议,请选择其他时间');
    return;
  }

  userMeetings.push({
    room,
    date,
    start: startTime,
    time: duration,
    status: 1,
    isCreator: 'true',
    title,
    content,
    attendeeCount: parseInt(attendeeCount)
  });

  alert('会议申请已提交');
  closeApplyModal();

  // 清空表单
  ['modalMeetingTitle', 'modalMeetingContent', 'modalAttendeeCount'].forEach(id => {
    document.getElementById(id).value = '';
  });

  search();
}

2.4 时间选择联动

为了提高用户体验,系统的会议时间选择具有联动效果。当用户选择开始时间后,结束时间选项会自动更新,只显示晚于开始时间的选项。

javascript 复制代码
function updateEndTimeOptions() {
  var startTimeSelect = document.getElementById('startTimeSelect');
  var endTimeSelect = document.getElementById('endTimeSelect');
  var selectedStartTime = startTimeSelect.value;

  endTimeSelect.innerHTML = '';

  var startIndex = timeArr.indexOf(selectedStartTime);
  for (var i = startIndex + 1; i < timeArr.length; i++) {
    var option = document.createElement('option');
    option.value = timeArr[i];
    option.textContent = timeArr[i];
    endTimeSelect.appendChild(option);
  }
}

四、扩展建议

  • 权限管理系统:增加用户角色管理,区分普通用户、管理员等不同权限,允许管理员审批会议申请。
  • 导入导出功能:支持将会议室预订情况导出为Excel或PDF格式,便于统计和汇报。
  • 会议室资源配置:为每个会议室添加容量、设备等详细信息,帮助用户选择合适的会议室。
  • 重复会议功能:支持创建周期性会议,如每周例会等。
  • 人员选择功能:支持直接选择参会人员,方便每个人参看自己要参与的会议。

五、完整代码

git地址:https://gitee.com/ironpro/hjdemo/blob/master/meeting-gantt/index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>多会议室甘特视图</title>
  <style>
      * {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
      }
      body {
          background-color: #f5f5f5;
          min-height: 100vh;
          padding: 20px;
      }
      .container {
          max-width: 1500px;
          margin: 0 auto;
          background: white;
          border-radius: 15px;
          box-shadow: 0 20px 40px rgba(0,0,0,0.1);
          overflow: hidden;
      }
      .header {
          background: #4a5568;
          color: white;
          padding: 20px;
          text-align: center;
      }

      .header h1 {
          font-size: 24px;
          font-weight: 500;
      }
      .main {
          padding: 20px;
      }
      .controls {
          display: flex;
          gap: 10px;
          margin-bottom: 10px;
          flex-wrap: wrap;
      }

      select, button, input, textarea {
          padding: 8px 12px;
          border: 1px solid #e0e0e0;
          border-radius: 4px;
      }

      button {
          background: #409EFF;
          color: white;
          border: none;
          cursor: pointer;
          transition: background 0.3s;
      }

      button:hover {
          opacity: 0.9;
      }

      .gantt-container {
          background: white;
          border: 1px solid #e0e0e0;
          border-radius: 4px;
          overflow: hidden;
          box-shadow: 0 2px 5px rgba(0,0,0,0.05);
      }

      .gantt {
          width: 100%;
          display: table;
          table-layout: fixed;
      }

      .row {
          display: table-row;
      }

      .cell {
          display: table-cell;
          border: 1px solid #e0e0e0;
          text-align: center;
          vertical-align: middle;
          height: 36px;
          width: 4.2%;
          position: relative;
      }

      .cell.room {
          width: 11.8%;
          font-weight: bold;
          background: #fafafa;
      }

      .cell-time {
          width: 4.2%;
          height: 38px;
          font-size: 12px;
          color: #666;
          display: table-cell;
          text-align: center;
          vertical-align: middle;
          position: relative;
          left: -2.1%;
      }

      .cell-time:first-child {
          left: 0;
      }

      .meeting {
          height: 10px;
          border-radius: 3px;
          position: absolute;
          left: 0;
          top: 13px;
          z-index: 999;
          cursor: pointer;
      }

      .yellow { background: #FFCE1A; }
      .blue { background: #409EFF; }
      .pink { background: #DE1794; }
      .gray { background: #777; }

      .pagination {
          text-align: right;
          margin-top: 10px;
      }

      .legend {
          margin: 15px 0;
          display: flex;
          align-items: center;
          gap: 20px;
          flex-wrap: wrap;
      }

      .legend-item {
          display: flex;
          align-items: center;
          gap: 5px;
      }

      .legend-color {
          width: 20px;
          height: 10px;
          border-radius: 3px;
      }

      .apply-form {
          margin: 20px 0;
          padding: 15px;
          border: 1px solid #e0e0e0;
          background: #fff;
          border-radius: 4px;
      }

      .apply-form input,
      .apply-form textarea,
      .apply-form select {
          margin-right: 10px;
          margin-bottom: 10px;
      }

      .modal {
          display: none;
          position: fixed;
          z-index: 1000;
          left: 0;
          top: 0;
          width: 100%;
          height: 100%;
          background-color: rgba(0,0,0,0.5);
      }

      .modal-content {
          background-color: #fff;
          margin: 10% auto;
          padding: 20px;
          border: none;
          width: 90%;
          max-width: 500px;
          border-radius: 8px;
          box-shadow: 0 4px 15px rgba(0,0,0,0.2);
      }

      .close {
          color: #aaa;
          float: right;
          font-size: 28px;
          font-weight: bold;
          cursor: pointer;
          line-height: 1;
      }

      .close:hover {
          color: #000;
      }

      .meeting-detail div {
          margin-bottom: 12px;
      }

      .meeting-detail label {
          font-weight: bold;
          margin-right: 10px;
          display: inline-block;
          width: 80px;
      }

      .time-selection {
          display: flex;
          align-items: center;
          gap: 10px;
          margin: 15px 0;
      }

      .time-selection select {
          padding: 5px;
      }
  </style>
</head>
<body>
<div class="container">
  <div class="header">
    <h1>多会议室甘特视图</h1>
  </div>

  <div class="main">
    <div class="controls">
      <input type="date" id="dateInp">
      <button onclick="search()">查询</button>
      <button onclick="openApplyModal()">申请会议</button>
    </div>

    <div class="legend">
      <div class="legend-item">
        <div class="legend-color yellow"></div>
        <span>待审批</span>
      </div>
      <div class="legend-item">
        <div class="legend-color blue"></div>
        <span>已批准</span>
      </div>
      <div class="legend-item">
        <div class="legend-color pink"></div>
        <span>进行中</span>
      </div>
      <div class="legend-item">
        <div class="legend-color gray"></div>
        <span>已完成</span>
      </div>
    </div>

    <div class="gantt-container">
      <div id="gantt" class="gantt"></div>
    </div>
  </div>
</div>

<!-- 会议申请弹窗 -->
<div id="applyModal" class="modal">
  <div class="modal-content">
    <span class="close" onclick="closeApplyModal()">&times;</span>
    <h3>会议申请</h3>
    <div class="meeting-detail">
      <div>
        <label>会议主题:</label>
        <input type="text" id="modalMeetingTitle" placeholder="请输入会议主题">
      </div>
      <div>
        <label>会议日期:</label>
        <input type="date" id="modalApplyDate">
      </div>
      <div>
        <label>会议室:</label>
        <select id="modalApplyRoom">
          <option value="">选择会议室</option>
        </select>
      </div>
      <div class="time-selection">
        <label>会议时间:</label>
        <select id="startTimeSelect"></select>
        <span>到</span>
        <select id="endTimeSelect"></select>
      </div>
      <div>
        <label>参会人数:</label>
        <input type="number" id="modalAttendeeCount" min="1" placeholder="请输入人数">
      </div>
      <div>
        <label>会议内容:</label>
        <textarea id="modalMeetingContent" placeholder="请输入会议内容"></textarea>
      </div>
      <div style="text-align: right; margin-top: 15px;">
        <button onclick="closeApplyModal()" style="background:#999">取消</button>
        <button onclick="submitMeeting()">提交申请</button>
      </div>
    </div>
  </div>
</div>

<!-- 会议详情弹窗 -->
<div id="meetingModal" class="modal">
  <div class="modal-content">
    <span class="close" onclick="closeMeetingModal()">&times;</span>
    <h3>会议详情</h3>
    <div class="meeting-detail" id="meetingDetailContent"></div>
  </div>
</div>

<script>
  // 数据和配置
  var rooms = ['梅花厅','兰亭厅','竹苑厅','菊堂厅'];
  var timeArr = ['08:30','09:00','09:30','10:00','10:30','11:00','11:30','12:00','12:30','13:00','13:30','14:00','14:30','15:00','15:30','16:00','16:30','17:00','17:30','18:00','18:30'];
  var statusMap = {
    1: '待审批',
    3: '已批准',
    4: '进行中',
    5: '已完成'
  };

  // 固定的模拟数据
  var mockDataByDate = {
    '2025-11-25': {
      '梅花厅': [
        {start: '09:00', time: 2, status: 1, title: '项目启动会', content: '讨论新项目启动相关事宜', attendeeCount: 15},
        {start: '14:00', time: 3, status: 3, title: '技术评审会', content: '代码和技术方案评审', attendeeCount: 8}
      ],
      '兰亭厅': [
        {start: '10:00', time: 1, status: 4, title: '客户洽谈会', content: '重要客户合作洽谈', attendeeCount: 5},
        {start: '15:00', time: 2, status: 5, title: '培训会', content: '新员工技能培训', attendeeCount: 20}
      ],
      '竹苑厅': [
        {start: '09:30', time: 2, status: 1, title: '部门例会', content: '部门日常工作安排', attendeeCount: 5},
        {start: '14:30', time: 1, status: 3, title: '预算审批会', content: '部门预算审批讨论', attendeeCount: 6}
      ],
      '菊堂厅': [
        {start: '11:00', time: 2, status: 4, title: '合作伙伴会', content: '合作伙伴关系维护', attendeeCount: 10},
        {start: '16:00', time: 1, status: 5, title: '安全培训会', content: '安全知识培训', attendeeCount: 25}
      ]
    },
    // 其他日期数据
  };

  var userMeetings = [];

  var dayjs = (d) => {
    var date = new Date(d);
    return {
      format(fmt) {
        return fmt.replace('YYYY', date.getFullYear())
          .replace('MM', String(date.getMonth() + 1).padStart(2, '0'))
          .replace('DD', String(date.getDate()).padStart(2, '0'));
      }
    };
  };

  // 检查时间重叠
  var isTimeOverlap = (start1, duration1, start2, duration2) => {
    var startIndex1 = timeArr.indexOf(start1);
    var endIndex1 = startIndex1 + duration1;
    var startIndex2 = timeArr.indexOf(start2);
    var endIndex2 = startIndex2 + duration2;
    return (startIndex1 < endIndex2) && (startIndex2 < endIndex1);
  };

  // 获取指定日期的会议数据
  var getMeetingsByDate = (date) => {
    var meetings = [];

    rooms.forEach(room => {
      var dateData = mockDataByDate[date] || {};
      var roomMeetings = dateData[room] || [];

      roomMeetings.forEach(meeting => {
        meetings.push({
          room,
          date,
          start: meeting.start,
          time: meeting.time,
          status: meeting.status,
          isCreator: Math.random() > 0.5 ? 'true' : 'false',
          title: meeting.title,
          content: meeting.content,
          attendeeCount: meeting.attendeeCount
        });
      });
    });

    meetings.push(...userMeetings.filter(m => m.date === date));
    return meetings;
  };

  // 渲染甘特图
  function renderGantt(meetingsData) {
    var box = document.getElementById('gantt');
    box.innerHTML = '';

    // 创建时间标题行
    var hRow = document.createElement('div');
    hRow.className = 'row';
    hRow.innerHTML = '<div class="cell-time"></div>' +
      timeArr.map(t => `<div class="cell-time">${t}</div>`).join('');
    box.appendChild(hRow);

    // 渲染每行会议室
    rooms.forEach(room => {
      var row = document.createElement('div');
      row.className = 'row';

      var html = `<div class="cell room">${room}</div>`;
      var roomMeetings = meetingsData.filter(m => m.room === room)[0]?.map || {};

      timeArr.forEach(time => {
        if (roomMeetings[time]) {
          var [len, color, meeting] = roomMeetings[time];
          html += `<div class="cell">
                      <div class="meeting ${color}"
                           style="width:calc(${len*100}% + ${(len-1)*2}px)"
                           data-meeting='${JSON.stringify(meeting)}'>
                      </div>
                     </div>`;
        } else {
          html += '<div class="cell"></div>';
        }
      });

      row.innerHTML = html;
      box.appendChild(row);
    });

    // 绑定点击事件
    document.querySelectorAll('.meeting').forEach(el => {
      el.addEventListener('click', () => {
        var meetingData = JSON.parse(el.getAttribute('data-meeting'));
        showMeetingDetail(meetingData);
      });
    });
  }

  // 查询功能
  function search() {
    var date = document.getElementById('dateInp').value;
    if (!date) date = dayjs(new Date()).format('YYYY-MM-DD');
    else date = dayjs(date).format('YYYY-MM-DD');

    var list = getMeetingsByDate(date);

    // 按会议室分组
    var groupedData = rooms.map(room => ({
      room,
      name: room,
      map: {}
    }));

    list.forEach(meeting => {
      var color = {1:'yellow', 3:'blue', 4:'pink', 5:'gray'}[meeting.status];
      var roomData = groupedData.find(r => r.room === meeting.room);
      if (roomData) {
        roomData.map[meeting.start] = [meeting.time, color, meeting];
      }
    });

    renderGantt(groupedData);
  }

  // 弹窗相关函数
  function openApplyModal() {
    var modal = document.getElementById('applyModal');
    document.getElementById('modalApplyDate').value = dayjs(new Date()).format('YYYY-MM-DD');

    var roomSelect = document.getElementById('modalApplyRoom');
    roomSelect.innerHTML = '<option value="">选择会议室</option>';
    rooms.forEach(room => {
      var option = document.createElement('option');
      option.value = room;
      option.textContent = room;
      roomSelect.appendChild(option);
    });

    initTimeSelectors();
    modal.style.display = 'block';
  }

  function closeApplyModal() {
    document.getElementById('applyModal').style.display = 'none';
  }

  function initTimeSelectors() {
    var startTimeSelect = document.getElementById('startTimeSelect');
    var endTimeSelect = document.getElementById('endTimeSelect');

    startTimeSelect.innerHTML = '';
    endTimeSelect.innerHTML = '';

    timeArr.slice(0, -1).forEach(time => {
      var option = document.createElement('option');
      option.value = time;
      option.textContent = time;
      startTimeSelect.appendChild(option);
    });

    timeArr.slice(1).forEach(time => {
      var option = document.createElement('option');
      option.value = time;
      option.textContent = time;
      endTimeSelect.appendChild(option);
    });

    startTimeSelect.selectedIndex = 0;
    endTimeSelect.selectedIndex = 0;

    startTimeSelect.onchange = updateEndTimeOptions;
  }

  function updateEndTimeOptions() {
    var startTimeSelect = document.getElementById('startTimeSelect');
    var endTimeSelect = document.getElementById('endTimeSelect');
    var selectedStartTime = startTimeSelect.value;
    var currentEndTime = endTimeSelect.value;

    endTimeSelect.innerHTML = '';

    var startIndex = timeArr.indexOf(selectedStartTime);
    for (var i = startIndex + 1; i < timeArr.length; i++) {
      var option = document.createElement('option');
      option.value = timeArr[i];
      option.textContent = timeArr[i];
      endTimeSelect.appendChild(option);
    }

    if (timeArr.indexOf(currentEndTime) > startIndex) {
      endTimeSelect.value = currentEndTime;
    } else {
      endTimeSelect.selectedIndex = 0;
    }
  }

  function submitMeeting() {
    var title = document.getElementById('modalMeetingTitle').value;
    var content = document.getElementById('modalMeetingContent').value;
    var date = document.getElementById('modalApplyDate').value;
    var room = document.getElementById('modalApplyRoom').value;
    var attendeeCount = document.getElementById('modalAttendeeCount').value;
    var startTime = document.getElementById('startTimeSelect').value;
    var endTime = document.getElementById('endTimeSelect').value;

    if (!title || !date || !room || !attendeeCount || !startTime || !endTime) {
      alert('请填写完整信息');
      return;
    }

    var startIndex = timeArr.indexOf(startTime);
    var endIndex = timeArr.indexOf(endTime);

    if (startIndex >= endIndex) {
      alert('结束时间必须晚于开始时间');
      return;
    }

    var duration = endIndex - startIndex;
    var conflictingMeetings = getMeetingsByDate(date).filter(m =>
      m.room === room && m.date === date && isTimeOverlap(startTime, duration, m.start, m.time)
    );

    if (conflictingMeetings.length > 0) {
      alert('该时间段已有会议,请选择其他时间');
      return;
    }

    userMeetings.push({
      room,
      date,
      start: startTime,
      time: duration,
      status: 1,
      isCreator: 'true',
      title,
      content,
      attendeeCount: parseInt(attendeeCount)
    });

    alert('会议申请已提交');
    closeApplyModal();

    // 清空表单
    ['modalMeetingTitle', 'modalMeetingContent', 'modalAttendeeCount'].forEach(id => {
      document.getElementById(id).value = '';
    });

    search();
  }

  function showMeetingDetail(meeting) {
    var modal = document.getElementById('meetingModal');
    var detailContent = document.getElementById('meetingDetailContent');

    var startTimeIndex = timeArr.indexOf(meeting.start);
    var endTimeIndex = startTimeIndex + meeting.time;
    var endTime = endTimeIndex < timeArr.length ? timeArr[endTimeIndex] : '结束';
    var statusText = statusMap[meeting.status] || '未知';

    detailContent.innerHTML = `
        <div><label>主题:</label> ${meeting.title}</div>
        <div><label>日期:</label> ${meeting.date}</div>
        <div><label>地点:</label> ${meeting.room}</div>
        <div><label>时间:</label> ${meeting.start} - ${endTime}</div>
        <div><label>状态:</label> ${statusText}</div>
        <div><label>参会人数:</label> ${meeting.attendeeCount || 'N/A'}</div>
        <div><label>会议内容:</label> ${meeting.content || '无'}</div>
      `;

    modal.style.display = 'block';
  }

  function closeMeetingModal() {
    document.getElementById('meetingModal').style.display = 'none';
  }

  // 页面初始化
  window.onload = function() {
    document.getElementById('dateInp').value = dayjs(new Date()).format('YYYY-MM-DD');
    search();

    window.onclick = function(event) {
      var applyModal = document.getElementById('applyModal');
      var meetingModal = document.getElementById('meetingModal');

      if (event.target === applyModal) closeApplyModal();
      if (event.target === meetingModal) closeMeetingModal();
    };
  };
</script>

</body>
</html>
相关推荐
qiqiliuwu1 小时前
VUE3+TS+ElementUI项目中监测页面滚动scroll事件以及滚动高度不生效问题的解决方案(window.addEventListener)
前端·javascript·elementui·typescript·vue
LawrenceLan2 小时前
16.Flutter 零基础入门(十六):Widget 基础概念与第一个 Flutter 页面
开发语言·前端·flutter·dart
喔烨鸭2 小时前
antdv编辑表格,根据选择时间区间展示动态列
前端·vue·表格编辑
天天向上10242 小时前
el-table 解决一渲染数据页面就卡死
前端·javascript·vue.js
bjzhang752 小时前
使用 HTML + JavaScript 实现单会议室周日历管理系统
前端·javascript·html
weixin_431600442 小时前
开发中遇到需要对组件库组件结构调整的两种落地方案实践
前端·组件库
Code知行合壹2 小时前
Vue3入门
前端·javascript·vue.js
LawrenceLan2 小时前
17.Flutter 零基础入门(十七):StatelessWidget 与 State 的第一次分离
开发语言·前端·flutter·dart
酷酷的鱼2 小时前
Expo Router vs 原生React Native 完全对比指南
javascript·react native·react.js