今天我們將重點放在 「前端預約介面的全平台 UX 升級」。為了讓職工無論是在桌面電腦使用滑鼠,還是在 iPhone / iPad / Android 等行動裝置上透過觸控螢幕操作,都能享受流暢且防呆的預約體驗,我們實作了以下四大核心功能:
介面採用 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) | 高亮顯示當前拖拉涵蓋的連續時段 |
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')}`;
}
在行動裝置(iOS Safari / Android Chrome)的網頁中,原生的 onTouchMove 事件預設會觸發頁面滾動,且手指移動時**不會像滑鼠一樣觸發下方子元素的 onMouseEnter**。為了實現順暢的觸控滑動選取,我們採用了以下技術:
touch-action: none 阻斷原頁面滾動: 在網格容器加上 touch-none 類別,防止手指滑動時觸發手機頁面的上下拉動與刷新。document.elementFromPoint(x, y) 座標定位: 在 onTouchMove 處理函式中擷取 e.touches[0].clientX 與 clientY,即時計算手指當前的螢幕座標,並反查當前座標下的 DOM 元素。data-* 屬性標記: 每個時間格網卡片皆加上 data-slot="true"、data-day-idx 與 data-slot-idx。透過 targetEl.closest('[data-slot]') 可精確判斷手指正滑過哪一天的哪一個時間格。