Q

一小時做一個
課堂小測驗系統

用 Google 試算表做資料庫,用 Gemini 寫程式,做一個學生打開連結就用得的網上測驗。不需要任何程式底子。

教師工作坊 · 跟着以下六個步驟做,完成後你會有一條屬於自己的網址。

你完成之後會有甚麼?一條網址。學生打開,輸入姓名,答題,按交卷即時見到分數同正確答案;而全班的成績會自動回到你那份 Google 試算表裏。題目由你自己在試算表輸入,改題目不需要改程式。

先理解一件事:三件套

今日做的東西,其實只有三個部分。明白了這三樣,之後你想做甚麼工具都是同一個道理。

部分它是甚麼負責甚麼
Google 試算表資料庫存放題目、答案、成績
Code.gs後端程式去試算表拿題目、把成績寫回試算表
index.html前端網頁學生眼睛見到的畫面

今日我們不會逐行寫程式。程式由 Gemini 寫,我們負責講清楚要求,然後把它貼到正確的地方。

步驟一 · 複製題庫試算表

1
先有資料,才有程式
大約 2 分鐘

按下面的按鈕,會直接彈出「建立副本」的視窗,按確定,副本就會存到你自己的 Google 雲端硬碟。之後你一直用這份副本,不要用原本那份。

複製之後請看一眼:試算表左下角的分頁名稱必須是「題庫」兩個字。程式靠這個名字去找你的資料,改了名就會找不到。

步驟二 · 輸入你自己的題目

2
先用加減乘除試水溫
大約 5 分鐘

範本已經有兩條數學題示範格式。請你在下面加多三條,任何科目都可以,但今日建議先用加減乘除——因為我們現在要練的是流程,不是出題。

填甚麼例子
A · 題目題目全文7 + 5 = ?
B–E · 選項A 至 選項D四個選項10 / 11 / 12 / 13
F · 正確答案只填一個英文字母 A、B、C 或 DC
G · 題解答錯時給學生看的解釋,可留空7 加 5 等於 12。

最常見的錯誤:F 欄填了完整的選項內容(例如填「12」)。F 欄只可以填 ABCD 其中一個字母。

步驟三 · 用 Gemini 生成程式碼

3
把要求講清楚,AI 就寫得準
大約 8 分鐘
  1. 開啟 gemini.google.com,登入你的 Google 帳戶。
  2. 在輸入框開啟 Canvas 功能(Canvas 會把生成的程式碼獨立顯示,方便複製)。
  3. 複製下面整段提示詞,貼上去,然後送出。
我要用 Google Apps Script 製作一個簡單的網上測驗系統,請幫我寫程式碼。

【資料來源】
我有一個 Google 試算表,裡面有一個工作表,名稱是「題庫」。
第一行是標題行,由 A 欄至 G 欄依次為:
A 欄:題目
B 欄:選項A
C 欄:選項B
D 欄:選項C
E 欄:選項D
F 欄:正確答案(只會填 A、B、C 或 D 其中一個英文字母)
G 欄:題解(可能留空)
第二行開始是題目資料,數量不固定。

【功能要求】
1. 學生打開網頁後,先輸入姓名,然後按「開始測驗」。
2. 系統從「題庫」工作表讀取全部題目,每題以單選按鈕顯示四個選項。
3. 學生按「交卷」後:
   - 即時批改,答對的題目顯示綠色,答錯的顯示紅色;
   - 答錯的題目要顯示正確答案,如果 G 欄有題解就一併顯示;
   - 在頁面底部顯示總分,例如「3 / 5」。
4. 交卷後,把成績寫入同一個試算表中名為「成績記錄」的工作表,
   欄位依次為:時間、姓名、分數、總題數。
   如果「成績記錄」工作表不存在,請自動建立並加上標題行。

【技術要求】
- 請給我兩個檔案:Code.gs(後端)和 index.html(前端)。
- 前端使用 google.script.run 呼叫後端函式。
- 不要使用任何外部程式庫或 CDN,所有 CSS 和 JavaScript 都寫在 index.html 之內。
- 介面簡潔清爽,適合中學生使用,全部文字使用繁體中文。
- 請在程式碼中加入中文註解,方便我理解每一段的作用。

這一段就是今日最值得學的一課。留意提示詞裏面,我們把工作表叫甚麼名、每一欄放甚麼,全部寫得清清楚楚。要 AI 幫你寫程式,你必須先講清楚你的資料是怎樣擺放的——你日後做任何工具,這一步都一樣。

