0. 作品介紹

作者 張祐誠 光華國中

本系統為「校內 iPad 借用預約系統」,使用 Google Apps Script + Google 試算表 + HTML/JS 建立,提供: 1. 一般使用者:查詢、預約借用 2. 管理員:設備數量管理、全校檢視、刪除預約


87 3 0
辦公
示範影片

1. AI 提示詞

把這段指令貼給 AI,讓它幫你生成程式碼。

你是一位熟悉 Google Apps Script、HTML、JavaScript 的資深校務系統工程師。
請幫我設計一套「學校 iPad 借用預約系統」,需完全符合以下規格:
【一、技術架構】
使用 Google Apps Script(Code.gs)
使用 SpreadsheetApp 作為資料庫
使用 HtmlService 建立單頁式 Web App
前後端以 google.script.run 溝通
【二、試算表結構(名稱不可更改)】
使用者清單:A=帳號、B=姓名
管理員清單:A=帳號、B=姓名
借用紀錄:
A: 日期 (yyyy-MM-dd)
B: 節次
C: 帳號
D: 姓名
E: 班級
F: 借用數量
G: 建立時間
系統設定:B1 = iPad 總數
【三、固定節次(唯一真實來源)】 ["早修","第一節","第二節","第三節","第四節","午休","第五節","第六節","第七節","第八節","第九節"]
【四、使用者功能】
帳號登入驗證
查看本週 / 下週借用狀況
預約借用(需符合以下規則):
僅能預約 T+2 ~ 下週週六
同一班級同一日期同一節次不可重複
借用數量必須為正整數
不可超過系統剩餘台數
使用 Script Lock 防止多人同時超借
使用者不可自行刪除
【五、管理員功能】
管理員登入
修改系統總台數
週檢視 / 單日檢視所有借用
可選擇是否顯示姓名
顯示剩餘可借數量
刪除任一筆借用紀錄
【六、其他要求】
後端需統一處理所有檢查邏輯
日期處理需避免時區錯誤(使用 yyyy-MM-dd parse)
程式需結構清楚、可維護
前端畫面需適合老師操作(清楚、直覺)
請提供:
Code.gs 完整程式碼
index.html(含 JS)
必要註解說明

2. 資料表規則

請照下面規則建立分頁與欄位。

第1分頁: 使用者清單 A 欄: 帳號 B 欄: 姓名 第2分頁: 管理員清單 A 欄: 帳號 B 欄: 姓名 第3分頁: 借用紀錄 A 欄: 日期 B 欄: 節次 C 欄: 帳號 D 欄: 姓名 E 欄: 班級 F 欄: 借用數量 G 欄: 建立時間 第4分頁: 系統設定 A 欄: iPad 總數 B 欄: 欄位B

📄 解析結果(自動表格)

分頁:使用者清單
A 欄 B 欄
帳號 姓名
分頁:管理員清單
A 欄 B 欄
帳號 姓名
分頁:借用紀錄
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄 G 欄
日期 節次 帳號 姓名 班級 借用數量 建立時間
分頁:系統設定
A 欄 B 欄
iPad 總數 欄位B

3. 開啟 Apps Script

從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。

  1. 試算表上方選單:擴充功能 → Apps Script
  2. 刪掉預設的程式碼(或全部選取覆蓋)
  3. 保留/建立檔案:
    • Code.gs
    • index.html

4. 貼上後端(Code.gs)

把後端程式貼到 Apps Script 的 Code.gs。

Code.gs(後端)
/* Code.gs */

var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheetUsers = ss.getSheetByName("使用者清單");
var sheetAdmins = ss.getSheetByName("管理員清單");
var sheetBookings = ss.getSheetByName("借用紀錄");
var sheetSettings = ss.getSheetByName("系統設定");

// 唯一的節次定義 (Source of Truth)
var PERIODS = ["早修", "第一節", "第二節", "第三節", "第四節", "午休", "第五節", "第六節", "第七節", "第八節", "第九節"];

