0. 作品介紹

作者 楊浚庭 建功高中

管理校園各場地以及專科教室鑰匙借用需求: 1.可視化登記介面:前端網頁透過【綠色(可借)】與【橘色(已借)】色塊,即時呈現各場地時段借用人與借還狀態。 2.高效率操作:支援跨時段批次選取借用,以及一鍵批次歸還功能,免去逐筆操作的繁瑣。 3.自動化紀錄:整合手寫電子簽名(可使用平板登記簽名),系統會自動將借用人、時段(自動合併)、簽名圖檔與精確歸還時間同步寫入雲端試算表,方便行政查詢與管理。


220 5 0
非教學
示範影片

1. AI 提示詞

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

「請幫我用 Google Apps Script 寫一個【鑰匙借還登記系統】。

1.前端介面:包含一個【下拉式選單】選擇場地,下方顯示該場地的【週課表網格】。

2.互動邏輯:點選空白格可【多選借用】(變綠色),點選已借用格可【批次歸還】(變橘色)。

3.借用流程:確認借用後跳出視窗,需輸入【借用人姓名】並包含【Canvas 手寫簽名板】,簽名圖檔需上傳至 Drive。

4.資料庫規則:資料存於 Google 試算表。借用時,將【多個時段合併為一列】寫入;歸還時,自動比對並填入【歸還時間(含秒數)】。

5.樣式:請提供完整的 index.html (含 CSS/JS) 與 code.gs,並支援從後端讀取 Drive 圖片作為網頁背景。」

2. 資料表規則

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

資料表規則圖
第1分頁: 場地清單 A 欄: 場地名稱 第2分頁: 紀錄 A 欄: 時間戳記 B 欄: 借用人 C 欄: 場地 D 欄: 借用時段 E 欄: 簽名檔連結 F 欄: 簽名圖片ID G 欄: 歸還時間

📄 解析結果(自動表格)

分頁:場地清單
A 欄
場地名稱
分頁:紀錄
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄 G 欄
時間戳記 借用人 場地 借用時段 簽名檔連結 簽名圖片ID 歸還時間

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 (最終修正版) */

// ==========================================
// ⚠️ 請仔細檢查這裡的 ID,不要貼整串網址
// ==========================================
const FOLDER_ID = '請填上你的google drive雲端資料夾ID'; //請貼上網址folders/之後的ID
const BACKGROUND_IMAGE_ID = '請填上你的google drive雲端背景圖片雲端ID'; // 若無背景圖請留空 ''
// ==========================================

function doGet() {
  let template = HtmlService.createTemplateFromFile('index');
  template.backgroundImageData = getBackgroundImageData();
  return template.evaluate()
    .setTitle('鑰匙借還登記系統')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function getBackgroundImageData() {
  if (!BACKGROUND_IMAGE_ID || BACKGROUND_IMAGE_ID.length < 5) return '';
  try {
    var file = DriveApp.getFileById(BACKGROUND_IMAGE_ID);
    return "data:" + file.getBlob().getContentType() + ";base64," + Utilities.base64Encode(file.getBlob().getBytes());
  } catch (e) { return ''; }
}

function getVenueList() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('場地清單');
  var lastRow = sheet.getLastRow();
  if (lastRow < 1) return [];
  return sheet.getRange(1, 1, lastRow, 1).getValues().map(r => r[0]).filter(v => v !== '');
}

// 取得借用狀態 (需解析合併的儲存格)
function getBookingStatus(venueName) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('紀錄');
  var lastRow = sheet.getLastRow();
  if (lastRow < 2) return {}; 

  var data = sheet.getRange(2, 1, lastRow - 1, 7).getValues();
  var status = {};

  // 遍歷每一列資料
  for (var i = 0; i < data.length; i++) {
    var row = data[i];
    var rVenue = row[2];
    var rSlotsStr = String(row[3]); // 這裡可能是 "早自習, 第1節"
    var rName = row[1];
    var rReturnTime = row[6];

    // 如果場地相符 且 還沒歸還
    if (rVenue === venueName && (rReturnTime === "" || rReturnTime === null)) {
      // 把合併的字串拆開,逐一標記為佔用
      var slotsArray = rSlotsStr.split(', ');
      slotsArray.forEach(function(slot) {
        status[slot] = rName;
      });
    }
  }
  return status;
}