Gemini 應該會給你兩段程式碼:一段是 Code.gs,一段是 index.html。等一等它生成完,先不要複製。

步驟四 · 貼進 Apps Script

4
把程式碼放到正確的檔案
大約 8 分鐘
  1. 回到你那份題庫試算表,選單揀「擴充功能 → Apps Script」,會開一個新分頁。
  2. 左邊會見到一個檔案叫 程式碼.gs(或 Code.gs)。把裏面原有的內容全部刪除,貼上 Gemini 給你的 Code.gs
  3. 按左邊「檔案」旁邊的 ,揀 HTML,檔案名輸入 index不要打 .html,系統會自動加)。
  4. 把新檔案裏面原有的內容全部刪除,貼上 Gemini 給你的 index.html
  5. 按上方的 儲存 圖示(或 Ctrl + S / ⌘ + S)。

HTML 檔案的名一定要是 index如果打錯,網頁打開會是一片空白。這是全場最常見的問題。

Gemini 生成的版本試極都不成功?先舉手,真的來不及就用頁尾的後備程式碼頂上,不要卡在這一步。

步驟五 · 部署成網頁

5
這一步做完,就有網址了
大約 10 分鐘
  1. 在 Apps Script 右上角按 部署 → 新增部署作業
  2. 按齒輪圖示「選取類型」,揀 網頁應用程式
  3. 兩個重要設定:
    • 執行身分:揀「」——這樣學生不需要有你試算表的權限。
    • 誰可以存取:揀「任何人」——這樣學生不用登入 Google 都用得。
  4. 部署
  5. 第一次部署會要求授權:揀你的 Google 帳戶 → 按「進階」→ 按「前往(不安全)」→ 按「允許」。
  6. 完成後會給你一條「網頁應用程式網址」。這條就是給學生的連結。

「前往(不安全)」是正常的,不用怕。因為這個程式是你自己剛剛寫的,未經 Google 審核,所以會出這個提示。你授權的對象是你自己的程式。

之後改了程式碼?要再做一次「部署 → 管理部署作業 → 編輯(鉛筆圖示)→ 版本揀『新版本』→ 部署」,網址不變。單純儲存是不會更新網頁的。

步驟六 · 試用,然後互相交換

6
確認它真的在互聯網上
大約 8 分鐘
  1. 用手機打開你那條網址,輸入姓名,做一次測驗。
  2. 回到試算表,你會見到多了一個分頁叫「成績記錄」,裏面有剛才那一筆成績。
  3. 把網址傳給旁邊的同事,互相做一次對方出的題目。

做到這一步,你已經有一個屬於自己的網上測驗系統。之後想換題目,只需要在試算表加減行數,完全不用碰程式碼。

行不通?先看這裏

你見到的情況多數是這個原因
網頁一片空白HTML 檔案不是叫 index。回 Apps Script 改檔名,然後重新部署。
「找不到工作表」之類的錯誤試算表的分頁名不是「題庫」。改回「題庫」兩個字。
題目出到,但全部答案都錯F 欄填了選項內容而不是 ABCD 字母。
改了程式碼,但網頁沒有變要重新部署(見步驟五最後一格),單純儲存不會更新。
學生說打不開部署時「誰可以存取」不是揀「任何人」。重新編輯部署設定。
沒有「成績記錄」分頁要成功交卷一次,程式才會自動建立這個分頁。
以上都不是,就是不成功下面的後備程式碼換掉兩個檔案,重新部署。

後備程式碼

如果 Gemini 生成的版本試極都不成功,可以直接用下面這一套。它跟提示詞的要求完全一樣,已經測試過可以運作。請先試自己生成的版本——今日的重點是學識怎樣叫 AI 幫你寫,這裏只是保險。

Code.gs(後端)
// ===== 課堂小測驗 · 後端 =====
// 這個檔案負責:開啟網頁、從試算表讀題目、把成績寫回試算表。

// 當有人打開網址時,顯示 index.html
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('課堂小測驗');
}

// 從「題庫」工作表讀取所有題目
function getQuestions() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('題庫');
  const data = sheet.getDataRange().getValues();
  const questions = [];

  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    if (!row[0]) continue;

    const letter = String(row[5]).toUpperCase().trim();
    const options = { A: row[1], B: row[2], C: row[3], D: row[4] };

    questions.push({
      question: row[0],
      options: [row[1], row[2], row[3], row[4]].filter(String),
      answer: options[letter],
      explanation: row[6] || ''
    });
  }
  return questions;
}