function doGet() {
  return HtmlService.createTemplateFromFile('index')
      .evaluate()
      .setTitle('iPad 借用系統')
      .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
      .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

// 提供前端獲取節次清單的接口
function getPeriods() {
  return PERIODS;
}

// 登入驗證
function loginUser(account, mode) {
  var targetSheet = (mode === 'admin') ? sheetAdmins : sheetUsers;
  if (!targetSheet) {
    return { status: "error", message: "系統錯誤:找不到 '" + (mode==='admin'?"管理員清單":"使用者清單") + "' 分頁" };
  }

  var data = targetSheet.getDataRange().getValues();
  for (var i = 1; i < data.length; i++) {
    if (String(data[i][0]).trim() == String(account).trim()) {
      return { 
        status: "success", 
        name: data[i][1], 
        account: account, 
        role: mode 
      };
    }
  }
  return { status: "error", message: "帳號不存在或權限不足" };
}

function getTabletTotal() {
  var val = sheetSettings.getRange("B1").getValue();
  return val ? parseInt(val) : 30; 
}

function setTabletTotal(newQty) {
  sheetSettings.getRange("B1").setValue(newQty);
  return { status: "success", newVal: newQty };
}

function getBookingData(startDateStr, endDateStr) {
  var data = sheetBookings.getDataRange().getValues();
  var bookings = [];
  
  // 日期比較統一使用自定義解析,避免時區誤差
  var start = parseDate(startDateStr);
  var end = parseDate(endDateStr);
  
  start.setHours(0,0,0,0);
  end.setHours(23,59,59,999);

  for (var i = 1; i < data.length; i++) {
    var rowDate = new Date(data[i][0]);
    if (rowDate >= start && rowDate <= end) {
      bookings.push({
        date: Utilities.formatDate(rowDate, Session.getScriptTimeZone(), "yyyy-MM-dd"),
        period: data[i][1],
        name: data[i][3],
        className: data[i][4],
        qty: data[i][5],
        account: data[i][2],
        rowIndex: i + 1
      });
    }
  }
  return bookings;
}

// 核心功能:儲存預約 (包含所有邏輯檢查與鎖定)
function saveBooking(account, name, dateStr, period, className, qty) {
  var lock = LockService.getScriptLock();
  
  try {
    // 等待鎖定,最多 10 秒
    lock.waitLock(10000); 
  } catch (e) {
    return { status: "error", message: "系統忙碌中 (多人同時操作),請稍後再試" };
  }

  try {
    // 1. 驗證數量:必須是正整數
    var reqQty = Number(qty);
    if (!Number.isInteger(reqQty) || reqQty <= 0) {
      return { status: "error", message: "借用數量必須為大於 0 的整數!" };
    }

    // 2. 驗證日期範圍 (T+2 ~ T+14)
    var bookingDate = parseDate(dateStr); 
    var today = new Date();
    today.setHours(0,0,0,0);
    
    // 限制:只能借後天 (Today + 2)
    var minDate = new Date(today);
    minDate.setDate(today.getDate() + 2);
    minDate.setHours(0,0,0,0);
    
    if (bookingDate < minDate) {
       return { status: "error", message: "借用需提前申請,無法借用當日或隔日。" };
    }

    // 限制:不超過下週 (約14天)
    var limitBase = new Date(today);
    var day = limitBase.getDay();
    var diff = limitBase.getDate() - day; // 回到本週日
    limitBase.setDate(diff); 
    var maxDate = new Date(limitBase);
    maxDate.setDate(maxDate.getDate() + 13); // 下週六
    maxDate.setHours(23, 59, 59, 999);

    if (bookingDate > maxDate) {
      return { status: "error", message: "預約日期超過限制範圍!系統僅開放至下週。" };
    }

    // 3. 讀取資料並檢查:(A) 重複預約 (B) 剩餘數量
    // 在 Lock 內讀取最新資料並檢查,防止 Race Condition
    var totalLimit = getTabletTotal();
    var data = sheetBookings.getDataRange().getValues();
    var currentUsed = 0;
    
    // 統一使用格式化字串進行比對
    var targetDateStr = dateStr; // 傳入已是 yyyy-MM-dd
    
    for (var i = 1; i < data.length; i++) {
      var rowRawDate = data[i][0];
      var rowDateStr = Utilities.formatDate(new Date(rowRawDate), Session.getScriptTimeZone(), "yyyy-MM-dd");
      var rowPeriod = data[i][1];
      var rowClass = data[i][4]; // 班級在 E 欄
      var rowQty = parseInt(data[i][5]);

      if (rowDateStr == targetDateStr && rowPeriod == period) {
        // 同日期同節次 -> 累加已用數量
        currentUsed += rowQty;
        
        // 檢查重複班級 (嚴格禁止同一班級在同一時段重複登記)
        if (String(rowClass).trim() === String(className).trim()) {
           return { status: "error", message: "⛔ 預約失敗!\n該班級 (" + className + ") 在 " + dateStr + " 的「" + period + "」已經有預約紀錄了。" };
        }
      }
    }

    var remaining = totalLimit - currentUsed;
    if (reqQty > remaining) {
      return { status: "error", message: "數量不足!剩餘 " + remaining + " 台,欲借 " + reqQty + " 台。" };
    }

    // 4. 寫入資料
    sheetBookings.appendRow([dateStr, period, account, name, className, reqQty, new Date()]);
    return { status: "success" };

  } catch (e) {
    return { status: "error", message: "系統錯誤: " + e.toString() };
  } finally {
    lock.releaseLock();
  }
}

function getAdminBookings() {
  var data = sheetBookings.getDataRange().getValues();
  var list = [];
  var today = new Date();
  today.setHours(0,0,0,0);

  for (var i = 1; i < data.length; i++) {
    var rowDate = new Date(data[i][0]);
    // 檢查是否為未來/今日的預約
    if (rowDate >= today) {
      list.push({
        rowIndex: i + 1,
        date: Utilities.formatDate(rowDate, Session.getScriptTimeZone(), "yyyy-MM-dd"),
        period: data[i][1],
        account: data[i][2],
        name: data[i][3],
        className: data[i][4],
        qty: data[i][5]
      });
    }
  }
  
  // 排序
  list.sort(function(a, b) {
    var dateComp = a.date.localeCompare(b.date);
    if (dateComp !== 0) return dateComp;
    return PERIODS.indexOf(a.period) - PERIODS.indexOf(b.period);
  });
  
  return list;
}

function deleteBooking(rowIndex, dateStr, period, account) {
  var range = sheetBookings.getRange(rowIndex, 1, 1, 3);
  var values = range.getValues()[0];
  var checkDate = Utilities.formatDate(new Date(values[0]), Session.getScriptTimeZone(), "yyyy-MM-dd");
  
  if (checkDate == dateStr && values[1] == period && values[2] == account) {
    sheetBookings.deleteRow(rowIndex);
    return { status: "success" };
  } else {
    return { status: "error", message: "資料不符,可能已被其他人修改" };
  }
}

// 輔助函式:安全解析 yyyy-MM-dd 字串為 Date 物件
function parseDate(dateStr) {
  var parts = dateStr.split('-');
  return new Date(parts[0], parts[1] - 1, parts[2]);
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

把前端程式貼到 Apps Script 的 index.html。

index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
  <style>
    /* Theme: Clean & Minimal (Business Standard)
       Colors: White, Light Gray, Slate Blue, Emerald Green
    */
    :root {
      --bg-color: #f4f6f8;
      --card-bg: #ffffff;
      --text-main: #333333;
      --text-sub: #666666;
      --border-color: #e0e0e0;
      
      --primary: #4a90e2;   /* 標準藍 */
      --success: #28a745;   /* 標準綠 */
      --warning: #ffc107;   /* 標準黃 */
      --danger: #dc3545;    /* 標準紅 */
      --secondary: #6c757d; /* 標準灰 */
    }

    body { 
      font-family: "Noto Sans TC", sans-serif; 
      background-color: var(--bg-color);
      color: var(--text-main);
      margin: 0; 
      padding: 20px; 
      min-height: 100vh;
    }

    /* 容器樣式:白底卡片 */
    .container { 
      max-width: 1200px; 
      margin: 0 auto;
      background: var(--card-bg); 
      padding: 40px; 
      border-radius: 8px; 
      box-shadow: 0 2px 8px rgba(0,0,0,0.05); 
      border: 1px solid var(--border-color);
    }
    
    h2 { text-align: center; color: var(--text-main); font-weight: 700; margin-bottom: 20px; }
    h3 { text-align: center; color: var(--text-sub); font-weight: 500; font-size: 1.2em; margin-bottom: 20px; }
    h4 { color: var(--text-main); font-weight: 700; margin: 10px 0; }
    
    /* 按鈕樣式:扁平化 */
    .btn { 
      padding: 8px 16px; margin: 4px; cursor: pointer; 
      border: 1px solid transparent; border-radius: 4px; 
      font-size: 14px; font-weight: 500;
      transition: background-color 0.2s;
    }
    .btn:hover { opacity: 0.9; }
    .btn:active { transform: translateY(1px); }

    .btn-primary { background-color: var(--primary); color: white; }
    .btn-success { background-color: var(--success); color: white; }
    .btn-warning { background-color: var(--warning); color: #333; }
    .btn-danger { background-color: var(--danger); color: white; }
    .btn-secondary { background-color: var(--secondary); color: white; }
    
    /* 切換按鈕 */
    .btn-toggle { background-color: #fff; border: 1px solid var(--border-color); color: var(--text-sub); }
    .btn-toggle.active { background-color: var(--primary); color: white; border-color: var(--primary); }

    /* 首頁登入區塊 */
    #home-screen { padding: 10px 0; }
    .login-container { display: flex; justify-content: center; gap: 40px; flex-wrap: wrap; margin-bottom: 40px; margin-top: 30px;}
    .login-box { 
      padding: 30px; border: 1px solid var(--border-color); border-radius: 8px; background: #fff;
      width: 280px; text-align: left; box-shadow: 0 2px 4px rgba(0,0,0,0.02);
    }

    input, select { 
      padding: 10px; margin: 10px 0; border: 1px solid var(--border-color); border-radius: 4px; 
      width: 100%; box-sizing: border-box; font-family: inherit; font-size: 14px;
    }
    input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); }

    /* Loading 遮罩 */
    #loading { 
      position: fixed; top: 0; left: 0; width: 100%; height: 100%; 
      background: rgba(255,255,255, 0.9); z-index: 999; display: none; 
      text-align: center; padding-top: 20%; font-size: 18px; color: var(--text-sub);
    }
    #loading::after {
      content: " ⏳";
    }

    /* 行事曆佈局 */
    .calendar-container { display: flex; gap: 20px; justify-content: center; flex-wrap: wrap; margin-top: 20px; }
    .calendar-box { 
      flex: 1; min-width: 480px; padding: 0;
      background: transparent;
    }
    .calendar-header-row { 
      display: flex; justify-content: space-between; align-items: center; 
      margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);
    }
    .calendar-title { font-size: 16px; font-weight: bold; color: var(--text-main); margin: 0; }
    .filter-checkbox { margin-left: 15px; font-size: 14px; cursor: pointer; user-select: none; color: var(--text-sub); }

    /* 表格樣式 */
    table { width: 100%; border-collapse: collapse; margin-top: 5px; font-size: 13px; table-layout: fixed; }
    th, td { border: 1px solid var(--border-color); padding: 8px; text-align: center; vertical-align: middle;}
    th { background-color: #f8f9fa; color: var(--text-main); font-weight: bold; height: 35px; }
    td { background: #fff; }
    
    .period-col { background-color: #f8f9fa; font-weight: bold; width: 60px; color: var(--text-sub); }
    .special-bg { background-color: #fffcf0 !important; } /* 早修/午休稍微區隔 */
    
    .booked { background-color: #e3f2fd; padding: 4px; } /* 預約佔用底色 (淺藍) */
    .booked-info { font-weight: bold; color: var(--primary); display: inline-block; font-size: 13px;}
    .booked-detail { color: #666; font-size: 12px; }
    .booked-name { display: block; font-size: 11px; color: #555; margin-bottom: 2px; }
    
    .qty-badge { margin-top: 4px; font-size: 11px; display: block; color: var(--text-sub); }
    .qty-ok { color: var(--success); font-weight: bold;} 
    .qty-zero { color: var(--danger); font-weight: bold;}

    /* 預約頁面佈局 */
    .reserve-layout { display: flex; gap: 30px; align-items: flex-start; }
    .sidebar { 
      width: 260px; padding: 20px; background: #f8f9fa; border-radius: 4px;
      flex-shrink: 0; position: sticky; top: 20px; border: 1px solid var(--border-color);
    }

    /* 管理後台樣式 */
    .admin-section { 
      padding: 20px; margin-bottom: 20px; 
      border: 1px solid var(--border-color); border-radius: 4px; background-color: #fff; 
    }
    .qty-display-box { 
      background: #e9ecef; color: var(--text-main); padding: 10px; border-radius: 4px;
      margin-bottom: 10px; text-align: center; font-size: 14px;
    }
    .qty-number { font-size: 20px; font-weight: bold; color: var(--primary); margin: 0 5px; }
    .view-controls { background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid var(--border-color); }

    .header-bar {
      display: flex; justify-content: space-between; align-items: center;
      margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px;
    }

    ::-webkit-scrollbar { width: 8px; height: 8px; }
    ::-webkit-scrollbar-track { background: #f1f1f1; }
    ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
    
    .hidden { display: none; }
    
    /* 連結樣式 */
    .manual-link {
      display: inline-block; margin-bottom: 20px; 
      color: var(--primary); text-decoration: none; 
      font-weight: 500; 
    }
    .manual-link:hover { text-decoration: underline; color: #2a6fb8; }
  </style>
</head>
<body>

  <div id="loading">系統載入中...</div>

  <div class="container">
    
    <div id="home-screen">
      <h2>新竹市〇〇國中 iPad 借用預約系統</h2>
      
      <div style="text-align: center;">
        <a href="https://docs.google.com/document/d/1j-Ei6kVYxJunCwcjc6138LHHSoRAl1KVCKwPmA6jiZw/edit?usp=drive_link" target="_blank" class="manual-link">
          📖 點此查看系統使用說明
        </a>
      </div>

      <div class="login-container">
        <div class="login-box">
          <h4 style="margin-top:0;">✏️ 老師登記借用【使用 test 登入】</h4>
          <input type="text" id="user-account" placeholder="請輸入帳號">
          <p style="font-size:12px; color:#999;">(請輸入使用者清單內的帳號)</p>
          <button class="btn btn-success" style="width:100%" onclick="attemptLogin('user')">登入</button>
        </div>

        <div class="login-box" style="border-top: 3px solid var(--warning);">
          <h4 style="margin-top:0;">⚙️ 管理員登入【使用 test 登入】</h4>
          <input type="text" id="admin-account" placeholder="請輸入管理帳號">
          <p style="font-size:12px; color:#fff; user-select:none;">(Spacer)</p>
          <button class="btn btn-warning" style="width:100%" onclick="attemptLogin('admin')">管理登入</button>
        </div>
      </div>

      <div style="margin-top: 40px;">
        <h3>目前借用狀況一覽</h3>
        <div class="calendar-container">
          <div class="calendar-box">
            <div class="calendar-header-row">
              <span id="home-week0-title" class="calendar-title">本週</span>
            </div>
            <div id="home-week0-table">讀取中...</div>
          </div>
          <div class="calendar-box">
            <div class="calendar-header-row">
              <span id="home-week1-title" class="calendar-title">下週</span>
            </div>
            <div id="home-week1-table">讀取中...</div>
          </div>
        </div>
      </div>
    </div>

    <div id="reserve-screen" class="hidden">
      <div class="header-bar">
        <h3 style="margin:0; color:var(--text-main);">📝 登記借用</h3>
        <div>
          <button class="btn btn-secondary" onclick="goHome()">回首頁</button>
          <button class="btn btn-danger" onclick="logout()">登出</button>
        </div>
      </div>

      <div class="reserve-layout">
        <div class="sidebar">
          <h4 style="margin-top:0;">Hi, <span id="display-name"></span></h4>
          
          <div style="margin-bottom: 15px; font-size: 13px;">
             <a href="https://docs.google.com/document/d/1j-Ei6kVYxJunCwcjc6138LHHSoRAl1KVCKwPmA6jiZw/edit?usp=drive_link" target="_blank" style="color: var(--primary); text-decoration: none;">
               📖 查看使用說明
             </a>
          </div>

          <p style="color:var(--danger); font-weight:bold; font-size:13px; margin:5px 0; line-height:1.5;">
            注意:登記後無法自行刪除,如需修改請聯繫管理員。
          </p>

          <hr style="border:0; border-top:1px solid #ddd; margin: 15px 0;">

          <label>1. 借用日期 <span style="font-size:11px; color:#888;">(T+2)</span></label>
          <input type="date" id="input-date">
          <div id="date-hint" style="font-size:11px; color:#666; margin-bottom:5px;"></div>

          <label>2. 節次</label>
          <select id="input-period"></select>

          <label>3. 班級</label>
          <input type="text" id="input-class" placeholder="例: 801">

          <label>4. 數量 (目前總數: <span id="max-qty-display">...</span>)</label>
          <input type="number" id="input-qty" placeholder="數量" min="1" step="1">

          <br><br>
          <button class="btn btn-primary" style="width:100%;" onclick="submitBooking()">送出預約</button>
        </div>

        <div style="flex-grow: 1;">
          <div class="calendar-box">
            <div class="calendar-header-row">
              <span id="reserve-calendar-title" class="calendar-title">本週借用狀況</span>
              <div style="display:flex; align-items:center;">
                <button id="btn-reserve-week0" class="btn btn-toggle active" onclick="switchWeek('reserve', 0)">本週</button>
                <button id="btn-reserve-week1" class="btn btn-toggle" onclick="switchWeek('reserve', 1)">下週</button>
                <label class="filter-checkbox">
                  <input type="checkbox" id="filter-show-all" onchange="toggleShowAll()"> 顯示全部
                </label>
              </div>
            </div>
            <div id="reserve-calendar-table"></div>
          </div>
        </div>
      </div>
    </div>

    <div id="admin-screen" class="hidden">
      <div class="header-bar">
        <h3 style="margin:0;">🛠️ 管理後台</h3>
        <div>
           <button class="btn btn-secondary" onclick="goHome()">回首頁</button>
           <button class="btn btn-danger" onclick="logout()">登出</button>
        </div>
      </div>
      
      <div class="admin-section">
        <h4 style="margin-top:0;">設備數量設定</h4>
        <div style="display:flex; align-items:center; gap:15px;">
          <div class="qty-display-box" style="margin:0;">
            目前總數:<span id="display-current-total" class="qty-number">...</span> 台
          </div>
          <span style="color:#666;">➔ 修改為:</span>
          <input type="number" id="admin-total-qty" style="width:80px; margin:0;" placeholder="數量">
          <button class="btn btn-primary" onclick="updateTotalQty()">更新</button>
        </div>
      </div>

      <div class="admin-section">
        <h4 style="margin-top:0;">借用狀況檢視</h4>
        
        <div class="view-controls">
          <label>檢視模式:</label>
          <input type="radio" name="admin-view-mode" id="mode-weekly" value="weekly" checked onchange="toggleAdminViewMode()"> 週檢視
          <input type="radio" name="admin-view-mode" id="mode-daily" value="daily" onchange="toggleAdminViewMode()" style="margin-left:15px;"> 單日檢視
          
          <label class="filter-checkbox" style="margin-left:auto;">
              <input type="checkbox" id="admin-show-name" onchange="refreshAdminView()"> 顯示借用人姓名
          </label>
        </div>

        <div id="admin-weekly-area" style="margin-top:10px;">
          <div class="calendar-header-row">
            <span id="admin-calendar-title" class="calendar-title">週曆</span>
            <div style="display:flex; align-items:center;">
              <button id="btn-admin-week0" class="btn btn-toggle active" onclick="switchWeek('admin', 0)">本週</button>
              <button id="btn-admin-week1" class="btn btn-toggle" onclick="switchWeek('admin', 1)">下週</button>
            </div>
          </div>
          <div id="admin-calendar-table"></div>
        </div>

        <div id="admin-daily-area" class="hidden" style="text-align:center; margin-top:20px;">
          <div style="margin-bottom:15px;">
            <label style="font-weight:bold;">選擇日期:</label>
            <input type="date" id="admin-daily-date" style="width:auto; display:inline-block;" onchange="loadAdminDaily()">
          </div>
          <div style="border:1px solid #ddd; border-radius:4px; padding:15px;">
             <h4 id="admin-daily-title" style="margin-top:0;">單日狀況</h4>
             <div id="admin-daily-table">請選擇日期...</div>
          </div>
        </div>

      </div>

      <div class="admin-section">
        <div style="display:flex; justify-content:space-between; align-items:center;">
          <h4 style="margin:0; color:var(--danger);">詳細紀錄列表 (刪除操作)</h4>
          <button class="btn btn-secondary" style="font-size:12px;" onclick="loadAdminList()">重新整理</button>
        </div>
        <hr style="border:0; border-top:1px solid #eee; margin:10px 0;">
        <div id="admin-booking-list">載入中...</div>
      </div>
    </div>

  </div>

  <script>
    // ================== JavaScript 邏輯 (完全保留) ==================
    var currentUser = {};
    var periods = [];
    var specialPeriods = ["早修", "午休", "第八節", "第九節"]; 
    var systemMaxQty = 30; 
    
    var viewState = { 'reserve': 0, 'admin': 0 };
    var isShowAll = false;       
    var adminShowName = false;   
    var adminViewMode = 'weekly'; 
    var latestRequestedDate = ""; 

    window.onload = function() {
      showLoading(true);
      google.script.run.withSuccessHandler(function(serverPeriods) {
        periods = serverPeriods;
        var select = document.getElementById("input-period");
        periods.forEach(p => select.add(new Option(p, p)));
        document.getElementById('admin-daily-date').valueAsDate = new Date();
        loadHomeCalendars();
      }).getPeriods();
    };

    function showLoading(show) { document.getElementById('loading').style.display = show ? 'block' : 'none'; }
    function loadHomeCalendars() { google.script.run.withSuccessHandler(function(qty) { showLoading(false); systemMaxQty = parseInt(qty); var thisSun = getSunday(new Date()); var nextSun = new Date(thisSun); nextSun.setDate(nextSun.getDate() + 7); renderCalendarTable(thisSun, 'home-week0-table', 'home-week0-title', '本週', true, false); renderCalendarTable(nextSun, 'home-week1-table', 'home-week1-title', '下週', true, false); }).getTabletTotal(); }
    function goHome() { document.querySelectorAll('.container > div').forEach(div => { if(div.id !== 'loading') div.classList.add('hidden'); }); document.getElementById('home-screen').classList.remove('hidden'); document.getElementById('user-account').value = ""; document.getElementById('admin-account').value = ""; showLoading(true); loadHomeCalendars(); }
    function logout() { currentUser = {}; alert("已登出。"); goHome(); }
    function attemptLogin(mode) { var accId = (mode === 'admin') ? 'admin-account' : 'user-account'; var account = document.getElementById(accId).value.trim(); if (!account) { alert("請輸入帳號"); return; } showLoading(true); google.script.run.withSuccessHandler(function(res) { showLoading(false); if (res.status === "success") { currentUser = res; mode === 'admin' ? initAdminScreen() : initReserveScreen(); } else { alert(res.message); } }).loginUser(account, mode); }
    function switchWeek(page, weekOffset) { viewState[page] = weekOffset; document.getElementById('btn-' + page + '-week0').classList.remove('active'); document.getElementById('btn-' + page + '-week1').classList.remove('active'); document.getElementById('btn-' + page + '-week' + weekOffset).classList.add('active'); var targetSunday = getSunday(new Date()); if (weekOffset === 1) targetSunday.setDate(targetSunday.getDate() + 7); var tableId = page + '-calendar-table'; var titleId = page + '-calendar-title'; var titlePrefix = (weekOffset === 0) ? "本週" : "下週"; var forceShowAll = (page === 'reserve') ? isShowAll : true; var showName = (page === 'admin') ? adminShowName : false; showLoading(true); renderCalendarTable(targetSunday, tableId, titleId, titlePrefix, forceShowAll, showName); }
    function toggleShowAll() { isShowAll = document.getElementById('filter-show-all').checked; switchWeek('reserve', viewState['reserve']); }
    function toggleAdminViewMode() { if (document.getElementById('mode-weekly').checked) { adminViewMode = 'weekly'; document.getElementById('admin-weekly-area').classList.remove('hidden'); document.getElementById('admin-daily-area').classList.add('hidden'); switchWeek('admin', viewState['admin']); } else { adminViewMode = 'daily'; document.getElementById('admin-weekly-area').classList.add('hidden'); document.getElementById('admin-daily-area').classList.remove('hidden'); loadAdminDaily(); } }
    function refreshAdminView() { adminShowName = document.getElementById('admin-show-name').checked; if (adminViewMode === 'weekly') { switchWeek('admin', viewState['admin']); } else { loadAdminDaily(); } }
    function loadAdminDaily() { var dateVal = document.getElementById('admin-daily-date').value; if (!dateVal) return; latestRequestedDate = dateVal; document.getElementById('admin-daily-title').innerText = "單日借用狀況 (" + dateVal + ")"; document.getElementById('admin-daily-table').innerHTML = '<div style="padding:20px; color:#666;">資料讀取中...</div>'; showLoading(true); google.script.run.withSuccessHandler(function(bookings) { showLoading(false); if (latestRequestedDate !== dateVal) return; renderAdminDailyTable(bookings, dateVal); }).withFailureHandler(function(err){ showLoading(false); document.getElementById('admin-daily-table').innerHTML = '<div style="color:red; padding:20px;">資料讀取失敗,請重試。<br>錯誤訊息: ' + err + '</div>'; }).getBookingData(dateVal, dateVal); }
    function renderAdminDailyTable(bookings, dateStr) { try { var html = '<div style="overflow-x:auto;"><table class="daily-table"><thead><tr>'; periods.forEach(function(p) { var bgClass = specialPeriods.includes(p) ? "special-bg" : ""; html += `<th class="${bgClass}">${p}</th>`; }); html += '</tr></thead><tbody><tr>'; periods.forEach(function(p) { var bgClass = specialPeriods.includes(p) ? "special-bg" : ""; var items = []; if(bookings && Array.isArray(bookings)) { items = bookings.filter(b => b.date === dateStr && b.period === p); } items.sort(function(a, b) { var c1 = String(a.className || ""); var c2 = String(b.className || ""); return c1.localeCompare(c2, 'zh-Hant', {numeric: true}); }); var totalUsed = items.reduce((acc, curr) => { var q = parseInt(curr.qty); return acc + (isNaN(q) ? 0 : q); }, 0); var remaining = systemMaxQty - totalUsed; if (remaining < 0) remaining = 0; var cellContent = ""; if (items.length > 0) { var info = items.map(b => { var nameHtml = adminShowName ? `<span class="booked-name">👤 ${b.name}</span>` : ''; return `<div> ${nameHtml} <span class="booked-info">${b.className}</span> <span class="booked-detail"> / ${b.qty}台</span> </div>`; }).join("<hr style='margin:2px 0; border-top:1px dashed #ccc;'>"); cellContent += info; } var qtyClass = (remaining > 0) ? "qty-ok" : "qty-zero"; cellContent += `<div class="qty-badge ${qtyClass}">剩餘 ${remaining} 台</div>`; var cellClass = (items.length > 0) ? "booked" : ""; html += `<td class="${cellClass} ${bgClass}">${cellContent}</td>`; }); html += '</tr></tbody></table></div>'; document.getElementById('admin-daily-table').innerHTML = html; } catch (e) { console.error("Render Error:", e); document.getElementById('admin-daily-table').innerHTML = "<div style='color:red;'>表格渲染發生錯誤。<br>" + e.message + "</div>"; } }
    function initReserveScreen() { document.getElementById('home-screen').classList.add('hidden'); document.getElementById('reserve-screen').classList.remove('hidden'); document.getElementById('display-name').innerText = currentUser.name; isShowAll = false; document.getElementById('filter-show-all').checked = false; setBookingDateLimits(); showLoading(true); google.script.run.withSuccessHandler(function(qty) { systemMaxQty = parseInt(qty); document.getElementById('max-qty-display').innerText = systemMaxQty; switchWeek('reserve', 0); }).getTabletTotal(); }
    function setBookingDateLimits() { var today = new Date(); var minDate = new Date(today); minDate.setDate(today.getDate() + 2); minDate.setHours(0,0,0,0); var thisSunday = getSunday(today); var maxDate = new Date(thisSunday); maxDate.setDate(thisSunday.getDate() + 13); var inputDate = document.getElementById('input-date'); inputDate.min = formatDate(minDate); inputDate.max = formatDate(maxDate); inputDate.value = formatDate(minDate); document.getElementById('date-hint').innerText = "可預約範圍: " + formatDate(minDate) + " 至 " + formatDate(maxDate) + " (需提前2天預約)"; }
    function initAdminScreen() { document.getElementById('home-screen').classList.add('hidden'); document.getElementById('admin-screen').classList.remove('hidden'); adminShowName = false; document.getElementById('admin-show-name').checked = false; document.getElementById('mode-weekly').checked = true; toggleAdminViewMode(); showLoading(true); google.script.run.withSuccessHandler(function(qty) { systemMaxQty = parseInt(qty); document.getElementById('display-current-total').innerText = qty; document.getElementById('admin-total-qty').value = ""; loadAdminList(); switchWeek('admin', 0); }).getTabletTotal(); }
    function loadAdminList() { google.script.run.withSuccessHandler(function(list) { renderAdminListTable(list); }).getAdminBookings(); }
    function renderAdminListTable(list) { if (list.length === 0) { document.getElementById('admin-booking-list').innerHTML = "<p style='color:#666; text-align:center;'>目前沒有未來的預約紀錄。</p>"; return; } var html = '<table class="week-table" style="margin-top:10px;"><thead><tr><th>日期</th><th>節次</th><th>借用人</th><th>班級</th><th>數量</th><th>操作</th></tr></thead><tbody>'; list.forEach(item => { html += `<tr> <td>${item.date}</td> <td>${item.period}</td> <td>${item.name}</td> <td>${item.className}</td> <td>${item.qty}</td> <td><button class="btn btn-danger" style="padding:4px 8px; font-size:12px;" onclick="deleteBooking(${item.rowIndex}, '${item.date}', '${item.period}', '${item.account}')">刪除</button></td> </tr>`; }); html += '</tbody></table>'; document.getElementById('admin-booking-list').innerHTML = html; }
    function renderCalendarTable(sundayDate, containerId, titleId, titlePrefix, forceShowAll, showName) { var weekDates = []; for (var i = 0; i < 7; i++) { var d = new Date(sundayDate); d.setDate(sundayDate.getDate() + i); weekDates.push(d); } var titleStr = (sundayDate.getMonth()+1) + "/" + sundayDate.getDate() + " - " + (weekDates[6].getMonth()+1) + "/" + weekDates[6].getDate(); document.getElementById(titleId).innerHTML = titlePrefix + " (" + titleStr + ")"; var startStr = formatDate(weekDates[0]); var endStr = formatDate(weekDates[6]); google.script.run.withSuccessHandler(function(bookings) { showLoading(false); var days = ["日", "一", "二", "三", "四", "五", "六"]; var html = '<table class="week-table"><thead><tr><th>節次</th>'; for (var i = 0; i < 7; i++) { html += `<th>${days[i]}<br><span style="font-size:10px; color:#666;">${weekDates[i].getMonth()+1}/${weekDates[i].getDate()}</span></th>`; } html += '</tr></thead><tbody>'; periods.forEach(function(p) { var bgClass = specialPeriods.includes(p) ? "special-bg" : ""; html += `<tr class="${bgClass}"><td class="period-col">${p}</td>`; for (var i = 0; i < 7; i++) { var dateStr = formatDate(weekDates[i]); var allItems = bookings.filter(b => b.date === dateStr && b.period === p); var totalUsed = allItems.reduce((acc, curr) => acc + parseInt(curr.qty), 0); var remaining = systemMaxQty - totalUsed; if (remaining < 0) remaining = 0; var displayItems = allItems; if (!forceShowAll && currentUser.account) { displayItems = allItems.filter(b => b.account === currentUser.account); } var cellContent = ""; if (displayItems.length > 0) { var info = displayItems.map(b => { var nameHtml = showName ? `<span class="booked-name">👤 ${b.name}</span>` : ''; return `<div> ${nameHtml} <span class="booked-info">${b.className}</span> <span class="booked-detail"> / ${b.qty}台</span> </div>`; }).join("<hr style='margin:2px 0; border-top:1px dashed #ccc;'>"); cellContent += info; } var qtyClass = (remaining > 0) ? "qty-ok" : "qty-zero"; cellContent += `<div class="qty-badge ${qtyClass}">剩餘 ${remaining} 台</div>`; var cellClass = (displayItems.length > 0) ? "booked" : ""; html += `<td class="${cellClass}">${cellContent}</td>`; } html += '</tr>'; }); html += '</tbody></table>'; document.getElementById(containerId).innerHTML = html; }).getBookingData(startStr, endStr); }
    function submitBooking() { var dateInput = document.getElementById('input-date'); var dateVal = dateInput.value; var period = document.getElementById('input-period').value; var cls = document.getElementById('input-class').value.trim(); var qty = document.getElementById('input-qty').value; if(!dateVal || !cls || !qty) { alert("請填寫完整資訊!"); return; } if (!/^\d+$/.test(qty) || parseInt(qty) <= 0) { alert("借用數量必須是正整數!"); return; } var inputD = new Date(dateVal); var today = new Date(); today.setHours(0,0,0,0); var minDate = new Date(today); minDate.setDate(today.getDate() + 2); minDate.setHours(0,0,0,0); if(inputD < minDate) { alert("無法預約!\n借用需提前申請,無法借用當日或隔日。"); return; } var maxDateStr = dateInput.max; if (maxDateStr && dateVal > maxDateStr) { alert("日期超過限制!"); return; } showLoading(true); google.script.run.withSuccessHandler(function(res) { showLoading(false); if(res.status === "success") { alert("預約成功!"); switchWeek('reserve', viewState['reserve']); document.getElementById('input-class').value = ""; document.getElementById('input-qty').value = ""; } else { alert(res.message); } }).saveBooking(currentUser.account, currentUser.name, dateVal, period, cls, qty); }
    function deleteBooking(idx, date, pd, acc) { if(confirm("確定刪除此紀錄?")) { showLoading(true); google.script.run.withSuccessHandler(res => { if(res.status === "success") { loadAdminList(); refreshAdminView(); } else { alert("失敗: " + res.message); showLoading(false); } }).deleteBooking(idx, date, pd, acc); } }
    function updateTotalQty() { var v = document.getElementById('admin-total-qty').value; if (!v) { alert("請輸入數字"); return; } if(confirm("更改總數為 " + v + " ?")) { showLoading(true); google.script.run.withSuccessHandler(function() { showLoading(false); alert("已更新"); systemMaxQty = parseInt(v); document.getElementById('display-current-total').innerText = v; refreshAdminView(); }).setTabletTotal(v); } }
    function getSunday(d) { d = new Date(d); var day = d.getDay(); var diff = d.getDate() - day; return new Date(d.setDate(diff)); }
    function formatDate(date) { var d = new Date(date), month = '' + (d.getMonth() + 1), day = '' + d.getDate(), year = d.getFullYear(); if (month.length < 2) month = '0' + month; if (day.length < 2) day = '0' + day; return [year, month, day].join('-'); }
  </script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

部署成 Web App 後,就會拿到可分享網址。

  1. Apps Script 右上:部署 → 新部署
  2. 類型選:Web app
  3. 執行身分:你
  4. 存取權限:任何人
  5. 完成後複製網址

💬 評論區

老師們的交流與提問

載入評論中...