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)
必要註解說明
請幫我設計一套「學校 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 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 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。
<!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 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論