// 把學生的成績寫入「成績記錄」工作表(第一次會自動建立)
function saveResult(name, score, total) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName('成績記錄');

  if (!sheet) {
    sheet = ss.insertSheet('成績記錄');
    sheet.appendRow(['時間', '姓名', '分數', '總題數']);
  }

  sheet.appendRow([new Date(), name, score, total]);
  return '已儲存';
}
index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <base target="_top">
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet">
  <style>
    * { box-sizing: border-box; }
    body {
      margin: 0; min-height: 100vh; padding: 1.5rem;
      background: linear-gradient(160deg,#f6faff 0%,#e6f0fb 38%,#d2e4f7 72%,#c8dcf5 100%) fixed;
      color: #102c4a;
      font-family: "Noto Sans TC", -apple-system, "PingFang TC", "Microsoft JhengHei", sans-serif;
      font-size: 17px; line-height: 1.75;
    }
    .wrap { max-width: 42rem; margin: 0 auto; }
    .card {
      background: rgba(255,255,255,.82); border: 1px solid rgba(24,88,176,.16);
      border-radius: 20px; box-shadow: 0 6px 20px rgba(20,76,150,.08);
      padding: 1.8rem 1.5rem; margin-bottom: 1.2rem;
    }
    h1 { font-size: 1.5rem; font-weight: 900; letter-spacing: -.02em; margin: 0 0 .2rem; }
    .sub { color: #24628a; font-size: .95rem; margin: 0 0 1.3rem; }
    label { display: block; font-size: .9rem; font-weight: 500; color: #24628a; margin-bottom: .3rem; }
    input[type=text] {
      width: 100%; padding: .7rem .85rem; font-size: 1rem; font-family: inherit; color: #102c4a;
      background: #fff; border: 1px solid #aecdf0; border-radius: 14px; transition: all .25s;
    }
    input[type=text]:focus { outline: none; border-color: #2b7aea; box-shadow: 0 0 0 3px rgba(43,122,234,.18); }
    button {
      width: 100%; margin-top: 1rem; font-family: inherit; font-size: 1.02rem; font-weight: 700; color: #fff;
      background: linear-gradient(120deg,#13539c,#1f66e2); box-shadow: 0 4px 14px rgba(19,83,156,.3);
      border: 0; border-radius: 14px; padding: .8rem 2rem; cursor: pointer; transition: all .25s;
    }
    button:hover { filter: brightness(1.08); transform: translateY(-1px); }
    button:disabled { background: #9fb3c8; box-shadow: none; cursor: not-allowed; transform: none; }
    .q-num { font-size: .82rem; font-weight: 700; color: #2b7aea; letter-spacing: .04em; }
    .q-text { font-weight: 700; font-size: 1.05rem; margin: .2rem 0 .9rem; }
    .opt {
      display: block; padding: .7rem .9rem; margin-bottom: .55rem; cursor: pointer;
      background: #fff; border: 1px solid #d7e6f7; border-radius: 14px; transition: all .2s;
    }
    .opt:hover { border-color: #2b7aea; background: #f2f8ff; }
    .opt input { margin-right: .55rem; }
    .card.correct { border-color: #7fc39a; background: rgba(233,248,239,.9); }
    .card.wrong { border-color: #f0adb9; background: rgba(253,238,240,.9); }
    .fb { margin-top: .8rem; padding: .8rem 1rem; border-radius: 14px; font-size: .92rem; }
    .fb.ok { background: #e3f5ea; color: #1c6b41; }
    .fb.no { background: #fdecef; color: #a11d38; }
    .fb b { font-weight: 700; }
    .score-card { text-align: center; }
    .score-num { font-size: 2.6rem; font-weight: 900; letter-spacing: -.03em; margin: .3rem 0; }
    .saved { color: #24628a; font-size: .88rem; margin-top: .4rem; }
    .hidden { display: none !important; }
  </style>
</head>
<body>
<div class="wrap">

  <!-- 第一頁:輸入姓名 -->
  <div id="startCard" class="card">
    <h1>課堂小測驗</h1>
    <p class="sub">請輸入你的姓名,然後開始作答。</p>
    <label for="nameInput">姓名</label>
    <input type="text" id="nameInput" placeholder="例如:陳大文">
    <button id="startBtn" onclick="startQuiz()">開始測驗</button>
  </div>

  <!-- 第二頁:題目 -->
  <div id="quizArea" class="hidden">
    <div id="questionList"></div>
    <button id="submitBtn" onclick="submitQuiz()">交卷</button>
  </div>

  <!-- 第三頁:成績 -->
  <div id="resultCard" class="card score-card hidden">
    <p class="sub" style="margin:0">你的成績</p>
    <div class="score-num" id="scoreText"></div>
    <p class="saved" id="savedText"></p>
  </div>

</div>

<script>
  let questions = [];
  let studentName = '';

  // 按「開始測驗」:向後端拿題目
  function startQuiz() {
    const name = document.getElementById('nameInput').value.trim();
    if (!name) { alert('請先輸入姓名'); return; }
    studentName = name;

    const btn = document.getElementById('startBtn');
    btn.disabled = true;
    btn.textContent = '載入題目中…';

    google.script.run
      .withSuccessHandler(showQuestions)
      .withFailureHandler(function (err) {
        alert('載入失敗:' + err.message);
        btn.disabled = false;
        btn.textContent = '開始測驗';
      })
      .getQuestions();
  }

  // 把題目畫出來
  function showQuestions(list) {
    if (!list || list.length === 0) {
      alert('題庫沒有題目,請先在試算表輸入題目。');
      const btn = document.getElementById('startBtn');
      btn.disabled = false;
      btn.textContent = '開始測驗';
      return;
    }
    questions = list;
    document.getElementById('startCard').classList.add('hidden');
    document.getElementById('quizArea').classList.remove('hidden');

    const box = document.getElementById('questionList');
    box.innerHTML = '';

    questions.forEach(function (q, i) {
      const card = document.createElement('div');
      card.className = 'card';
      card.id = 'q' + i;

      let html = '<div class="q-num">第 ' + (i + 1) + ' 題</div>';
      html += '<div class="q-text">' + q.question + '</div>';
      q.options.forEach(function (opt) {
        html += '<label class="opt"><input type="radio" name="q' + i + '" value="' + opt + '">' + opt + '</label>';
      });
      html += '<div class="fb hidden" id="fb' + i + '"></div>';

      card.innerHTML = html;
      box.appendChild(card);
    });
  }

  // 交卷:批改、顯示答案、把成績寫回試算表
  function submitQuiz() {
    let score = 0;
    const btn = document.getElementById('submitBtn');

    questions.forEach(function (q, i) {
      const picked = document.querySelector('input[name="q' + i + '"]:checked');
      const chosen = picked ? picked.value : '';
      const card = document.getElementById('q' + i);
      const fb = document.getElementById('fb' + i);

      document.querySelectorAll('input[name="q' + i + '"]').forEach(function (r) { r.disabled = true; });

      if (chosen === q.answer) {
        score++;
        card.classList.add('correct');
        fb.className = 'fb ok';
        fb.innerHTML = '<b>答對了!</b>' + (q.explanation ? ' ' + q.explanation : '');
      } else {
        card.classList.add('wrong');
        fb.className = 'fb no';
        fb.innerHTML = '<b>正確答案:</b>' + q.answer + (q.explanation ? '<br>' + q.explanation : '');
      }
      fb.classList.remove('hidden');
    });

    btn.disabled = true;
    btn.textContent = '已交卷';

    document.getElementById('scoreText').textContent = score + ' / ' + questions.length;
    document.getElementById('resultCard').classList.remove('hidden');
    document.getElementById('resultCard').scrollIntoView({ behavior: 'smooth' });

    google.script.run
      .withSuccessHandler(function () {
        document.getElementById('savedText').textContent = '成績已記錄到試算表。';
      })
      .withFailureHandler(function () {
        document.getElementById('savedText').textContent = '成績未能儲存,請通知老師。';
      })
      .saveResult(studentName, score, questions.length);
  }
</script>
</body>
</html>

之後還可以做甚麼

同樣的三件套,加多幾張工作表、多幾個函式,就可以一路長出去:

一個要知道的限制:今日這個簡化版,正確答案會隨題目一起送到學生的瀏覽器,適合自己溫習用。如果日後要拿來做正式評估,就要改成由伺服器批改(即是答案不下發到瀏覽器),否則學生有辦法看到答案。

課堂小測驗工作坊 · 教材由 Desmond 準備。有問題隨時問,或者把你做好的網址傳給我看看。