// 儲存借用資料 (修正:多個時段合併寫入同一格)
function saveBookingData(data) {
  try {
    if (!FOLDER_ID || FOLDER_ID.includes('http')) {
      return { success: false, error: "資料夾 ID 設定錯誤,請檢查 Code.gs" };
    }

    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var sheet = ss.getSheetByName('紀錄');
    
    // 處理圖片
    var signatureUrl = "", signatureId = "";
    if (data.signature) {
      var contentType = 'image/jpeg';
      var blob = Utilities.newBlob(Utilities.base64Decode(data.signature.split(',')[1]), contentType, data.name + "_" + new Date().getTime() + ".jpg");
      var folder = DriveApp.getFolderById(FOLDER_ID);
      var file = folder.createFile(blob);
      file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
      signatureUrl = file.getUrl();
      signatureId = file.getId();
    }
    
    // 格式化時間
    var timestamp = Utilities.formatDate(new Date(), "GMT+8", "yyyy/MM/dd HH:mm:ss");
    
    // 將陣列 ["早自習", "第1節"] 合併成字串 "早自習, 第1節"
    var slotsString = data.slots.join(', ');

    // 寫入一行資料
    sheet.appendRow([
      timestamp,
      data.name,
      data.venue,
      slotsString,    // 合併後的時段
      signatureUrl,
      signatureId,
      ""              // 歸還時間留空
    ]);
    
    return { success: true };
  } catch (e) {
    return { success: false, error: e.toString() };
  }
}

