iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Build on Google AI

零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統系列 第 13 篇

【Day 13】全平台響應式 UI/UX:橫向 7 天週月曆、桌面/行動雙模拖拉(Mouse & Touch Drag-to-Select)與五房間切換

  • 分享至 

  • xImage
  •  

今天我們將重點放在 「前端預約介面的全平台 UX 升級」。為了讓職工無論是在桌面電腦使用滑鼠,還是在 iPhone / iPad / Android 等行動裝置上透過觸控螢幕操作,都能享受流暢且防呆的預約體驗,我們實作了以下四大核心功能:

  1. 五大場地大型按鈕: 頂端提供 A1、A2、B1、B2 及洽談室 獨立大型切換按鈕,點選即時載入該場地的預約數據。
  2. 橫向 7 天(7 列)x 縱向時段檢視: 採用標準週月曆佈局(欄為週一至週日共 7 天,列為 08:00~22:00 縱向時段),全一週空缺一目瞭然。
  3. 紅綠色塊可視化(🟩 / 🟥 / ⬜ / 🟦): 🟩 可預約、🟥 已預約、⬜ 已過期、🟦 選取中。
  4. 桌面 mouse 與行動 touch 雙模拖拉選取: 支援按住滑鼠或在觸控螢幕上手指滑動(Drag-to-Select)選取連續時段,自動計算總時數並防止跨佔用區間。

一、 介面佈局與狀態視覺規範

介面採用 8 欄網格結構(第 1 欄為時間標籤,第 2~8 欄對應週一至週日):

+-----------------------------------------------------------------------------------------------+
| 1. [場地切換按鈕區 (5 間)]                                                                     |
|    [ 🏢 會議室 A1 ]  [ 🏢 會議室 A2 ]  [ 🏫 會議室 B1 ]  [ 🏫 會議室 B2 ]  [ 💬 洽談室 ]        |
+-----------------------------------------------------------------------------------------------+
| 2. [圖例說明] 🟩 可預約   🟥 已預約   ⬜ 已過期   🟦 滑動選取中                                   |
+-----------------------------------------------------------------------------------------------+
| 3. [橫向 7 天 × 縱向時段週日曆] (支援滑鼠/觸控拖拉)                                            |
|    時間   | 週一 (10/05) | 週二 (10/06) | 週三 (10/07) | ... | 週日 (10/11)                     |
|    08:00 |     🟩       |     🟥       |     🟩       |     |     🟩                           |
|    08:30 |     🟩       |     🟥       |     🟩       |     |     🟩                           |
|    09:00 |  [手指/滑鼠從 08:00 滑動至此 → 自動選取 🟦 1.5 小時]                                  |
+-----------------------------------------------------------------------------------------------+

色塊狀態定義

狀態圖示 色塊樣式 說明 互動行為
🟩 綠色 bg-emerald-50 border-emerald-200 可預約 (Available) 可點擊或按住拖拉選取
🟥 紅色 bg-red-50 border-red-200 已預約 (Booked) 鎖定不可選,拖拉穿越時自動截斷
⬜ 灰色 bg-gray-100 border-gray-200 已過期 (Past) 鎖定不可選
🟦 藍色 bg-blue-600 text-white 選取中 (Selecting) 高亮顯示當前拖拉涵蓋的連續時段

二、 前端完整 React 程式碼 (BookingCalendar.jsx)

程式碼同時處理了桌面端的 Mouse Event(onMouseDown, onMouseEnter, onMouseUp)與行動端 Touch Event(onTouchStart, onTouchMove, onTouchEnd),並透過 document.elementFromPoint 解析觸控位置。

import React, { useState, useEffect, useRef } from 'react';

// 五大場地配置
const ROOMS = [
  { id: 'A1', name: '會議室 A1', icon: '🏢', capacity: '20人' },
  { id: 'A2', name: '會議室 A2', icon: '🏢', capacity: '20人' },
  { id: 'B1', name: '會議室 B1', icon: '🏫', capacity: '20人' },
  { id: 'B2', name: '會議室 B2', icon: '🏫', capacity: '20人' },
  { id: 'TALK', name: '洽談室', icon: '💬', capacity: '6人' }
];

// 當週 7 天 (週一至週日)
const WEEK_DAYS = [
  { dayName: '週一', dateStr: '2026-10-05' },
  { dayName: '週二', dateStr: '2026-10-06' },
  { dayName: '週三', dateStr: '2026-10-07' },
  { dayName: '週四', dateStr: '2026-10-08' },
  { dayName: '週五', dateStr: '2026-10-09' },
  { dayName: '週六', dateStr: '2026-10-10' },
  { dayName: '週日', dateStr: '2026-10-11' }
];

