0. 作品介紹
1. AI 提示詞
把這段指令貼給 AI,讓它幫你生成程式碼。
「請幫我用 Google Apps Script 寫一個【鑰匙借還登記系統】。
1.前端介面:包含一個【下拉式選單】選擇場地,下方顯示該場地的【週課表網格】。
2.互動邏輯:點選空白格可【多選借用】(變綠色),點選已借用格可【批次歸還】(變橘色)。
3.借用流程:確認借用後跳出視窗,需輸入【借用人姓名】並包含【Canvas 手寫簽名板】,簽名圖檔需上傳至 Drive。
4.資料庫規則:資料存於 Google 試算表。借用時,將【多個時段合併為一列】寫入;歸還時,自動比對並填入【歸還時間(含秒數)】。
5.樣式:請提供完整的 index.html (含 CSS/JS) 與 code.gs,並支援從後端讀取 Drive 圖片作為網頁背景。」
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 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 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。
<!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 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論