// 批次歸還 (修正:精確時間格式 + 部分比對)
function batchReturnKeys(venue, slotsToReturn) {
  try {
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('紀錄');
    var lastRow = sheet.getLastRow();
    if (lastRow < 2) return { success: false, error: "無資料可歸還" };

    var data = sheet.getRange(2, 1, lastRow - 1, 7).getValues();
    // 格式化歸還時間 (包含秒數)
    var returnTimeStr = Utilities.formatDate(new Date(), "GMT+8", "yyyy/MM/dd HH:mm:ss");
    var updateCount = 0;

    for (var i = 0; i < data.length; i++) {
      var row = data[i];
      var rVenue = row[2];
      var rSlotsStr = String(row[3]); 
      var rReturn = row[6];

      // 邏輯:場地對 && 尚未歸還
      if (rVenue === venue && rReturn === "") {
        // 檢查這一筆紀錄的時段,是否有包含在使用者想歸還的時段內?
        // 例如紀錄是 "早自習, 第1節",使用者選了 "早自習" 要歸還 -> 判定為該筆紀錄歸還
        var isMatch = false;
        var recordSlots = rSlotsStr.split(', ');
        
        // 只要這筆紀錄中,有任何一個時段被選中要歸還,就將整筆紀錄視為歸還
        // (因為我們現在是一筆資料存多個時段,無法只歸還一半)
        for (var s = 0; s < recordSlots.length; s++) {
          if (slotsToReturn.includes(recordSlots[s])) {
            isMatch = true;
            break;
          }
        }

        if (isMatch) {
          // 寫入歸還時間到第 7 欄 (G欄)
          sheet.getRange(i + 2, 7).setValue(returnTimeStr);
          updateCount++;
        }
      }
    }

    if (updateCount > 0) {
      return { success: true, count: updateCount };
    } else {
      return { success: false, error: "找不到對應的借用紀錄。" };
    }

  } catch (e) {
    return { success: false, error: e.toString() };
  }
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

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

index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    /* 基礎樣式 */
    body { font-family: "Microsoft JhengHei", sans-serif; background-color: transparent; padding: 20px; position: relative; min-height: 100vh; }
    
    /* 背景圖處理 */
    body::before {
      content: ""; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1;
      background: <?!= backgroundImageData ? "url('" + backgroundImageData + "')" : "none" ?>;
      background-size: cover; background-position: center; background-repeat: no-repeat;
      opacity: 0.3;
    }
    
    .container { max-width: 800px; background: rgba(255, 255, 255, 0.95); padding: 30px; border-radius: 10px; box-shadow: 0 0 15px rgba(0,0,0,0.1); margin-bottom: 50px; }
    
    /* 表格樣式 */
    .timetable-container { overflow-x: auto; margin-top: 20px; margin-bottom: 20px; }
    table { width: 100%; text-align: center; border-collapse: collapse; table-layout: fixed; }
    th, td { border: 1px solid #dee2e6; padding: 5px; cursor: pointer; user-select: none; height: 70px; vertical-align: middle; font-size: 14px; }
    th { background-color: #e9ecef; height: auto; padding: 10px; }
    
    /* 借用中 (橘色) */
    td.borrowed { background-color: #fd7e14 !important; color: white; }
    .borrower-name { font-size: 12px; display: block; margin-top: 2px; font-weight: bold; background: rgba(0,0,0,0.2); border-radius: 4px; padding: 1px; }

    /* 準備借用 (綠色) */
    td.selected-borrow { background-color: #198754 !important; color: white; position: relative; }
    td.selected-borrow::after { content: '✔'; position: absolute; top: 2px; right: 2px; font-size: 12px; }

    /* 準備歸還 (紅色框) */
    td.selected-return { box-shadow: inset 0 0 0 4px #dc3545 !important; position: relative; }
    td.selected-return::after { content: '歸還'; position: absolute; top: 2px; right: 2px; background: #dc3545; font-size: 10px; padding: 1px 3px; border-radius: 3px; }

    /* 簽名板 */
    .signature-wrapper { position: relative; width: 100%; height: 200px; border: 2px dashed #ccc; border-radius: 5px; }
    #signature-pad { position: absolute; left: 0; top: 0; width: 100%; height: 100%; cursor: crosshair; }
    
    /* Loading */
    #loading-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.8); z-index: 9999; justify-content: center; align-items: center; flex-direction: column; }
  </style>
</head>
<body>

<div class="container">
  <h2 class="text-center mb-4">🔑 鑰匙借還登記系統</h2>

  <div class="mb-3">
    <label class="form-label fw-bold">1. 選擇場地:</label>
    <select class="form-select form-select-lg" id="venueSelect" onchange="loadVenueStatus()">
      <option value="" disabled selected>載入中...</option>
    </select>
  </div>

  <div class="alert alert-secondary py-2 small">
    <strong>操作方式:</strong>
    <span class="badge bg-light text-dark border">空白格</span> 點擊借用 (變綠)|
    <span class="badge bg-warning text-dark">橘色格</span> 點擊歸還 (變紅框)
  </div>

  <div class="timetable-container">
    <table class="table table-bordered" id="timeTable">
      <thead>
        <tr><th style="width:15%">節次</th><th>一</th><th>二</th><th>三</th><th>四</th><th>五</th></tr>
      </thead>
      <tbody></tbody>
    </table>
  </div>

  <div class="row g-2">
    <div class="col-6">
      <button id="btnBorrow" class="btn btn-primary btn-lg w-100 py-3" onclick="openConfirmModal()">
        📝 確認借用 <span id="badgeBorrow" class="badge bg-light text-dark ms-1 d-none">0</span>
      </button>
    </div>
    <div class="col-6">
      <button id="btnReturn" class="btn btn-danger btn-lg w-100 py-3" onclick="submitBatchReturn()">
        ↩️ 確認歸還 <span id="badgeReturn" class="badge bg-light text-dark ms-1 d-none">0</span>
      </button>
    </div>
  </div>
</div>

<div class="modal fade" id="confirmModal" tabindex="-1">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header bg-light">
        <h5 class="modal-title">📝 借用確認</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
      </div>
      <div class="modal-body">
        <div class="mb-2"><strong>場地:</strong> <span id="modalVenue" class="text-primary"></span></div>
        <div class="mb-3"><strong>時段:</strong> <span id="modalSlots" class="text-success fw-bold"></span></div>
        <div class="mb-3">
          <label class="form-label">借用人姓名 <span class="text-danger">*</span></label>
          <input type="text" class="form-control" id="borrowerName">
        </div>
        <div class="mb-3">
          <label class="form-label">簽名 <span class="text-danger">*</span></label>
          <div class="signature-wrapper">
            <canvas id="signature-pad"></canvas>
          </div>
          <button class="btn btn-sm btn-outline-secondary mt-1 w-100" onclick="signaturePad.clear()">清除重簽</button>
        </div>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
        <button type="button" class="btn btn-primary w-100" onclick="submitBooking()">送出登記</button>
      </div>
    </div>
  </div>
</div>

<div id="loading-overlay">
  <div class="spinner-border text-primary" style="width: 3rem; height: 3rem;"></div>
  <h4 class="mt-3 text-secondary">資料處理中...</h4>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.0.0/dist/signature_pad.umd.min.js"></script>

<script>
  const periods = ['早自習', '第1節', '第2節', '第3節', '第4節', '午休', '第5節', '第6節', '第7節', '第8節'];
  const days = ['一', '二', '三', '四', '五'];
  
  let signaturePad, myModal;
  let borrowSlots = new Set(); // 存要借的 ID
  let returnSlots = new Set(); // 存要還的 ID
  let currentVenueStatus = {}; 

  window.onload = function() {
    renderTable();
    loadVenues();
    
    // 初始化簽名板
    const canvas = document.getElementById('signature-pad');
    signaturePad = new SignaturePad(canvas, { backgroundColor: 'rgb(255, 255, 255)' });
    
    // 初始化 Modal
    const modalEl = document.getElementById('confirmModal');
    myModal = new bootstrap.Modal(modalEl);
    modalEl.addEventListener('shown.bs.modal', resizeCanvas);
  };

  function renderTable() {
    const tbody = document.querySelector('#timeTable tbody');
    let html = '';
    periods.forEach(p => {
      html += `<tr><td class="bg-light fw-bold">${p}</td>`;
      days.forEach(d => {
        const id = `星期${d}-${p}`;
        html += `<td id="${id}" onclick="handleCellClick(this, '${id}')"></td>`;
      });
      html += `</tr>`;
    });
    tbody.innerHTML = html;
  }

  function loadVenues() {
    google.script.run.withSuccessHandler(function(venues) {
      const select = document.getElementById('venueSelect');
      select.innerHTML = '<option value="" disabled selected>請選擇場地</option>';
      venues.forEach(v => select.innerHTML += `<option value="${v}">${v}</option>`);
    }).getVenueList();
  }

  function loadVenueStatus() {
    const venue = document.getElementById('venueSelect').value;
    if(!venue) return;
    
    document.getElementById('loading-overlay').style.display = 'flex';
    borrowSlots.clear();
    returnSlots.clear();
    updateBadges();

    google.script.run.withSuccessHandler(function(status) {
      currentVenueStatus = status;
      updateTableVisuals();
      document.getElementById('loading-overlay').style.display = 'none';
    }).getBookingStatus(venue);
  }

  function handleCellClick(cell, slotId) {
    const venue = document.getElementById('venueSelect').value;
    if (!venue) { alert('請先選擇場地!'); return; }

    // 判斷該格子是否已被借用
    if (currentVenueStatus[slotId]) {
      // --- 歸還模式 (橘色格) ---
      if (returnSlots.has(slotId)) {
        returnSlots.delete(slotId);
      } else {
        returnSlots.add(slotId);
      }
    } else {
      // --- 借用模式 (空白格) ---
      if (borrowSlots.has(slotId)) {
        borrowSlots.delete(slotId);
      } else {
        borrowSlots.add(slotId);
      }
    }
    updateTableVisuals();
    updateBadges();
  }

  function updateTableVisuals() {
    const cells = document.querySelectorAll('#timeTable td[id]');
    cells.forEach(cell => {
      const id = cell.id;
      cell.className = ''; 
      cell.innerHTML = ''; 

      // 1. 已借出 (橘色)
      if (currentVenueStatus[id]) {
        cell.classList.add('borrowed');
        cell.innerHTML = `<span>借用中</span><span class="borrower-name">${currentVenueStatus[id]}</span>`;
        // 若選中要歸還 (紅框)
        if (returnSlots.has(id)) cell.classList.add('selected-return');
      } 
      // 2. 準備借用 (綠色)
      else if (borrowSlots.has(id)) {
        cell.classList.add('selected-borrow');
      }
    });
  }

  function updateBadges() {
    const bCount = borrowSlots.size;
    const rCount = returnSlots.size;
    
    const bBadge = document.getElementById('badgeBorrow');
    bBadge.innerText = bCount;
    bBadge.classList.toggle('d-none', bCount === 0);

    const rBadge = document.getElementById('badgeReturn');
    rBadge.innerText = rCount;
    rBadge.classList.toggle('d-none', rCount === 0);
  }

  function resizeCanvas() {
    const canvas = document.getElementById('signature-pad');
    const wrapper = document.querySelector('.signature-wrapper');
    if (wrapper.clientWidth > 0) {
      const ratio = Math.max(window.devicePixelRatio || 1, 1);
      canvas.width = wrapper.clientWidth * ratio;
      canvas.height = wrapper.clientHeight * ratio;
      canvas.getContext("2d").scale(ratio, ratio);
      signaturePad.clear();
    }
  }

  // --- 送出借用 ---
  function openConfirmModal() {
    if (borrowSlots.size === 0) { alert('請先點選「空白格子」!'); return; }
    document.getElementById('modalVenue').innerText = document.getElementById('venueSelect').value;
    document.getElementById('modalSlots').innerText = Array.from(borrowSlots).sort().join(', ');
    myModal.show();
  }

  function submitBooking() {
    const name = document.getElementById('borrowerName').value.trim();
    if (!name || signaturePad.isEmpty()) { alert('請填寫完整資訊!'); return; }

    document.getElementById('loading-overlay').style.display = 'flex';
    myModal.hide();

    const payload = {
      venue: document.getElementById('venueSelect').value,
      slots: Array.from(borrowSlots).sort(), // 傳送排序後的陣列
      name: name,
      signature: signaturePad.toDataURL('image/jpeg') 
    };

    google.script.run.withSuccessHandler(function(res) {
      if (res.success) {
        alert('✅ 借用成功!');
        document.getElementById('borrowerName').value = '';
        signaturePad.clear();
        loadVenueStatus();
      } else {
        alert('❌ 失敗:' + res.error);
        document.getElementById('loading-overlay').style.display = 'none';
        myModal.show();
      }
    }).saveBookingData(payload);
  }

  // --- 送出歸還 ---
  function submitBatchReturn() {
    if (returnSlots.size === 0) { alert('請先點選要歸還的「橘色格子」!'); return; }
    
    const venue = document.getElementById('venueSelect').value;
    const list = Array.from(returnSlots);
    if (!confirm(`確定歸還以下 ${list.length} 個時段嗎?`)) return;

    document.getElementById('loading-overlay').style.display = 'flex';
    
    google.script.run.withSuccessHandler(function(res) {
      if(res.success) {
        alert(`✅ 歸還成功!(共更新 ${res.count} 筆紀錄)`);
        loadVenueStatus();
      } else {
        alert('❌ 歸還失敗:' + res.error);
        document.getElementById('loading-overlay').style.display = 'none';
      }
    }).batchReturnKeys(venue, list);
  }
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

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

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

💬 評論區

老師們的交流與提問

載入評論中...