// 08:00 - 22:00 縱向時間格網 (共 28 個 30 分鐘時段)
const TIME_SLOTS = generateTimeSlots("08:00", "22:00");

export default function BookingCalendar() {
  const [selectedRoom, setSelectedRoom] = useState(ROOMS[0].name);
  const [bookings, setBookings] = useState([]);
  
  // 拖拉狀態
  const [isDragging, setIsDragging] = useState(false);
  const [dragDayIdx, setDragDayIdx] = useState(null);       // 限定單一日期內拖拉
  const [dragStartSlotIdx, setDragStartSlotIdx] = useState(null);
  const [dragCurrentSlotIdx, setDragCurrentSlotIdx] = useState(null);

  const calendarRef = useRef(null);

  // 全局監聽放開事件,確保拖拉在容器外鬆開也能正常結束
  useEffect(() => {
    const handleEnd = () => setIsDragging(false);
    window.addEventListener('mouseup', handleEnd);
    window.addEventListener('touchend', handleEnd);
    return () => {
      window.removeEventListener('mouseup', handleEnd);
      window.removeEventListener('touchend', handleEnd);
    };
  }, []);

  // 判斷特定日期與時段的狀態
  const getSlotStatus = (dateStr, timeStr) => {
    const now = new Date();
    const slotTime = new Date(`${dateStr}T${timeStr}:00`);

    if (slotTime < now) {
      return { type: 'PAST', selectable: false, icon: '⬜' };
    }

    const isBooked = bookings.some(b => {
      const start = new Date(b.startTime);
      const end = new Date(b.endTime);
      return b.roomName === selectedRoom && slotTime >= start && slotTime < end;
    });

    if (isBooked) {
      return { type: 'BOOKED', selectable: false, icon: '🟥' };
    }

    return { type: 'AVAILABLE', selectable: true, icon: '🟩' };
  };

  // 計算當前選取的連續 Slot 範圍
  const getSelectedSlotIndices = (dayIdx) => {
    if (dragDayIdx !== dayIdx || dragStartSlotIdx === null || dragCurrentSlotIdx === null) return [];

    const min = Math.min(dragStartSlotIdx, dragCurrentSlotIdx);
    const max = Math.max(dragStartSlotIdx, dragCurrentSlotIdx);
    const dateStr = WEEK_DAYS[dayIdx].dateStr;

    const validIndices = [];
    for (let i = min; i <= max; i++) {
      const status = getSlotStatus(dateStr, TIME_SLOTS[i]);
      if (!status.selectable) break; // 遇阻即止:若遇到已預約或過期時段則在中斷處截斷
      validIndices.push(i);
    }
    return validIndices;
  };

  // --- 滑鼠 (Mouse) 事件 ---
  const handleMouseDown = (dayIdx, slotIdx) => {
    const dateStr = WEEK_DAYS[dayIdx].dateStr;
    const status = getSlotStatus(dateStr, TIME_SLOTS[slotIdx]);
    if (!status.selectable) return;

    setIsDragging(true);
    setDragDayIdx(dayIdx);
    setDragStartSlotIdx(slotIdx);
    setDragCurrentSlotIdx(slotIdx);
  };

  const handleMouseEnter = (dayIdx, slotIdx) => {
    if (!isDragging || dragDayIdx !== dayIdx) return;
    setDragCurrentSlotIdx(slotIdx);
  };

  // --- 觸控 (Touch) 事件,支援移動端滑動選取 ---
  const handleTouchStart = (dayIdx, slotIdx, e) => {
    handleMouseDown(dayIdx, slotIdx);
  };

  const handleTouchMove = (e) => {
    if (!isDragging) return;

    // 取得第一個觸控點的螢幕座標
    const touch = e.touches[0];
    // 利用 elementFromPoint 找出手指當前位置下的 DOM 元素
    const targetEl = document.elementFromPoint(touch.clientX, touch.clientY);
    
    if (targetEl) {
      // 尋找帶有 data-slot 屬性的格網容器
      const slotEl = targetEl.closest('[data-slot]');
      if (slotEl) {
        const dayIdx = parseInt(slotEl.getAttribute('data-day-idx'), 10);
        const slotIdx = parseInt(slotEl.getAttribute('data-slot-idx'), 10);

        // 僅允許同一天內進行垂直拖拉選取
        if (dayIdx === dragDayIdx) {
          setDragCurrentSlotIdx(slotIdx);
        }
      }
    }
  };

  // 計算已選取時段的摘要資訊
  const currentSelectedIndices = dragDayIdx !== null ? getSelectedSlotIndices(dragDayIdx) : [];

  return (
    <div className="p-4 sm:p-6 max-w-7xl mx-auto space-y-6 select-none">
      
      {/* 區塊 1:五大場地切換大型按鈕 */}
      <div>
        <h2 className="text-lg font-bold text-gray-700 mb-3">1. 請選擇場地:</h2>
        <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
          {ROOMS.map(room => {
            const isSelected = selectedRoom === room.name;
            return (
              <button
                key={room.id}
                onClick={() => {
                  setSelectedRoom(room.name);
                  setDragDayIdx(null);
                }}
                className={`p-4 rounded-xl border-2 text-left transition-all flex flex-col justify-between ${
                  isSelected
                    ? 'border-blue-600 bg-blue-50 shadow-md ring-2 ring-blue-200'
                    : 'border-gray-200 bg-white hover:border-gray-300'
                }`}
              >
                <div className="flex justify-between items-center w-full">
                  <span className="text-2xl">{room.icon}</span>
                  {isSelected && <span className="text-blue-600 font-bold">✓</span>}
                </div>
                <div className="mt-2">
                  <div className={`text-base font-bold ${isSelected ? 'text-blue-700' : 'text-gray-800'}`}>
                    {room.name}
                  </div>
                  <div className="text-xs text-gray-500 mt-0.5">{room.capacity}</div>
                </div>
              </button>
            );
          })}
        </div>
      </div>

      {/* 區塊 2:狀態圖例說明 */}
      <div className="flex flex-wrap items-center justify-between bg-white p-4 rounded-xl border border-gray-100 shadow-sm text-sm gap-4">
        <div className="flex items-center gap-4">
          <span className="font-bold text-gray-700">圖例:</span>
          <div className="flex items-center gap-1"><span className="text-base">🟩</span> 可預約</div>
          <div className="flex items-center gap-1"><span className="text-base">🟥</span> 已預約</div>
          <div className="flex items-center gap-1"><span className="text-base">⬜</span> 已過期</div>
          <div className="flex items-center gap-1"><span className="w-3.5 h-3.5 rounded bg-blue-600 inline-block"></span> 選取中</div>
        </div>

        {currentSelectedIndices.length > 0 && (
          <div className="text-sm font-bold text-blue-600 bg-blue-50 px-3 py-1 rounded-full border border-blue-200">
            已選 {WEEK_DAYS[dragDayIdx].dayName} ({WEEK_DAYS[dragDayIdx].dateStr}):
            {TIME_SLOTS[currentSelectedIndices[0]]} ~ {getEndTimeStr(TIME_SLOTS[currentSelectedIndices[currentSelectedIndices.length - 1]])} 
            (共 {currentSelectedIndices.length * 0.5} 小時)
          </div>
        )}
      </div>

      {/* 區塊 3:橫向 7 天 × 縱向時段週檢視(網格佈局) */}
      <div className="bg-white p-4 sm:p-6 rounded-2xl shadow-sm border border-gray-100 overflow-x-auto">
        <div 
          ref={calendarRef}
          onTouchMove={handleTouchMove}
          className="min-w-[700px] grid grid-cols-8 gap-1.5 touch-none"
        >
          {/* 表頭:時間 + 7 天 (週一 ~ 週日) */}
          <div className="p-2 text-center text-xs font-bold text-gray-500 bg-gray-50 rounded-lg">
            時間
          </div>
          {WEEK_DAYS.map((day) => (
            <div key={day.dateStr} className="p-2 text-center bg-blue-50/50 rounded-lg border border-blue-100">
              <div className="text-xs font-bold text-blue-900">{day.dayName}</div>
              <div className="text-[10px] text-gray-500">{day.dateStr.slice(5)}</div>
            </div>
          ))}

          {/* 表體:縱向時段列 */}
          {TIME_SLOTS.map((timeStr, slotIdx) => (
            <React.Fragment key={timeStr}>
              {/* 最左側時間軸 */}
              <div className="py-2 text-center text-xs font-semibold text-gray-400 border-b border-gray-50 flex items-center justify-center">
                {timeStr}
              </div>

              {/* 7 天對應的格網 */}
              {WEEK_DAYS.map((day, dayIdx) => {
                const status = getSlotStatus(day.dateStr, timeStr);
                const selectedIndices = getSelectedSlotIndices(dayIdx);
                const isSelected = selectedIndices.includes(slotIdx);

                // 動態 CSS 樣式
                let cellStyle = "bg-emerald-50 border-emerald-200 text-emerald-800 hover:bg-emerald-100 cursor-pointer";
                if (status.type === 'PAST') cellStyle = "bg-gray-100 border-gray-200 text-gray-300 cursor-not-allowed";
                if (status.type === 'BOOKED') cellStyle = "bg-red-50 border-red-200 text-red-700 cursor-not-allowed";
                if (isSelected) cellStyle = "bg-blue-600 border-blue-700 text-white shadow-md scale-[1.02]";

                return (
                  <div
                    key={`${day.dateStr}-${timeStr}`}
                    data-slot="true"
                    data-day-idx={dayIdx}
                    data-slot-idx={slotIdx}
                    onMouseDown={() => handleMouseDown(dayIdx, slotIdx)}
                    onMouseEnter={() => handleMouseEnter(dayIdx, slotIdx)}
                    onTouchStart={(e) => handleTouchStart(dayIdx, slotIdx, e)}
                    className={`py-2 px-1 text-center rounded-lg border text-xs transition-all flex items-center justify-between font-medium ${cellStyle}`}
                  >
                    <span className="text-xs">{isSelected ? '🟦' : status.icon}</span>
                    <span className="text-[10px] scale-90">{timeStr}</span>
                  </div>
                );
              })}
            </React.Fragment>
          ))}
        </div>

        {/* 底部提交按鈕 */}
        {currentSelectedIndices.length > 0 && (
          <div className="mt-6 flex justify-end">
            <button
              onClick={() => alert(`已確認預約 ${selectedRoom} [${WEEK_DAYS[dragDayIdx].dateStr}]:${TIME_SLOTS[currentSelectedIndices[0]]} 開始,共 ${currentSelectedIndices.length * 0.5} 小時`)}
              className="w-full sm:w-auto bg-blue-600 text-white px-8 py-3 rounded-xl font-bold shadow-lg hover:bg-blue-700 transition"
            >
              確認預約時段 →
            </button>
          </div>
        )}
      </div>

    </div>
  );
}

