Falo x Force Cheng 2026/6/10 | Ver 1.00 | 教學用途,商業用途請聯繫作者

Google Apps Script (GAS) 開發架構與部署實戰手冊

天心同仁 (ERP 專業開發團隊) 培訓專題

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 分頁中.
  • 優點:易於管理。即使是不懂技術的同仁,也能直接在試算表上調整設定值(如:警報通知人數、資料查詢起訖日),無需修改程式碼。
敏感憑證 (API Key、金鑰、系統密碼)
  • 做法禁止寫在程式碼中,也不宜直接曝露在 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 專案時,架構選擇會直接影響效能與安全性:

1. GAS_html (內建渲染模式)
  • 機制:在 GAS 內建編輯器中新增 .html 檔案,後端呼叫 HtmlService.createHtmlOutputFromFile() 來顯示網頁。
  • 缺點:GAS 的 Web App 網頁會被包裹在 Google 的安全沙盒 (iframe) 中,導致載入速度慢、效能較差。此外,對外整合前端套件與 Git 版本控制很不便。
2. GitHub HTML + GAS Proxy (推薦架構)
  • 機制
    • 前端 (Client):將精美的網頁發布至 GitHub Pages
    • 後端 (Proxy):GAS 部署成 Web App 作為 API Proxy。前端透過 fetch() 向 GAS 發送請求,GAS 處理試算表讀寫或寄信後再回傳。
  • 優勢:網頁載入流暢快速,可使用現代前端開發;且後端 GAS 能隱藏敏感的資料處理邏輯與金鑰,安全性更高。

✦ 5. 實戰演練一:極簡 Web App 部署步驟 (含圖文引導)

現在,我們將實作第一支最簡單的 GAS Web App,目的在於「確認部署成功」並熟悉整個部署設定與 Google 的授權審核流程。

🔗 此案例線上正式 Demo 網址:

同仁可先點擊右側連結,查看本實戰演練部署完成後的實際運行效果與網頁呈現。

前往測試網頁
1
Step 01. 建立 Google 試算表
請進入您雲端硬碟的共用專案目錄(例如:class3 > [study-gas]),在空白區點按右鍵,選擇 「Google 試算表」 建立新檔案。
建立 Google 試算表
2
Step 02. 處理共用資料夾建立提示
因為該目錄為共用資料夾,系統會跳出確認警告視窗,提示本試算表將沿用共用權限。請點選 「建立並共用」
共用資料夾建立提示
3
Step 03. 進入 Apps Script 編輯器
將新建好的試算表命名(例如:gas-study-v1),接著在頂部選單點選 「擴充功能」 > 「Apps Script」
點選 Apps Script
4
Step 04. 貼入極穩健之 Web App 程式碼
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);
}
貼上後的畫面如下,確保左側檔案名稱旁的橘色圓點亮起,表示代碼已編輯但尚未儲存,且無任何語法錯誤:
程式碼貼上成功無語法錯誤
💡 教學重點提示: 本程式碼採取最穩健的設計:(1) **無任何 Emoji 等特殊符號**,避免編碼與語法解析的潛在問題;(2) **宣告變數 html**,將輸出字串儲存為變數,以縮短單行寬度,防止同仁在複製貼上時,因代碼寬度自動折行而產生 JavaScript 語法錯誤;(3) **保留 SpreadsheetApp 呼叫**,強制 Google 在部署時跳出安全授權視窗,以提供同仁完整的權限授權實作體驗。
5
Step 05. 點擊新增部署作業
在 Apps Script 編輯器右上角的藍色選單中,點選 「部署」 按鈕,並在下拉選單中選擇 「新增部署作業」
點擊新增部署作業
6
Step 06. 選取部署類型
在彈出的視窗中,點擊左上角「選取類型」旁的 齒輪圖標,並在類型清單中選擇 「網頁應用程式」
選取網頁應用程式類型
7
Step 07. 配置部署參數與權限
配置部署設定,預設的「誰可以存取」是「只有我自己」:
預設誰可以存取只有我自己
為了讓外部對接口或同仁順利開啟,必須點開選單,將「誰可以存取」修改為 「所有人」,隨後點選右下角 「部署」 按鈕:
修改存取權限為所有人
8
Step 08. 安全授權流程 (首次部署必經)
設定完成並點擊「部署」後,由於我們的程式碼包含了對 SpreadsheetApp(試算表)的存取,Google 就會偵測到敏感權限要求,並強制跳出安全授權視窗。請依序完成以下放行步驟:
  1. 點選 「授予存取權限 (Authorize Access)」
    點選授予存取權限
  2. 選擇您的 Google 帳號
  3. 畫面出現紅色警告「Google hasn't verified this app」。請點擊左下角的 「進階 (Advanced)」
    Google尚未驗證警告
  4. 點選下方小字的 「前往『未命名專案』(Go to 未命名的專案 (unsafe))」
    確認前往未命名專案
  5. 在接下來的權限確認畫面中,確認權限範圍(查看、編輯、建立和刪除您在 Google 試算表中的所有試算表),點選右下角的 「Continue (繼續)」 完成授權放行:
    Google Sheets權限要求
    允許授權確認
