Google Apps Script (GAS) 開發架構與部署實戰手冊
1. GAS 兩大執行模式:容器綁定型 vs 獨立型
同仁在進行 ERP 系統整合與資料存取時,必須先清楚 GAS 專案的兩種存在模式,這直接決定了程式碼開發入口與存取權限的控制:
| 比較項目 | 容器綁定型腳本 (Container-bound Scripts) | 獨立型腳本 (Standalone Scripts) |
|---|---|---|
| 概念 | 類似 Excel VBA。程式碼與特定的 Google 試算表、文件或簡報深度綁定。 | 類似 獨立運行的後端 Server。它在雲端硬碟中是一個獨立的檔案。 |
| 開啟入口 | 在 Google 試算表中,點擊頂部選單 「擴充功能」 > 「Apps Script」。 | 直接在 Google 雲端硬碟中點選 「新增」 > 「更多」 > 「Google Apps Script」。 |
| 當前容器取得 | 可直接呼叫 SpreadsheetApp.getActiveSpreadsheet() 取得當前綁定的試算表。 |
必須透過特定的 ID 才能操作試算表:SpreadsheetApp.openById("試算表_ID") |
| 檔案管理 | 腳本不會單獨顯示在雲端硬碟中。當您複製、刪除該試算表時,腳本會隨之複製或刪除。 | 檔案獨立存在,有獨立的版本管理與分享權限,可供多個不同的試算表共用。 |
◆ 7.4 前後端分離整合關鍵:避開 OPTIONS 預檢 (Preflight) 限制
在進行跨網域對接時,最常見的問題是瀏覽器報出 CORS 錯誤。
1. 什麼是 OPTIONS 預檢?:當瀏覽器偵測到您向跨網域(Cross-Origin)發送非簡單請求(例如自訂了 Header,或是將 Content-Type 設為 application/json)時,會先自動發送一個 OPTIONS 方法的請求確認伺服器權限。
2. GAS 的限制:Google Apps Script 的 Web App 不支援 CORS 預檢(OPTIONS 請求),會直接回報失敗。
3. 避開限制的做法:
在前端發送 fetch 時,**不設定特殊的 Headers**。此時瀏覽器會採用 **「簡單請求 (Simple Request)」** 格式(即預設 text/plain 傳送字串),進而**完全避開** OPTIONS 預檢限制。伺服器端的 GAS 使用 JSON.parse(e.postData.contents) 來解析字串,即可完美實現跨網域資料交換!
2. 安全密鑰管理:試算表 (易管理) vs 指令碼屬性 (安全)
在 ERP 系統對接中,經常需要串接外部 API(例如 Line Bot API、ERP API 或資料庫金鑰)。我們在管理敏感密碼與參數時,應遵循以下安全原則:
- 做法:直接放在 Google Sheets 的某個
Config分頁中. - 優點:易於管理。即使是不懂技術的同仁,也能直接在試算表上調整設定值(如:警報通知人數、資料查詢起訖日),無需修改程式碼。
- 做法:禁止寫在程式碼中,也不宜直接曝露在 Sheets 上。應放入 GAS 專案設定的 「指令碼屬性 (Script Properties)」 中。
- 設定方法:在 GAS 編輯器左側點擊 「專案設定 (齒輪圖標)」 > 下拉至 「指令碼屬性」 > 點擊 「新增指令碼屬性」,將金鑰以 Key-Value 方式儲存於 Google 雲端後台。
📝 程式碼讀取範例 (Script Properties):
// 在程式碼中動態讀取敏感金鑰,避免程式碼外洩時洩漏憑證
var apiKey = PropertiesService.getScriptProperties().getProperty("ERP_API_KEY");
3. 資料正規化初始化:使用 setup.gs
在多人協作或將工具部署給其他部門使用時,最常遇到的問題是:使用者複製了您的試算表,但忘記建立對應的 Sheet 分頁,或是手動輸入的分頁名稱有空格、錯字,導致程式執行崩潰。
解決方案:在專案中設計 setup.gs 檔案,撰寫環境與資料正規化的初始化函數,確保執行環境的正確:
執行初始化腳本成功示意圖(會自動在後台完成資料表的檢查與正規化建表):
📝 setup.gs 程式碼範例:
function setup() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
// 1. 自動檢查並建立 Logs 分頁
var logSheet = ss.getSheetByName("Logs");
if (!logSheet) {
logSheet = ss.insertSheet("Logs");
// 自動寫入標準欄位標頭 (資料正規化)
logSheet.appendRow(["時間戳記", "操作人員", "執行動作", "狀態"]);
logSheet.getRange("A1:D1").setFontWeight("bold").setBackground("#d9ead3");
}
// 2. 初始化指令碼屬性 (如果尚未設定)
var scriptProperties = PropertiesService.getScriptProperties();
if (!scriptProperties.getProperty("SYSTEM_VERSION")) {
scriptProperties.setProperty("SYSTEM_VERSION", "v1.0.0");
}
Logger.log("系統初始化與資料正規化設定完成!");
}
好處:同仁複製新試算表後,只需要手動執行一次 setup 函數,程式就會自動在背景將資料結構、格式與屬性全部建立妥當,達到「開箱即用」並防止人工操作失誤。
4. 前後端分離思維:GAS HTML vs GitHub HTML + GAS Proxy
GAS 允許我們在專案中建立 HTML 檔案來產出網頁介面,但在進行較具規模的 Web 專案時,架構選擇會直接影響效能與安全性:
- 機制:在 GAS 內建編輯器中新增
.html檔案,後端呼叫HtmlService.createHtmlOutputFromFile()來顯示網頁。 - 缺點:GAS 的 Web App 網頁會被包裹在 Google 的安全沙盒 (iframe) 中,導致載入速度慢、效能較差。此外,對外整合前端套件與 Git 版本控制很不便。
- 機制:
- 前端 (Client):將精美的網頁發布至 GitHub Pages。
- 後端 (Proxy):GAS 部署成 Web App 作為 API Proxy。前端透過
fetch()向 GAS 發送請求,GAS 處理試算表讀寫或寄信後再回傳。
- 優勢:網頁載入流暢快速,可使用現代前端開發;且後端 GAS 能隱藏敏感的資料處理邏輯與金鑰,安全性更高。
✦ 5. 實戰演練一:極簡 Web App 部署步驟 (含圖文引導)
現在,我們將實作第一支最簡單的 GAS Web App,目的在於「確認部署成功」並熟悉整個部署設定與 Google 的授權審核流程。
🔗 此案例線上正式 Demo 網址:
同仁可先點擊右側連結,查看本實戰演練部署完成後的實際運行效果與網頁呈現。
class3 > [study-gas]),在空白區點按右鍵,選擇 「Google 試算表」 建立新檔案。
gas-study-v1),接著在頂部選單點選 「擴充功能」 > 「Apps Script」。
程式碼.gs。
myFunction 內容替換為以下 **「極穩健觸發授權版」** 示範程式碼:
📝 doGet 程式碼:
function doGet() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheetName = ss.getActiveSheet().getName();
var html = "Deployment success. This is Bank Software Taixi Branch. Current sheet: " + sheetName;
return HtmlService.createHtmlOutput(html);
}
SpreadsheetApp(試算表)的存取,Google 就會偵測到敏感權限要求,並強制跳出安全授權視窗。請依序完成以下放行步驟:
-
點選 「授予存取權限 (Authorize Access)」:
- 選擇您的 Google 帳號。
-
畫面出現紅色警告「Google hasn't verified this app」。請點擊左下角的 「進階 (Advanced)」:
-
點選下方小字的 「前往『未命名專案』(Go to 未命名的專案 (unsafe))」:
-
在接下來的權限確認畫面中,確認權限範圍(查看、編輯、建立和刪除您在 Google 試算表中的所有試算表),點選右下角的 「Continue (繼續)」 完成授權放行:
- 開啟瀏覽器的 「無痕視窗」。
- 貼上剛才複製的網頁應用程式網址並按下 Enter。
-
若網頁成功顯示下列字樣,即代表您第一版的極簡部署作業完全成功:
Deployment success. This is Bank Software Taixi Branch. Current sheet: 工作表1
⚠️ 🔥 重要避坑指南:如何正確儲存與更新網頁應用程式 (維持相同網址)
在開發與維護 Web App 時,很多同仁最常遇到的問題是:「修改了 程式碼.gs,但為什麼重新整理網頁後沒看見更新?」或是「每次更新程式,網址就變了,導致外部系統或前端網頁都要重新設定網址。」
這通常是因為以下兩個關鍵動作沒有正確執行:
避坑步驟一:程式碼必須「儲存變更」
在 Apps Script 中,如果程式碼檔案名稱旁邊亮起 橘色圓點,代表有修改但尚未存檔。如果此時直接去部署,運行的將會是舊版程式碼。請務必使用 Ctrl + S 存檔,或點選編輯器上方的「儲存」圖示,確保橘色圓點消失。
避坑步驟二:必須透過「管理部署」建立新版本,而非「新增部署」
當我們要更新已發布的 Web App 且維持原本的網頁 URL 網址不變時,絕對不要再次點選「新增部署作業」(這會產生一個全新 ID 的網址),而應依循以下步驟:
-
點選右上角 「部署」 > 「管理部署作業」。在彈出的管理視窗中,點擊右上角的 「編輯 (鉛筆圖示)」:
-
點開「版本」下拉選單,選擇 「建立新版本」:
-
選定後,點選右下角的 「部署」 按鈕完成更新,如此一來即可完美維持原本的 Web App 網址:
-
成功更新後,系統會顯示「已成功更新部署作業」,此時其網網頁應用程式網址依然維持完全相同:
✦ 6. 實戰演練二:雙表帳密登入與問答系統 (V2 - GAS 內建網頁版)
在第一個實戰確認部署成功後,我們將進一步模擬真實的 ERP 前後端對接架構:建立一個具有帳密登入控制與下拉選單問答撈取的實用 Web App。
🔗 此案例線上正式 Demo 網址:
同仁可先點擊右側連結,查看此帳密登入與 QA 系統部署完成後的實際運行效果與網頁呈現(內建網頁版)。
◆ 6.1 系統開發三種做法優劣對比
同仁在實作 GAS 網頁應用時,通常會有以下三種主流開發架構。在開始動手前,我們可以先評估各做法的優缺點與適用情境:
| 架構做法 | 網頁託管位置 | 通訊通訊方式 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|---|
| 做法 A:行內字串版 (V2) | Google Apps Script 內部 | fetch(對自身 Web App 請求) |
1. 只有一個 .gs 檔案,部署極快。2. 適合搭配 AI 一鍵生成一頁式網頁。 |
1. HTML 字串在代碼內極難排版與除錯。 2. 程式碼過長時易因複製折行出錯。 |
快速概念驗證 (PoC)、極簡易單頁小工具。 |
| 做法 B:獨立檔案版 (V2) | Google Apps Script 內部 | google.script.run(內建管道) |
1. 支援 HTML 獨立分頁,代碼排版清晰。 2. 完全免處理 CORS 跨網域與 Preflight 限制。 |
1. 網頁加載受限於 Google iframe 安全沙盒,效能慢。 2. 無法使用 Git 進行流暢的版本控制。 |
試算表內建側邊欄 (Sidebar)、對話框擴充。 |
| V3:前後端分離版 (V3) | 外部伺服器 (地端/GitHub Pages) |
fetch(跨網域 JSON 通訊) |
1. 網頁加載極快,體驗流暢無 Google 沙盒卡頓。 2. 代碼支援完整的 Git 版控。 3. 敏感資訊與邏輯完全隱藏於後端,安全防護極高。 |
1. 需在外部託管網頁。 2. 必須嚴格處理跨網域 CORS 問題與簡單請求限制。 |
正式生產環境項目、跨系統 ERP 資料對接、現代化 Web App。 |
◆ 6.1.1 雙表系統架構設計
⚙️ V2 雙表系統架構設計:
- 資料庫(兩張工作表):
Passwords(帳密、角色、備註,預設 admin/admin123)與QA(5組預設問題與答案)。 - 資料初始化 (`setup.gs`):一鍵執行,自動檢測並建立工作表,寫入正規化標頭與初始測試資料。
- 內建呈現網頁的兩種做法:
- 做法 A:行內字串版:直接將網頁 HTML 字串宣告在
.gs程式碼中輸出。適合快速驗證。 - 做法 B:獨立網頁檔案版 (推薦):在 GAS 專案內建立
index.html檔案,透過google.script.run進行前後端非同步通訊,可避開所有跨網域限制。
- 做法 A:行內字串版:直接將網頁 HTML 字串宣告在
◆ 6.2 雙表資料初始化腳本 (setup.gs) 的建立與執行步驟
不論採用做法 A 還是做法 B,我們都需要先建立並初始化 Passwords 與 QA 資料表。請遵循以下步驟操作:
步驟 1:建立 setup.gs 檔案
在 Apps Script 編輯器左側的「檔案」旁,點選 「+」 按鈕,並在下拉選單中選擇 「指令碼」:
將新建的檔案命名為 setup (系統會自動生成 setup.gs):
步驟 2:貼入初始化程式碼並儲存
清空新建檔案中的所有預設程式碼,複製並貼上下方的資料庫初始化程式碼。接著,點擊編輯器上方的 「儲存」 圖標或按 Ctrl + S:
📝 setup.gs (資料庫初始化與正規化)
function setup() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
// 1. 初始化 Passwords 工作表
var passwordSheet = ss.getSheetByName("Passwords");
if (!passwordSheet) {
passwordSheet = ss.insertSheet("Passwords");
passwordSheet.appendRow(["Username", "Password", "Role", "Note"]);
passwordSheet.getRange("A1:D1").setFontWeight("bold").setBackground("#cfe2f3");
passwordSheet.appendRow(["admin", "admin123", "Admin", "Default Admin Account"]);
passwordSheet.appendRow(["user", "user123", "User", "Default User Account"]);
}
// 2. 初始化 QA 工作表
var qaSheet = ss.getSheetByName("QA");
if (!qaSheet) {
qaSheet = ss.insertSheet("QA");
qaSheet.appendRow(["ID", "Question", "Answer"]);
qaSheet.getRange("A1:C1").setFontWeight("bold").setBackground("#d9ead3");
// 預設 5 組問答
qaSheet.appendRow(["1", "台西分公司營業時間?", "週一至週五 09:00 - 15:30。"]);
qaSheet.appendRow(["2", "如何聯絡台西分公司客服?", "請撥打分機 #888。"]);
qaSheet.appendRow(["3", "銀行軟體系統每日結帳時間?", "每日下午 17:00 進行批次結帳。"]);
qaSheet.appendRow(["4", "如何申請測試帳號?", "請填寫 ERP 權限申請單送交系統管理員。"]);
qaSheet.appendRow(["5", "系統發生連線逾時如何處理?", "請確認網路 VPN 狀態,並清除瀏覽器快取。"]);
}
Logger.log("V2 資料庫初始化與資料正規化設定完成!");
}
步驟 3:執行 setup 函數進行授權與建表
在編輯器頂端工具列中,確認選擇的執行函數為 setup,接著點選左側的 「執行」:
系統會偵測到對試算表的寫入要求,並彈出「需要授權」提示。請點擊 「審查權限」:
在彈出的帳號驗證及安全警告中,點選「進階」並按下 「前往『gas-study-v2』(不安全)」:
在最後的「確認您信任的應用程式」畫面中,確認權限範圍並點擊右下角的 「繼續 (Continue)」 完成授權放行:
授權完成後,編輯器下方執行記錄將顯示 V2 資料庫初始化與資料正規化設定完成! 與 執行已結束,這代表您的兩張資料工作表已成功在試算表中自動建立完成:
此時開啟您的 Google 試算表,您會看見新增了 Passwords 與 QA 兩個分頁,並已自動填入正規化的初始預設資料:
◆ 6.3 做法 A:極簡單檔行內字串版
不建立任何獨立 HTML 檔案,所有的網頁代碼都以「字串」形式寫在 程式碼.gs 中輸出:
📝 程式碼.gs (行內字串版)
function doGet(e) {
var action = e.parameter.action;
// 處理 QA 撈取
if (action === "getQA") {
return getQAData();
}
// 處理登入驗證
if (action === "login") {
return handleLogin(e.parameter.username, e.parameter.password);
}
// 預設輸出網頁 (行內字串版)
var htmlString = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Taixi Branch QA</title>' +
'<style>body{font-family:Arial;background:#0b0f19;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;}' +
'.card{background:#171c29;border:1px solid #06b6d4;border-radius:12px;padding:30px;width:300px;}' +
'input,select,button{width:100%;padding:10px;margin-top:10px;background:#1f2937;color:#fff;border:1px solid #374151;border-radius:6px;box-sizing:border-box;}' +
'button{background:#06b6d4;font-weight:bold;cursor:pointer;}' +
'.hidden{display:none;}.error{color:#ef4444;font-size:0.85rem;margin-top:5px; text-align:center;}' +
'</style></head><body>' +
'<div id="login" class="card"><h2>系統登入</h2><input type="text" id="user" placeholder="帳號"><input type="password" id="pass" placeholder="密碼"><button onclick="login()">登入</button><div id="err" class="error hidden"></div></div>' +
'<div id="qa" class="card hidden"><h2>問答查詢系統</h2><select id="sel" onchange="showAns()"><option value="">-- 請選擇 --</option></select><div id="ans" style="margin-top:15px;padding:10px;background:#1e293b;border-left:4px solid #10b981;display:none;"></div></div>' +
'<script>' +
'var webUrl = "' + ScriptApp.getService().getUrl() + '";' +
'var qaList = [];' +
'function login() {' +
' var u = document.getElementById("user").value.trim();' +
' var p = document.getElementById("pass").value.trim();' +
' if(!u||!p) { alert("請輸入完整帳密!"); return; }' +
' fetch(webUrl + "?action=login&username=" + encodeURIComponent(u) + "&password=" + encodeURIComponent(p))' +
' .then(r => r.json()).then(res => {' +
' if(res.success){' +
' document.getElementById("login").classList.add("hidden");' +
' document.getElementById("qa").classList.remove("hidden");' +
' loadQA();' +
' } else { var e=document.getElementById("err"); e.innerText=res.message; e.classList.remove("hidden"); }' +
' });' +
'}' +
'function loadQA() {' +
' fetch(webUrl + "?action=getQA").then(r => r.json()).then(res => {' +
' if(res.success){' +
' qaList = res.data;' +
' var sel = document.getElementById("sel");' +
' qaList.forEach(item => {' +
' var opt = document.createElement("option");' +
' opt.value = item.id; opt.innerText = item.question;' +
' sel.appendChild(opt);' +
' });' +
' }' +
' });' +
'}' +
'function showAns() {' +
' var val = document.getElementById("sel").value;' +
' var box = document.getElementById("ans");' +
' if(!val){ box.style.display="none"; return; }' +
' var found = qaList.find(x => String(x.id) === String(val));' +
' if(found){ box.innerText = "答案:" + found.answer; box.style.display="block"; }' +
'}' +
'</script></body></html>';
return HtmlService.createHtmlOutput(htmlString);
}
function handleLogin(username, password) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("Passwords");
if (!sheet) return createJsonResponse({ success: false, message: "Passwords sheet not found" });
var data = sheet.getDataRange().getValues();
for (var i = 1; i < data.length; i++) {
if (data[i][0] === username && String(data[i][1]) === String(password)) {
return createJsonResponse({ success: true, role: data[i][2] });
}
}
return createJsonResponse({ success: false, message: "帳號或密碼錯誤" });
}
function getQAData() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("QA");
if (!sheet) return createJsonResponse({ success: false, message: "QA sheet not found" });
var data = sheet.getDataRange().getValues();
var qaList = [];
for (var i = 1; i < data.length; i++) {
qaList.push({ id: data[i][0], question: data[i][1], answer: data[i][2] });
}
return createJsonResponse({ success: true, data: qaList });
}
function createJsonResponse(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON);
}
◇ 6.3.1 做法 A 網頁應用程式部署步驟 (圖文引導)
貼上做法 A 的程式碼後,請依照以下步驟將其發布為網頁應用程式:
程式碼.gs 原本的代碼清空,貼上做法 A 的完整程式碼,並按下 Ctrl + S 儲存。確保左側檔案名稱旁的橘色圓點消失。
◆ 6.4 做法 B:標準獨立檔案與 google.script.run 非同步通訊版 (最推薦)
我們在 Apps Script 專案內建立兩個檔案,利用 Google 內建的非同步通訊管道 google.script.run 呼叫後端函數。此做法排版乾淨、支援完整網頁開發且不需要處理跨網域 CORS 問題。
📝 伺服器端:程式碼.gs (獨立檔案版)
function doGet() {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('台西分公司問答系統')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
// 供前端 google.script.run.handleLogin() 呼叫
function handleLogin(username, password) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("Passwords");
if (!sheet) return { success: false, message: "Passwords sheet not found" };
var data = sheet.getDataRange().getValues();
for (var i = 1; i < data.length; i++) {
if (data[i][0] === username && String(data[i][1]) === String(password)) {
return { success: true, role: data[i][2] };
}
}
return { success: false, message: "帳號或密碼錯誤" };
}
// 供前端 google.script.run.getQAData() 呼叫
function getQAData() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("QA");
if (!sheet) return { success: false, message: "QA sheet not found" };
var data = sheet.getDataRange().getValues();
var qaList = [];
for (var i = 1; i < data.length; i++) {
qaList.push({ id: data[i][0], question: data[i][1], answer: data[i][2] });
}
return { success: true, data: qaList };
}
📝 用戶端網頁:index.html (獨立檔案版)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Bank Software Taixi Branch - QA System</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #0b0f19;
color: #f3f4f6;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.card {
background-color: #171c29;
border: 1px solid #06b6d4;
border-radius: 12px;
padding: 30px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(6, 182, 212, 0.2);
}
h2 {
margin-top: 0;
color: #06b6d4;
text-align: center;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-size: 0.9rem;
}
input, select {
width: 100%;
padding: 10px;
border: 1px solid #374151;
background-color: #1f2937;
color: #ffffff;
border-radius: 6px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
background-color: #06b6d4;
border: none;
color: white;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
margin-top: 10px;
}
button:hover {
background-color: #0891b2;
}
.hidden {
display: none;
}
.error-msg {
color: #ef4444;
font-size: 0.85rem;
margin-top: 5px;
text-align: center;
}
.answer-box {
margin-top: 20px;
padding: 15px;
background-color: #1e293b;
border-left: 4px solid #10b981;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- 1. 登入面板 -->
<div id="login-panel" class="card">
<h2>系統登入</h2>
<div class="form-group">
<label for="username">帳號</label>
<input type="text" id="username" placeholder="請輸入帳號">
</div>
<div class="form-group">
<label for="password">密碼</label>
<input type="password" id="password" placeholder="請輸入密碼">
</div>
<button id="login-btn">登入</button>
<div id="login-error" class="error-msg hidden"></div>
</div>
<!-- 2. QA 系統面板 -->
<div id="qa-panel" class="card hidden">
<h2>問答查詢系統</h2>
<div class="form-group">
<label for="qa-select">請選擇問題</label>
<select id="qa-select">
<option value="">-- 請選擇 --</option>
</select>
</div>
<div id="answer-container" class="answer-box hidden">
<strong style="color: #10b981;">答案:</strong>
<div id="answer-text" style="margin-top: 5px;"></div>
</div>
<button id="logout-btn" style="background-color: #4b5563; margin-top: 15px;">登出</button>
</div>
<script>
const loginPanel = document.getElementById("login-panel");
const qaPanel = document.getElementById("qa-panel");
const loginBtn = document.getElementById("login-btn");
const logoutBtn = document.getElementById("logout-btn");
const loginError = document.getElementById("login-error");
const qaSelect = document.getElementById("qa-select");
const answerContainer = document.getElementById("answer-container");
const answerText = document.getElementById("answer-text");
let qaData = [];
// 處理登入
loginBtn.addEventListener("click", function() {
const user = document.getElementById("username").value.trim();
const pass = document.getElementById("password").value.trim();
if (!user || !pass) {
showError("請輸入完整帳密!");
return;
}
loginError.classList.add("hidden");
loginBtn.disabled = true;
loginBtn.innerText = "驗證中...";
// 呼叫 GAS 後端 handleLogin 函數
google.script.run
.withSuccessHandler(function(result) {
loginBtn.disabled = false;
loginBtn.innerText = "登入";
if (result.success) {
loginPanel.classList.add("hidden");
qaPanel.classList.remove("hidden");
loadQA();
} else {
showError(result.message);
}
})
.handleLogin(user, pass);
});
// 撈取 QA 資料
function loadQA() {
google.script.run
.withSuccessHandler(function(result) {
if (result.success) {
qaData = result.data;
qaSelect.innerHTML = '<option value="">-- 請選擇 --</option>';
qaData.forEach(item => {
const opt = document.createElement("option");
opt.value = item.id;
opt.innerText = item.question;
qaSelect.appendChild(opt);
});
} else {
alert("撈取 QA 失敗:" + result.message);
}
})
.getQAData();
}
// 選取問題後顯示答案
qaSelect.addEventListener("change", function() {
const selectedId = qaSelect.value;
if (!selectedId) {
answerContainer.classList.add("hidden");
return;
}
const found = qaData.find(item => String(item.id) === String(selectedId));
if (found) {
answerText.innerText = found.answer;
answerContainer.classList.remove("hidden");
}
});
// 登出
logoutBtn.addEventListener("click", function() {
document.getElementById("username").value = "";
document.getElementById("password").value = "";
qaPanel.classList.add("hidden");
loginPanel.classList.remove("hidden");
answerContainer.classList.add("hidden");
});
function showError(msg) {
loginError.innerText = msg;
loginError.classList.remove("hidden");
}
</script>
</body>
</html>
✦ 7. 實戰演練三:前後端分離與環境變數安全防護 (V3 - 地端/GitHub HTML + GAS Proxy API)
在掌握了 GAS 內建渲染的 V2 版本後,接下來我們將系統升級為 前後端分離架構。前端網頁不再託管於 Google Apps Script 內部,而是放在同仁的本地端電腦或發布於 GitHub Pages,僅透過 API 形式呼叫後端 GAS(作為資料 Proxy 代理)。
此外,我們將導入 環境變數 (Script Properties) 的安全防護觀念,將敏感帳密從代碼與工作表中抽離。
🔗 此案例線上正式 Demo 前端測試網頁:
同仁可先點擊右側連結,在線上直接輸入您自己部署的 GAS API 進行登入與雙向寫入測試。
🛡️ V3 安全防護設計:使用指令碼屬性 (Script Properties)
- 環境變數後台設定:在 Apps Script 編輯器左側點選 ⚙️ 「專案設定」 > 下拉至「指令碼屬性」 > 點選「新增指令碼屬性」,建立
ADMIN_USERNAME(值為admin) 與ADMIN_PASSWORD(值為admin123)。 - 代碼安全讀取:使用
PropertiesService.getScriptProperties().getProperty("ADMIN_PASSWORD")動態比對,帳密不再外露於試算表或代碼。
◆ 7.2 伺服器端 GAS 程式碼
📝 setup.gs (敏感帳密抽離)
function setup() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
// 1. 初始化 Passwords 工作表
var passwordSheet = ss.getSheetByName("Passwords");
if (!passwordSheet) {
passwordSheet = ss.insertSheet("Passwords");
passwordSheet.appendRow(["Username", "Password", "Role", "Note"]); // 欄位包含 Password
passwordSheet.getRange("A1:D1").setFontWeight("bold").setBackground("#cfe2f3");
// 僅建立一般使用者帳密在 Sheets 中
passwordSheet.appendRow(["user", "user123", "User", "General User Account (Stored in Sheet)"]);
// 管理員只存放 Username 作為備忘,不儲存密碼於 Sheet
passwordSheet.appendRow(["admin", "", "Admin", "Admin password is set in Script Properties"]);
}
// 2. 初始化 QA 工作表
var qaSheet = ss.getSheetByName("QA");
if (!qaSheet) {
qaSheet = ss.insertSheet("QA");
qaSheet.appendRow(["ID", "Question", "Answer"]);
qaSheet.getRange("A1:C1").setFontWeight("bold").setBackground("#d9ead3");
// 預設 5 組問答
qaSheet.appendRow(["1", "台西分公司營業時間?", "週一至週五 09:00 - 15:30。"]);
qaSheet.appendRow(["2", "如何聯絡台西分公司客服?", "請撥打分機 #888。"]);
qaSheet.appendRow(["3", "銀行軟體系統每日結帳時間?", "每日下午 17:00 進行批次結帳。"]);
qaSheet.appendRow(["4", "如何申請測試帳號?", "請填寫 ERP 權限申請單送交系統管理員。"]);
qaSheet.appendRow(["5", "系統發生連線逾時如何處理?", "請確認網路 VPN 狀態,並清除瀏覽器快取。"]);
}
Logger.log("V3 資料庫初始化完成!敏感帳密已從 Sheets 中抽離。");
}
📝 程式碼.gs (環境變數安全路由)
function doGet(e) {
var action = e.parameter.action;
if (action === "getQA") {
return getQAData();
}
return ContentService.createTextOutput(JSON.stringify({
status: "success",
message: "Bank Software Taixi Branch V3 Server is running."
})).setMimeType(ContentService.MimeType.JSON);
}
function doPost(e) {
var postData;
try {
postData = JSON.parse(e.postData.contents);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({
success: false,
message: "Invalid JSON format"
})).setMimeType(ContentService.MimeType.JSON);
}
var action = postData.action;
if (action === "login") {
return handleLogin(postData.username, postData.password);
}
if (action === "addQA") {
return addQAData(postData.question, postData.answer);
}
return ContentService.createTextOutput(JSON.stringify({
success: false,
message: "Unknown action"
})).setMimeType(ContentService.MimeType.JSON);
}
function handleLogin(username, password) {
var scriptProperties = PropertiesService.getScriptProperties();
var sysAdminUser = scriptProperties.getProperty("ADMIN_USERNAME");
var sysAdminPass = scriptProperties.getProperty("ADMIN_PASSWORD");
// 做法 1:比對「指令碼屬性」安全環境變數 (適合 Admin 管理員)
if (username === sysAdminUser && password === sysAdminPass) {
return ContentService.createTextOutput(JSON.stringify({
success: true,
role: "Admin",
authType: "ScriptProperties",
message: "登入成功 (安全環境變數模式 - 管理員)"
})).setMimeType(ContentService.MimeType.JSON);
}
// 做法 2:比對「工作表」明碼資料表 (適合一般 User 查詢權限)
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("Passwords");
if (sheet) {
var data = sheet.getDataRange().getValues();
for (var i = 1; i < data.length; i++) {
var sheetUser = data[i][0];
var sheetPass = String(data[i][1]);
var sheetRole = data[i][2];
if (username === sheetUser && password === sheetPass) {
return ContentService.createTextOutput(JSON.stringify({
success: true,
role: sheetRole,
authType: "GoogleSheet",
message: "登入成功 (工作表儲存格比對模式 - 一般用戶)"
})).setMimeType(ContentService.MimeType.JSON);
}
}
}
return ContentService.createTextOutput(JSON.stringify({
success: false,
message: "帳號或密碼錯誤!"
})).setMimeType(ContentService.MimeType.JSON);
}
function getQAData() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("QA");
if (!sheet) {
return ContentService.createTextOutput(JSON.stringify({
success: false,
message: "Database error: QA sheet not found"
})).setMimeType(ContentService.MimeType.JSON);
}
var data = sheet.getDataRange().getValues();
var qaList = [];
for (var i = 1; i < data.length; i++) {
qaList.push({ id: data[i][0], question: data[i][1], answer: data[i][2] });
}
return ContentService.createTextOutput(JSON.stringify({
success: true,
data: qaList
})).setMimeType(ContentService.MimeType.JSON);
}
function addQAData(question, answer) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("QA");
if (!sheet) {
return ContentService.createTextOutput(JSON.stringify({
success: false,
message: "Database error: QA sheet not found"
})).setMimeType(ContentService.MimeType.JSON);
}
var lastRow = sheet.getLastRow();
var nextId = 1;
if (lastRow > 1) {
nextId = Number(sheet.getRange(lastRow, 1).getValue()) + 1;
}
sheet.appendRow([nextId, question, answer]);
return ContentService.createTextOutput(JSON.stringify({
success: true,
message: "QA 新增成功!"
})).setMimeType(ContentService.MimeType.JSON);
}
◇ 7.2.1 伺服器端 V3 部署與執行步驟 (圖文引導)
請依照以下步驟完成 V3 伺服器端的設定與 Web App 部署:
setup.gs 指令碼檔案,並將上方的初始化程式碼貼入並儲存。
setup 函數,點擊 「執行」 按鈕。確保執行記錄成功完成,表示試算表分頁已自動建立。
index.html 檔案,將部署獲得的新 V3 URL 複製並替換寫入 GAS_URL 變數中。
◆ 7.3 用戶端外部網頁程式碼 (index.html)
請在您的本地電腦建立一個 index.html 檔案,完整貼入以下程式碼。記得將 GAS_URL 替換為您 V3 部署後生成的新網址:
📝 外部 index.html 代碼:
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bank Software Taixi Branch - V3 QA System</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #0b0f19;
color: #f3f4f6;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.card {
background-color: #171c29;
border: 1px solid #06b6d4;
border-radius: 12px;
padding: 30px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(6, 182, 212, 0.2);
}
h2 {
margin-top: 0;
color: #06b6d4;
text-align: center;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-size: 0.9rem;
}
input, select {
width: 100%;
padding: 10px;
border: 1px solid #374151;
background-color: #1f2937;
color: #ffffff;
border-radius: 6px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
background-color: #06b6d4;
border: none;
color: white;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
margin-top: 10px;
}
button:hover {
background-color: #0891b2;
}
.hidden {
display: none;
}
.error-msg {
color: #ef4444;
font-size: 0.85rem;
margin-top: 5px;
text-align: center;
}
.answer-box {
margin-top: 20px;
padding: 15px;
background-color: #1e293b;
border-left: 4px solid #10b981;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- 1. 登入面板 -->
<div id="login-panel" class="card">
<h2>V3 系統登入</h2>
<!-- API 連線網址 (選填) -->
<div class="form-group" style="margin-bottom: 20px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 15px;">
<label for="gas-url-input">API 連線網址 (選填)</label>
<input type="text" id="gas-url-input" placeholder="可輸入自訂的 GAS Web App 網址">
<small style="color: #94a3b8; font-size: 0.75rem; margin-top: 5px; display: block;">若留空,將使用手冊內置的 V3 預設 Demo 網址。</small>
</div>
<div class="form-group">
<label for="username">帳號</label>
<input type="text" id="username" placeholder="請輸入帳號">
</div>
<div class="form-group">
<label for="password">密碼</label>
<input type="password" id="password" placeholder="請輸入密碼">
</div>
<button id="login-btn">登入</button>
<div id="login-error" class="error-msg hidden"></div>
</div>
<!-- 2. QA 系統面板 -->
<div id="qa-panel" class="card hidden">
<h2>問答查詢系統</h2>
<div id="auth-info" style="font-size: 0.85rem; color: #06b6d4; text-align: center; margin-bottom: 15px; font-weight: bold;"></div>
<div class="form-group" style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex-grow: 1;">
<label for="qa-select">請選擇問題</label>
<select id="qa-select">
<option value="">-- 請選擇 --</option>
</select>
</div>
<button id="refresh-btn" style="width: auto; margin-top: 0; background-color: #4b5563; padding: 10px 15px; flex-shrink: 0;">🔄 整理</button>
</div>
<div id="answer-container" class="answer-box hidden">
<strong style="color: #10b981;">答案:</strong>
<div id="answer-text" style="margin-top: 5px;"></div>
</div>
<!-- 3. 新增問答面板 (雙向寫入) -->
<div style="border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 20px; padding-top: 15px;">
<h3 style="color: #06b6d4; font-size: 1rem; margin-top: 0; text-align: center;">新增問答資料 (雙向寫入)</h3>
<div class="form-group">
<label for="new-question">問題</label>
<input type="text" id="new-question" placeholder="請輸入問題內容">
</div>
<div class="form-group">
<label for="new-answer">答案</label>
<input type="text" id="new-answer" placeholder="請輸入答案內容">
</div>
<button id="upload-btn" style="background-color: #10b981;">上傳問答</button>
<div id="upload-message" style="margin-top: 10px; font-size: 0.85rem; text-align: center;" class="hidden"></div>
</div>
<button id="logout-btn" style="background-color: #4b5563; margin-top: 15px;">登出</button>
</div>
<script>
// 這裡替換為您 V3 部署後生成的 Web App 網址
const GAS_URL = "https://script.google.com/macros/s/AKfycbziX48EPI9OcMsbtmFjeqEQGXaPkXxbchzgk5Tn3nu2l2fUvhlIPHXXKCshSR8xAi_gHA/exec";
const loginPanel = document.getElementById("login-panel");
const qaPanel = document.getElementById("qa-panel");
const loginBtn = document.getElementById("login-btn");
const logoutBtn = document.getElementById("logout-btn");
const loginError = document.getElementById("login-error");
const qaSelect = document.getElementById("qa-select");
const answerContainer = document.getElementById("answer-container");
const answerText = document.getElementById("answer-text");
// V3 雙向回寫與 URL 控制項
const uploadBtn = document.getElementById("upload-btn");
const newQuestion = document.getElementById("new-question");
const newAnswer = document.getElementById("new-answer");
const uploadMessage = document.getElementById("upload-message");
const refreshBtn = document.getElementById("refresh-btn");
let qaData = [];
// 獲取當前要使用的 GAS URL (選填欄位有值就用它,無則用程式碼預設)
function getGasUrl() {
const inputUrl = document.getElementById("gas-url-input").value.trim();
return inputUrl || GAS_URL;
}
// 處理登入
loginBtn.addEventListener("click", function() {
const user = document.getElementById("username").value.trim();
const pass = document.getElementById("password").value.trim();
if (!user || !pass) {
showError("請輸入完整帳密!");
return;
}
loginError.classList.add("hidden");
loginBtn.disabled = true;
loginBtn.innerText = "驗證中...";
// 使用簡單 POST 請求,避開 Preflight 限制
fetch(getGasUrl(), {
method: "POST",
body: JSON.stringify({
action: "login",
username: user,
password: pass
})
})
.then(res => res.json())
.then(result => {
loginBtn.disabled = false;
loginBtn.innerText = "登入";
if (result.success) {
loginPanel.classList.add("hidden");
qaPanel.classList.remove("hidden");
// 顯示目前所使用的登入方式與驗證源 (明確呈現)
const authModeText = result.authType === "ScriptProperties" ? "指令碼屬性驗證 (安全環境變數)" : "工作表格儲存格驗證";
document.getElementById("auth-info").innerText = `登入角色:${result.role} (${authModeText})`;
loadQA();
} else {
showError(result.message || "登入失敗!");
}
})
.catch(err => {
loginBtn.disabled = false;
loginBtn.innerText = "登入";
showError("連線伺服器失敗,請確認 GAS URL 是否填寫正確!");
console.error(err);
});
});
// 撈取 QA 資料
function loadQA() {
fetch(getGasUrl() + "?action=getQA")
.then(res => res.json())
.then(result => {
if (result.success) {
qaData = result.data;
qaSelect.innerHTML = '<option value="">-- 請選擇 --</option>';
qaData.forEach(item => {
const opt = document.createElement("option");
opt.value = item.id;
opt.innerText = item.question;
qaSelect.appendChild(opt);
});
} else {
alert("撈取 QA 失敗:" + result.message);
}
})
.catch(err => {
alert("撈取 QA 連線失敗!");
console.error(err);
});
}
// 選取問題後顯示答案
qaSelect.addEventListener("change", function() {
const selectedId = qaSelect.value;
if (!selectedId) {
answerContainer.classList.add("hidden");
return;
}
const found = qaData.find(item => String(item.id) === String(selectedId));
if (found) {
answerText.innerText = found.answer;
answerContainer.classList.remove("hidden");
}
});
// 重新整理按鈕事件
refreshBtn.addEventListener("click", loadQA);
// 處理問答上傳 (雙向通訊回寫)
uploadBtn.addEventListener("click", function() {
const q = newQuestion.value.trim();
const a = newAnswer.value.trim();
if (!q || !a) {
showUploadMessage("請輸入完整的問題與答案!", "#ef4444");
return;
}
uploadBtn.disabled = true;
uploadBtn.innerText = "上傳中...";
uploadMessage.classList.add("hidden");
fetch(getGasUrl(), {
method: "POST",
body: JSON.stringify({
action: "addQA",
question: q,
answer: a
})
})
.then(res => res.json())
.then(result => {
uploadBtn.disabled = false;
uploadBtn.innerText = "上傳問答";
if (result.success) {
showUploadMessage("上傳成功!", "#10b981");
newQuestion.value = "";
newAnswer.value = "";
loadQA(); // 重新載入問題選單
} else {
showUploadMessage("上傳失敗:" + result.message, "#ef4444");
}
})
.catch(err => {
uploadBtn.disabled = false;
uploadBtn.innerText = "上傳問答";
showUploadMessage("連線錯誤:" + err.message, "#ef4444");
console.error(err);
});
});
function showUploadMessage(msg, color) {
uploadMessage.innerText = msg;
uploadMessage.style.color = color;
uploadMessage.classList.remove("hidden");
}
// 登出
logoutBtn.addEventListener("click", function() {
document.getElementById("username").value = "";
document.getElementById("password").value = "";
qaPanel.classList.add("hidden");
loginPanel.classList.remove("hidden");
answerContainer.classList.add("hidden");
uploadMessage.classList.add("hidden");
});
function showError(msg) {
loginError.innerText = msg;
loginError.classList.remove("hidden");
}
</script>
</body>
</html>
8. 常見問題與 AI 互動排錯
🙋 情境 A:修改了程式碼,重新整理網頁卻沒有任何變化?
原理解析:GAS 的正式部署網址是版本鎖定的,重新整理舊網址無法看到更新。
💬 對話指令:「我改了 doGet 的 HTML 字串,但重新整理 Web App 網址卻沒有更新,要怎麼處理?」
AI 會指導您:(1) 開發時使用「測試部署 (Test deployments)」網址,可以即時預覽;(2) 正式發布時,至「管理部署」編輯並選擇「新增版本」進行部署。
🙋 情境 B:其他同仁點開網址,畫面顯示「需要授權」或「無法存取」?
原理解析:部署時的「誰有權限存取 (Who has access)」設定有誤,未開放給外部或他人。
💬 對話指令:「我部署的 GAS Web App 網址給同仁點開,提示需要登入或無法存取,請問要在哪裡修改部署參數?」
AI 會指導您:前往「管理部署」編輯該部署,將 Who has access 修改為 「所有人」(在英文介面是 Anyone)並重新部署。
🙋 情境 C:貼上代碼儲存時提示 語法錯誤:SyntaxError: Invalid or unexpected token
原理解析:在 JavaScript 中,若使用普通雙引號 " 定義字串,該字串不可在編輯器中直接換行(斷行),否則會導致編譯錯誤。
💬 對話指令:「我貼上 doGet 代碼時提示 SyntaxError: Invalid or unexpected token,要怎麼修復?」
AI 會指導您:確保雙引號內的字串完全維持在同一行閉合,或改用反引號 `(Template Literals,支援直接折行)。