// 輔助函式:產生 08:00 至 22:00 時間陣列
function generateTimeSlots(startStr, endStr) {
  const slots = [];
  let h = 8, m = 0;
  while (h < 22 || (h === 22 && m === 0)) {
    slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
    m += 30;
    if (m >= 60) { m = 0; h += 1; }
  }
  return slots;
}

// 計算結束時間 (+30分鐘)
function getEndTimeStr(startStr) {
  const [h, m] = startStr.split(':').map(Number);
  const endMin = m + 30;
  const endHour = endMin >= 60 ? h + 1 : h;
  const finalMin = endMin >= 60 ? 0 : endMin;
  return `${String(endHour).padStart(2, '0')}:${String(finalMin).padStart(2, '0')}`;
}


三、 行動端觸控(Touch Event)核心技術解析

在行動裝置(iOS Safari / Android Chrome)的網頁中,原生的 onTouchMove 事件預設會觸發頁面滾動,且手指移動時**不會像滑鼠一樣觸發下方子元素的 onMouseEnter**。為了實現順暢的觸控滑動選取,我們採用了以下技術:

  1. touch-action: none 阻斷原頁面滾動: 在網格容器加上 touch-none 類別,防止手指滑動時觸發手機頁面的上下拉動與刷新。
  2. document.elementFromPoint(x, y) 座標定位: 在 onTouchMove 處理函式中擷取 e.touches[0].clientX 與 clientY,即時計算手指當前的螢幕座標,並反查當前座標下的 DOM 元素。
  3. data-* 屬性標記: 每個時間格網卡片皆加上 data-slot="true"、data-day-idx 與 data-slot-idx。透過 targetEl.closest('[data-slot]') 可精確判斷手指正滑過哪一天的哪一個時間格。

上一篇
【Day 12】後台管理與預預約維護:實作「全區查詢、自主改期/取消」與管理員「週期性活動」排程
下一篇
【Day 14】資料留痕與審計日誌:實作 Google Sheets 審計日誌 (Audit Log) 與場地使用率統計分析
系列文
零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言