9
Step 09. 取得部署成功網址
部署與授權放行作業完成後,系統會提示「已成功更新部署作業」,並生成專屬的 「網頁應用程式網址」。請點擊網址下方的 **「複製」**:
部署成功複製網址
10
Step 10. 無痕測試驗證
  1. 開啟瀏覽器的 「無痕視窗」
  2. 貼上剛才複製的網頁應用程式網址並按下 Enter。
  3. 若網頁成功顯示下列字樣,即代表您第一版的極簡部署作業完全成功:
    Deployment success. This is Bank Software Taixi Branch. Current sheet: 工作表1
    無痕測試驗證成功

⚠️ 🔥 重要避坑指南:如何正確儲存與更新網頁應用程式 (維持相同網址)

在開發與維護 Web App 時,很多同仁最常遇到的問題是:「修改了 程式碼.gs,但為什麼重新整理網頁後沒看見更新?」或是「每次更新程式,網址就變了,導致外部系統或前端網頁都要重新設定網址。」

這通常是因為以下兩個關鍵動作沒有正確執行:

避坑步驟一:程式碼必須「儲存變更」

在 Apps Script 中,如果程式碼檔案名稱旁邊亮起 橘色圓點,代表有修改但尚未存檔。如果此時直接去部署,運行的將會是舊版程式碼。請務必使用 Ctrl + S 存檔,或點選編輯器上方的「儲存」圖示,確保橘色圓點消失。

橘色圓點與部署選單

避坑步驟二:必須透過「管理部署」建立新版本,而非「新增部署」

當我們要更新已發布的 Web App 且維持原本的網頁 URL 網址不變時,絕對不要再次點選「新增部署作業」(這會產生一個全新 ID 的網址),而應依循以下步驟:

  1. 點選右上角 「部署」 > 「管理部署作業」。在彈出的管理視窗中,點擊右上角的 「編輯 (鉛筆圖示)」
    點選編輯鉛筆圖示
  2. 點開「版本」下拉選單,選擇 「建立新版本」
    下拉選取建立新版本
  3. 選定後,點選右下角的 「部署」 按鈕完成更新,如此一來即可完美維持原本的 Web App 網址:
    選取建立新版本後點選部署
  4. 成功更新後,系統會顯示「已成功更新部署作業」,此時其網網頁應用程式網址依然維持完全相同:
    部署更新成功完成畫面

✦ 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 進行前後端非同步通訊,可避開所有跨網域限制。

6.2 雙表資料初始化腳本 (setup.gs) 的建立與執行步驟

不論採用做法 A 還是做法 B,我們都需要先建立並初始化 PasswordsQA 資料表。請遵循以下步驟操作:

步驟 1:建立 setup.gs 檔案

在 Apps Script 編輯器左側的「檔案」旁,點選 「+」 按鈕,並在下拉選單中選擇 「指令碼」

新增指令碼檔案

將新建的檔案命名為 setup (系統會自動生成 setup.gs):

命名為setup

步驟 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 試算表,您會看見新增了 PasswordsQA 兩個分頁,並已自動填入正規化的初始預設資料:

📊 Passwords 工作表實例:
Passwords工作表
📊 QA 工作表實例:
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 的程式碼後,請依照以下步驟將其發布為網頁應用程式:

1
Step 01. 貼入程式碼並確實儲存
程式碼.gs 原本的代碼清空,貼上做法 A 的完整程式碼,並按下 Ctrl + S 儲存。確保左側檔案名稱旁的橘色圓點消失。
貼入程式碼並存檔
2
Step 02. 點選「新增部署作業」
在右上角點擊 「部署」 按鈕,並在下拉選單中選擇 「新增部署作業」
點選新增部署作業
3
Step 03. 設定部署類型為「網頁應用程式」
點選左上角「選取類型」旁的齒輪圖示,在選單中選取 「網頁應用程式」
選取類型為網頁應用程式
4
Step 04. 將存取權限設定為「所有人」
將「誰可以存取」修改為 「所有人」,以便外部帳戶與 API 請求能順利存取此應用程式,隨後點選下方的 「部署」 按鈕。
設定存取權限為所有人
5
Step 05. 部署成功並取得網址
系統完成部署後,會顯示「已成功更新部署作業」。請點選網址下方的 「複製」 按鈕取得網頁應用程式 URL。
取得網頁應用程式網址

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 部署:

1
Step 01. 建立並撰寫 setup.gs 程式碼
在 Apps Script 專案中新增 setup.gs 指令碼檔案,並將上方的初始化程式碼貼入並儲存。
建立 setup.gs 程式碼
2
Step 02. 執行 setup 初始化資料庫
在上方工具列選定 setup 函數,點擊 「執行」 按鈕。確保執行記錄成功完成,表示試算表分頁已自動建立。
執行初始化腳本
3
Step 03. 點選「新增部署作業」
點擊右上角 「部署」 按鈕,選擇 「新增部署作業」。選取類型為「網頁應用程式」,並將存取權限設為「所有人」,隨後發布。
點選新增部署作業
4
Step 04. 在外部網頁中配置 GAS_URL
在本地電腦建立 index.html 檔案,將部署獲得的新 V3 URL 複製並替換寫入 GAS_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,支援直接折行)。