0. 作品介紹

作者 smj*** 三民國中

適合國三學生自我檢測國中英語時態。畫面操作簡單明瞭,題庫網羅歷屆魔王時態題,人生不要只會按選擇題,有加入改寫題,等你來試玩!


40 1 0
作業
封面圖 封面

2. 資料表規則

請照下面規則建立分頁與欄位。

第1分頁: 首頁總覽說明 A 欄: 總覽 B 欄: 公式

📄 解析結果(自動表格)

分頁:首頁總覽說明
A 欄 B 欄
總覽 公式

3. 開啟 Apps Script

從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。

  1. 試算表上方選單:擴充功能 → Apps Script
  2. 刪掉預設的程式碼(或全部選取覆蓋)
  3. 保留/建立檔案:
    • Code.gs
    • index.html

4. 貼上後端(Code.gs)

把後端程式貼到 Apps Script 的 Code.gs。

Code.gs(後端)
/**
 * ============================================================
 * English 常見時態練習系統 - 後端程式碼 (Code.gs)
 * ============================================================
 * 本檔案為系統唯一的 .gs 檔案,所有函式集中於此,
 * 避免多檔案間函式同名互相覆蓋的問題。
 * ============================================================
 */

// ------------------------------------------------------------
// 一、資料表結構設定(新增欄位時,只需要在這裡的 headers 陣列加一個名稱,
//     再執行選單「⬆️ 升級資料表結構」即可,程式會自動補到最後一欄;
//     系統設定的新設定項目也是同樣道理,加在 sample 裡,執行升級選單就會補上)
// ------------------------------------------------------------

var SHEET_CONFIG = {
  '系統設定': {
    headers: ['設定項目', '設定值', '說明'],
    sample: [
      ['網站標題', 'English 常見時態練習', '顯示在學生測驗頁面最上方的標題文字,可自行修改'],
      ['每次測驗題數', '10', '學生每次進入測驗頁面時,系統隨機抽取的題目數量,可自行修改'],
      ['提交後是否顯示正確答案', '是', '填「是」或「否」,決定學生作答後是否立即看到正解'],
      ['管理員信箱', 'teacher@example.com', '系統管理者信箱,僅供記錄與未來擴充使用'],
      ['排行榜最低正確題數', '20', '學生累積「答對」的題數達到這個數字以上,才會被列入排行榜計算,可自行修改'],
      ['排行榜顯示筆數', '10', '排行榜最多顯示前幾名,可自行調整']
    ]
  },
  '時態說明': {
    headers: ['時態名稱', '形式', '用法說明', '常見時間字', '疑問句公式'],
    sample: [
      ['簡單現在式', '原形動詞(+s/es)', '表示習慣、事實、恆常真理', 'every day, usually, always', 'Do/Does + 主詞 + 原形動詞'],
      ['現在進行式', 'am/is/are + V-ing', '表示現在正在發生的動作', 'now, right now, at the moment', 'Am/Is/Are + 主詞 + V-ing'],
      ['現在完成式', 'have/has + 過去分詞', '表示過去到現在的經驗、持續或剛完成的動作', 'ever, never, already, yet, since, for', 'Have/Has + 主詞 + 過去分詞']
    ]
  },
  '題庫': {
    headers: ['題目ID', '題型', '中文提示', '題目內容', '選項A', '選項B', '選項C', '正確答案', '對應時態', '難度', '說明'],
    sample: [
      ['Q001', '選擇題', '看!那隻狗正在追貓。', 'Look! The dog ___ after the cat.', 'runs', 'is running', 'has run', 'is running', '現在進行式', '易', '動作正在眼前發生'],
      ['Q002', '選擇題', '我每天早上七點吃早餐。', 'I ___ breakfast every morning at 7 a.m.', 'eat', 'am eating', 'have eaten', 'eat', '簡單現在式', '易', '描述習慣,搭配 every morning'],
      ['Q003', '選擇題', '我們以前從來沒去過那間博物館。', 'We ___ that museum before.', 'never visit', 'are never visiting', 'have never visited', 'have never visited', '現在完成式', '中', '經驗用法,搭配 never...before'],
      ['Q004', '疑問句改寫', '她每天走路上學。(改成疑問句)', 'She walks to school every day.', '', '', '', 'Does she walk to school every day?', '簡單現在式', '易', 'Do/Does + 主詞 + 原形動詞'],
      ['Q005', '疑問句改寫', '他們現在正在看電影。(改成疑問句)', 'They are watching a movie right now.', '', '', '', 'Are they watching a movie right now?', '現在進行式', '中', 'Am/Is/Are + 主詞 + V-ing'],
      ['Q006', '疑問句改寫', '他已經完成他的作業了。(改成疑問句)', 'He has finished his homework.', '', '', '', 'Has he finished his homework?', '現在完成式', '中', 'Have/Has + 主詞 + 過去分詞'],
      ['Q007', '時態分類', '請判斷這句話使用的時態。', 'We usually have dinner at six.', '簡單現在式', '現在進行式', '現在完成式', '簡單現在式', '', '易', 'usually 是簡單現在式的常見信號詞'],
      ['Q008', '時態分類', '請判斷這句話使用的時態。', 'She has already left the office.', '簡單現在式', '現在進行式', '現在完成式', '現在完成式', '', '中', 'already 常搭配現在完成式'],
      ['Q009', '時態分類', '請判斷這句話使用的時態。', 'He is reading an interesting book right now.', '簡單現在式', '現在進行式', '現在完成式', '現在進行式', '', '易', 'right now 是現在進行式的常見信號詞']
    ]
  },
  '作答紀錄': {
    headers: ['紀錄ID', '學生姓名或代號', '題目ID', '題型', '作答內容', '正確答案', '是否正確', '作答時間'],
    sample: [
      ['R0001', '(範例)王小明', 'Q001', '選擇題', 'is running', 'is running', 'TRUE', '2026/07/22 10:00:00']
    ]
  }
};

// ------------------------------------------------------------
// 二、自訂選單
// ------------------------------------------------------------

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('🎓 時態練習系統')
    .addItem('📋 初始化工作表', 'menuInitializeSheets')
    .addItem('⬆️ 升級資料表結構(補新欄位)', 'menuUpgradeSheetStructure')
    .addToUi();
}

function menuInitializeSheets() {
  initializeSheets();
  SpreadsheetApp.getUi().alert('初始化完成!已建立所有必要的工作表(已存在的工作表不會被覆蓋)。');
}

function menuUpgradeSheetStructure() {
  var result = upgradeSheetStructure();
  SpreadsheetApp.getUi().alert('升級完成!\n' + result);
}

// ------------------------------------------------------------
// 三、初始化 / 升級邏輯
// ------------------------------------------------------------

function initializeSheets() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();

  // 一般表格型工作表
  for (var sheetName in SHEET_CONFIG) {
    var config = SHEET_CONFIG[sheetName];
    var sheet = ss.getSheetByName(sheetName);
    if (sheet) {
      // 已存在,不重建、不覆蓋
      continue;
    }
    sheet = ss.insertSheet(sheetName);
    sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
    sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold').setBackground('#1F4E79').setFontColor('#FFFFFF');
    if (config.sample && config.sample.length > 0) {
      sheet.getRange(2, 1, config.sample.length, config.headers.length).setValues(config.sample);
    }
    sheet.setFrozenRows(1);
    sheet.autoResizeColumns(1, config.headers.length);
  }

  // 首頁儀表板(特殊處理,因為含公式,不是表格資料)
  var homeSheet = ss.getSheetByName('首頁');
  if (!homeSheet) {
    homeSheet = ss.insertSheet('首頁');
    homeSheet.getRange('A1').setValue('English 三大常見時態練習系統 — 總覽').setFontWeight('bold').setFontSize(16);

    homeSheet.getRange('A3').setValue('題庫總題數:');
    homeSheet.getRange('B3').setFormula('=COUNTA(題庫!A2:A1000)-COUNTBLANK(題庫!A2:A1000)');

    homeSheet.getRange('A4').setValue('累計作答次數:');
    homeSheet.getRange('B4').setFormula('=COUNTA(作答紀錄!A2:A1000)-COUNTBLANK(作答紀錄!A2:A1000)');

    homeSheet.getRange('A5').setValue('最近更新時間:');
    homeSheet.getRange('B5').setFormula('=NOW()');

    homeSheet.getRange('A7').setValue('使用說明:').setFontWeight('bold');
    homeSheet.getRange('A8').setValue('1. 「題庫」分頁可直接新增/修改題目,題目ID請維持唯一值');
    homeSheet.getRange('A9').setValue('2. 「系統設定」分頁可調整測驗題數、標題等設定');
    homeSheet.getRange('A10').setValue('3. 「作答紀錄」會自動記錄學生作答,僅您(試算表擁有者)看得到');
    homeSheet.getRange('A11').setValue('4. 學生測驗網址請見部署後取得的網頁應用程式網址');

    homeSheet.autoResizeColumns(1, 2);
  }

  // 把首頁移到最前面
  ss.setActiveSheet(homeSheet);
  ss.moveActiveSheet(1);
}

function upgradeSheetStructure() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var log = [];

  for (var sheetName in SHEET_CONFIG) {
    var config = SHEET_CONFIG[sheetName];
    var sheet = ss.getSheetByName(sheetName);

    if (!sheet) {
      // 工作表完全不存在,直接建立(含表頭與範例資料)
      sheet = ss.insertSheet(sheetName);
      sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
      sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold').setBackground('#1F4E79').setFontColor('#FFFFFF');
      if (config.sample && config.sample.length > 0) {
        sheet.getRange(2, 1, config.sample.length, config.headers.length).setValues(config.sample);
      }
      sheet.setFrozenRows(1);
      log.push(sheetName + ':工作表不存在,已建立');
      continue;
    }

    var lastCol = Math.max(sheet.getLastColumn(), 1);
    var existingHeaders = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
    var missing = [];

    config.headers.forEach(function (headerName) {
      if (existingHeaders.indexOf(headerName) === -1) {
        missing.push(headerName);
      }
    });

    if (missing.length > 0) {
      var startCol = lastCol + 1;
      sheet.getRange(1, startCol, 1, missing.length).setValues([missing]);
      sheet.getRange(1, startCol, 1, missing.length).setFontWeight('bold').setBackground('#1F4E79').setFontColor('#FFFFFF');
      log.push(sheetName + ':新增欄位 ' + missing.join('、'));
    } else {
      log.push(sheetName + ':欄位已是最新,無需變更');
    }
  }

  // 系統設定是「用列」存資料,不是用欄,所以另外處理:
  // 找不到該「設定項目」名稱時,才在最後面補一列,不會動到既有的設定值
  log.push(ensureSettingRows_());

  return log.join('\n');
}

function ensureSettingRows_() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('系統設定');
  if (!sheet) return '系統設定:工作表不存在,略過設定項目檢查';

  var lastRow = sheet.getLastRow();
  var lastCol = Math.max(sheet.getLastColumn(), 1);
  var existingKeys = [];
  if (lastRow >= 2) {
    var data = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
    existingKeys = data.map(function (row) { return row[0]; });
  }

  var defaults = SHEET_CONFIG['系統設定'].sample;
  var toAdd = defaults.filter(function (row) {
    return existingKeys.indexOf(row[0]) === -1;
  });

  if (toAdd.length > 0) {
    sheet.getRange(sheet.getLastRow() + 1, 1, toAdd.length, toAdd[0].length).setValues(toAdd);
    return '系統設定:新增設定項目 ' + toAdd.map(function (r) { return r[0]; }).join('、');
  }
  return '系統設定:設定項目已是最新,無需變更';
}

// ------------------------------------------------------------
// 四、共用工具函式(依欄位標題名稱定位欄位,不寫死欄位編號)
// ------------------------------------------------------------

function getSheetSafe_(sheetName) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName(sheetName);
  if (!sheet) {
    throw new Error('找不到工作表:' + sheetName + ',請先執行選單「📋 初始化工作表」');
  }
  return sheet;
}

// 將整張表讀成物件陣列,每個物件的 key 是表頭名稱
function readSheetAsObjects_(sheetName) {
  var sheet = getSheetSafe_(sheetName);
  var lastRow = sheet.getLastRow();
  var lastCol = sheet.getLastColumn();
  if (lastRow < 2) return [];

  var values = sheet.getRange(1, 1, lastRow, lastCol).getValues();
  var headers = values[0];
  var rows = [];

  for (var i = 1; i < values.length; i++) {
    try {
      var rowArr = values[i];
      // 跳過整列空白的資料
      var isEmpty = rowArr.every(function (v) { return v === '' || v === null; });
      if (isEmpty) continue;

      var obj = {};
      for (var c = 0; c < headers.length; c++) {
        var headerName = headers[c];
        if (!headerName) continue;
        var cellValue = rowArr[c];
        obj[headerName] = normalizeCellValue_(cellValue);
      }
      obj._rowIndex = i + 1; // 實際試算表列號,供需要時使用
      rows.push(obj);
    } catch (rowErr) {
      // 單一列壞資料只略過,不讓整頁失敗
      continue;
    }
  }
  return rows;
}

// 日期轉字串、壞值轉空字串
function normalizeCellValue_(value) {
  try {
    if (Object.prototype.toString.call(value) === '[object Date]') {
      return Utilities.formatDate(value, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');
    }
    if (value === null || value === undefined) return '';
    return value;
  } catch (e) {
    return '';
  }
}

function getSettingValue_(key, defaultValue) {
  try {
    var settings = readSheetAsObjects_('系統設定');
    for (var i = 0; i < settings.length; i++) {
      if (settings[i]['設定項目'] === key) {
        return settings[i]['設定值'];
      }
    }
  } catch (e) {
    // 讀不到就用預設值
  }
  return defaultValue;
}

function normalizeAnswerText_(text) {
  if (!text) return '';
  return String(text)
    .trim()
    .toLowerCase()
    .replace(/[??.!!,,]/g, '')
    .replace(/\s+/g, ' ');
}

function appendRowByHeaders_(sheetName, rowObject) {
  var sheet = getSheetSafe_(sheetName);
  var lastCol = Math.max(sheet.getLastColumn(), 1);
  var headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
  var rowArr = headers.map(function (h) {
    return rowObject.hasOwnProperty(h) ? rowObject[h] : '';
  });
  sheet.appendRow(rowArr);
}

// 姓名遮罩:只顯示前面 1~2 個字,其餘用 *** 取代
function maskName_(name) {
  if (!name) return '匿名';
  var str = String(name);
  if (str.length <= 1) return str + '***';
  var visibleLen = Math.min(2, Math.ceil(str.length / 2));
  return str.substring(0, visibleLen) + '***';
}

// ------------------------------------------------------------
// 五、網頁應用程式進入點
// ------------------------------------------------------------

function doGet(e) {
  var title = getSettingValue_('網站標題', 'English 三大常見時態練習');
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle(title)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

// ------------------------------------------------------------
// 六、提供給前端呼叫的函式(google.script.run)
// ------------------------------------------------------------

// 取得測驗題目(不包含正確答案,避免洩漏給前端)
function getQuizQuestions() {
  try {
    var allQuestions = readSheetAsObjects_('題庫');
    var countSetting = parseInt(getSettingValue_('每次測驗題數', '10'), 10);
    if (isNaN(countSetting) || countSetting <= 0) countSetting = 10;

    // 洗牌
    for (var i = allQuestions.length - 1; i > 0; i--) {
      var j = Math.floor(Math.random() * (i + 1));
      var tmp = allQuestions[i];
      allQuestions[i] = allQuestions[j];
      allQuestions[j] = tmp;
    }

    var picked = allQuestions.slice(0, Math.min(countSetting, allQuestions.length));

    var siteTitle = getSettingValue_('網站標題', 'English 三大常見時態練習');

    var safeList = picked.map(function (q) {
      return {
        id: q['題目ID'],
        type: q['題型'],
        prompt: q['中文提示'],
        content: q['題目內容'],
        optionA: q['選項A'],
        optionB: q['選項B'],
        optionC: q['選項C']
      };
    });

    return { success: true, title: siteTitle, questions: safeList };
  } catch (err) {
    return { success: false, error: err.message };
  }
}

// 提交單題作答,回傳是否正確(並依系統設定決定是否附上正解)
function submitAnswer(studentName, questionId, studentAnswer) {
  try {
    var allQuestions = readSheetAsObjects_('題庫');
    var matched = null;
    for (var i = 0; i < allQuestions.length; i++) {
      if (allQuestions[i]['題目ID'] === questionId) {
        matched = allQuestions[i];
        break;
      }
    }
    if (!matched) {
      return { success: false, error: '找不到對應題目' };
    }

    var correctAnswer = matched['正確答案'];
    var isCorrect = normalizeAnswerText_(studentAnswer) === normalizeAnswerText_(correctAnswer);
    var now = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');
    var recordId = 'R' + new Date().getTime() + '_' + Math.floor(Math.random() * 1000);

    appendRowByHeaders_('作答紀錄', {
      '紀錄ID': recordId,
      '學生姓名或代號': studentName || '匿名',
      '題目ID': questionId,
      '題型': matched['題型'],
      '作答內容': studentAnswer,
      '正確答案': correctAnswer,
      '是否正確': isCorrect,
      '作答時間': now
    });

    var showAnswer = getSettingValue_('提交後是否顯示正確答案', '是') === '是';

    return {
      success: true,
      isCorrect: isCorrect,
      correctAnswer: showAnswer ? correctAnswer : null,
      explanation: showAnswer ? matched['說明'] : null
    };
  } catch (err) {
    return { success: false, error: err.message };
  }
}

// 取得排行榜:依「累積正確率」排序,姓名遮罩顯示,需累積「答對題數」達門檻才列入
function getLeaderboard() {
  try {
    var records = readSheetAsObjects_('作答紀錄');
    var statsByName = {};

    records.forEach(function (r) {
      var name = r['學生姓名或代號'] || '匿名';
      if (!statsByName[name]) statsByName[name] = { total: 0, correct: 0 };
      statsByName[name].total++;

      var correctVal = r['是否正確'];
      var isCorrect = (correctVal === true || correctVal === 'TRUE' || correctVal === 'true');
      if (isCorrect) statsByName[name].correct++;
    });

    // 上榜門檻改為「累積答對題數」,例如答對題數要滿 20 題才會被列入排行榜
    var minCorrect = parseInt(getSettingValue_('排行榜最低正確題數', '20'), 10);
    if (isNaN(minCorrect) || minCorrect < 0) minCorrect = 20;

    var topN = parseInt(getSettingValue_('排行榜顯示筆數', '10'), 10);
    if (isNaN(topN) || topN <= 0) topN = 10;

    var list = [];
    for (var name in statsByName) {
      var s = statsByName[name];
      if (s.correct < minCorrect) continue; // 答對題數未達門檻,不列入排行榜

      var accuracy = s.total > 0 ? (s.correct / s.total) * 100 : 0;
      list.push({
        name: maskName_(name),
        accuracy: Math.round(accuracy * 10) / 10,
        correctCount: s.correct,
        totalAttempts: s.total
      });
    }

    // 名次仍依「累積正確率」高到低排序
    list.sort(function (a, b) { return b.accuracy - a.accuracy; });
    var top = list.slice(0, topN);

    return { success: true, minCorrect: minCorrect, leaderboard: top };
  } catch (err) {
    return { success: false, error: err.message };
  }
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

把前端程式貼到 Apps Script 的 index.html。

index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    * { box-sizing: border-box; }
    body {
      font-family: "Microsoft JhengHei", "Helvetica Neue", Arial, sans-serif;
      background: #F4F6F8;
      margin: 0;
      padding: 20px;
      color: #222;
    }
    .container {
      max-width: 640px;
      margin: 0 auto;
    }
    h1 {
      font-size: 22px;
      color: #1F4E79;
      text-align: center;
      margin-bottom: 6px;
    }
    .subtitle {
      text-align: center;
      color: #666;
      font-size: 14px;
      margin-bottom: 24px;
    }
    .card {
      background: #fff;
      border-radius: 10px;
      padding: 20px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.08);
      margin-bottom: 16px;
    }
    input[type="text"] {
      width: 100%;
      padding: 10px 12px;
      border: 1px solid #ccc;
      border-radius: 6px;
      font-size: 15px;
      margin-top: 6px;
    }
    button {
      background: #1F4E79;
      color: #fff;
      border: none;
      padding: 10px 20px;
      border-radius: 6px;
      font-size: 15px;
      cursor: pointer;
      margin-top: 12px;
      margin-right: 8px;
    }
    button:hover { background: #163a5c; }
    button:disabled { background: #999; cursor: not-allowed; }
    .option-btn {
      display: block;
      width: 100%;
      text-align: left;
      background: #F4F6F8;
      color: #222;
      border: 1px solid #ccc;
      margin: 6px 0;
      padding: 10px 12px;
    }
    .option-btn:hover { background: #E8EEF4; }
    .option-btn.selected {
      background: #1F4E79;
      color: #fff;
      border-color: #1F4E79;
    }
    .prompt {
      color: #555;
      font-size: 14px;
      margin-bottom: 4px;
    }
    .content {
      font-size: 18px;
      font-weight: bold;
      margin-bottom: 14px;
    }
    .progress {
      font-size: 13px;
      color: #888;
      margin-bottom: 10px;
    }
    .feedback {
      margin-top: 12px;
      padding: 10px 12px;
      border-radius: 6px;
      font-size: 14px;
    }
    .feedback.correct { background: #E4F5E9; color: #1E7A34; }
    .feedback.incorrect { background: #FBE9E9; color: #B23434; }
    .error-box {
      text-align: center;
      padding: 40px 20px;
    }
    .error-box p { color: #B23434; margin-bottom: 16px; }
    .spinner {
      border: 4px solid #eee;
      border-top: 4px solid #1F4E79;
      border-radius: 50%;
      width: 32px;
      height: 32px;
      animation: spin 1s linear infinite;
      margin: 30px auto;
    }
    @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
    .result-summary {
      text-align: center;
    }
    .result-summary .score {
      font-size: 40px;
      font-weight: bold;
      color: #1F4E79;
      margin: 10px 0;
    }
    table.lb-table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 8px;
    }
    table.lb-table th {
      text-align: left;
      padding: 8px;
      border-bottom: 2px solid #1F4E79;
      font-size: 13px;
      color: #1F4E79;
    }
    table.lb-table td {
      padding: 8px;
      border-bottom: 1px solid #eee;
      font-size: 14px;
    }
    .lb-rank-1 { color: #C9971E; font-weight: bold; }
    .lb-rank-2 { color: #8A8A8A; font-weight: bold; }
    .lb-rank-3 { color: #A0622D; font-weight: bold; }
    .lb-note {
      font-size: 12px;
      color: #888;
      margin-top: 10px;
      text-align: center;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1 id="pageTitle">English 三大常見時態練習</h1>
    <div class="subtitle">簡單現在式・現在進行式・現在完成式</div>

    <div id="app">
      <div class="card">
        <div class="spinner"></div>
        <p style="text-align:center; color:#888;">題目載入中,請稍候...</p>
      </div>
    </div>
  </div>

  <script>
    var state = {
      name: '',
      questions: [],
      currentIndex: 0,
      correctCount: 0,
      selectedOption: null
    };

    var loadTimedOut = false;
    var loadTimer = setTimeout(function () {
      loadTimedOut = true;
      renderError('題目載入逾時,請檢查網路連線後重試。');
    }, 10000);

    function renderError(message) {
      document.getElementById('app').innerHTML =
        '<div class="card error-box">' +
        '<p>⚠️ ' + escapeHtml(message) + '</p>' +
        '<button onclick="location.reload()">重新載入</button>' +
        '</div>';
    }

    function escapeHtml(str) {
      var div = document.createElement('div');
      div.innerText = str == null ? '' : String(str);
      return div.innerHTML;
    }

    // 進入頁面先請學生輸入姓名/代號
    function renderNameForm() {
      var html = '<div class="card">' +
        '<p>請輸入您的姓名或座號,開始練習:</p>' +
        '<input type="text" id="studentNameInput" placeholder="例如:王小明 或 座號3號">' +
        '<button onclick="startQuiz()">開始測驗</button>' +
        '</div>';
      document.getElementById('app').innerHTML = html;
    }

    function startQuiz() {
      var nameInput = document.getElementById('studentNameInput');
      state.name = (nameInput && nameInput.value.trim()) || '匿名';
      loadQuestions();
    }

    function loadQuestions() {
      document.getElementById('app').innerHTML =
        '<div class="card"><div class="spinner"></div>' +
        '<p style="text-align:center; color:#888;">題目載入中,請稍候...</p></div>';

      clearTimeout(loadTimer);
      loadTimer = setTimeout(function () {
        loadTimedOut = true;
        renderError('題目載入逾時,請檢查網路連線後重試。');
      }, 10000);

      google.script.run
        .withSuccessHandler(function (result) {
          if (loadTimedOut) return; // 已經逾時顯示錯誤畫面,忽略遲到的回應
          clearTimeout(loadTimer);

          if (!result || !result.success) {
            renderError((result && result.error) || '載入題目失敗,請重試。');
            return;
          }
          if (result.title) {
            document.getElementById('pageTitle').innerText = result.title;
          }
          if (!result.questions || result.questions.length === 0) {
            renderError('目前題庫是空的,請聯絡老師新增題目。');
            return;
          }
          state.questions = result.questions;
          state.currentIndex = 0;
          state.correctCount = 0;
          renderQuestion();
        })
        .withFailureHandler(function (err) {
          if (loadTimedOut) return;
          clearTimeout(loadTimer);
          renderError('連線發生錯誤:' + (err && err.message ? err.message : err));
        })
        .getQuizQuestions();
    }

    function renderQuestion() {
      var q = state.questions[state.currentIndex];
      state.selectedOption = null;

      var optionsHtml = '';
      if (q.type === '選擇題' || q.type === '時態分類') {
        var opts = [
          { label: q.optionA, key: 'A' },
          { label: q.optionB, key: 'B' },
          { label: q.optionC, key: 'C' }
        ];
        opts.forEach(function (opt) {
          if (!opt.label) return;
          optionsHtml += '<button type="button" class="option-btn" onclick="selectOption(this, \'' +
            escapeHtml(opt.label).replace(/'/g, "\\'") + '\')">' + escapeHtml(opt.label) + '</button>';
        });
      } else {
        optionsHtml = '<input type="text" id="fillBlankInput" placeholder="請輸入完整的疑問句">';
      }

      var html =
        '<div class="card">' +
        '<div class="progress">第 ' + (state.currentIndex + 1) + ' / ' + state.questions.length + ' 題 | 題型:' + escapeHtml(q.type) + '</div>' +
        '<div class="prompt">' + escapeHtml(q.prompt || '') + '</div>' +
        '<div class="content">' + escapeHtml(q.content || '') + '</div>' +
        optionsHtml +
        '<div id="feedbackArea"></div>' +
        '<button id="submitBtn" onclick="submitCurrentAnswer()">送出答案</button>' +
        '</div>';

      document.getElementById('app').innerHTML = html;
    }

    function selectOption(btnEl, value) {
      state.selectedOption = value;
      var buttons = document.querySelectorAll('.option-btn');
      buttons.forEach(function (b) { b.classList.remove('selected'); });
      btnEl.classList.add('selected');
    }

    function submitCurrentAnswer() {
      var q = state.questions[state.currentIndex];
      var answer = '';

      if (q.type === '選擇題' || q.type === '時態分類') {
        if (!state.selectedOption) {
          alert('請先選擇一個選項');
          return;
        }
        answer = state.selectedOption;
      } else {
        var input = document.getElementById('fillBlankInput');
        answer = input ? input.value.trim() : '';
        if (!answer) {
          alert('請輸入答案');
          return;
        }
      }

      document.getElementById('submitBtn').disabled = true;

      google.script.run
        .withSuccessHandler(function (res) {
          if (!res || !res.success) {
            renderError((res && res.error) || '提交失敗,請重試。');
            return;
          }
          if (res.isCorrect) state.correctCount++;

          var feedbackHtml = res.isCorrect
            ? '<div class="feedback correct">✅ 答對了!</div>'
            : '<div class="feedback incorrect">❌ 答錯了。' +
              (res.correctAnswer ? '正確答案:' + escapeHtml(res.correctAnswer) : '') +
              (res.explanation ? '<br>說明:' + escapeHtml(res.explanation) : '') +
              '</div>';

          document.getElementById('feedbackArea').innerHTML = feedbackHtml;
          document.getElementById('submitBtn').innerText = '下一題';
          document.getElementById('submitBtn').disabled = false;
          document.getElementById('submitBtn').onclick = goToNextQuestion;
        })
        .withFailureHandler(function (err) {
          document.getElementById('submitBtn').disabled = false;
          alert('提交發生錯誤,請重試:' + (err && err.message ? err.message : err));
        })
        .submitAnswer(state.name, q.id, answer);
    }

    function goToNextQuestion() {
      state.currentIndex++;
      if (state.currentIndex >= state.questions.length) {
        renderResult();
      } else {
        renderQuestion();
      }
    }

    function renderResult() {
      var html =
        '<div class="card result-summary">' +
        '<p>測驗完成!' + escapeHtml(state.name) + ' 的成績:</p>' +
        '<div class="score">' + state.correctCount + ' / ' + state.questions.length + '</div>' +
        '<button onclick="showLeaderboard()">🏆 查看排行榜</button>' +
        '<button onclick="renderNameForm()">再測一次</button>' +
        '</div>';
      document.getElementById('app').innerHTML = html;
    }

    // ---------------- 排行榜 ----------------

    function showLeaderboard() {
      document.getElementById('app').innerHTML =
        '<div class="card"><div class="spinner"></div>' +
        '<p style="text-align:center; color:#888;">排行榜載入中,請稍候...</p></div>';

      var lbTimedOut = false;
      var lbTimer = setTimeout(function () {
        lbTimedOut = true;
        renderError('排行榜載入逾時,請檢查網路連線後重試。');
      }, 10000);

      google.script.run
        .withSuccessHandler(function (res) {
          if (lbTimedOut) return;
          clearTimeout(lbTimer);

          if (!res || !res.success) {
            renderError((res && res.error) || '載入排行榜失敗,請重試。');
            return;
          }
          renderLeaderboardTable(res);
        })
        .withFailureHandler(function (err) {
          if (lbTimedOut) return;
          clearTimeout(lbTimer);
          renderError('連線發生錯誤:' + (err && err.message ? err.message : err));
        })
        .getLeaderboard();
    }

    function renderLeaderboardTable(res) {
      var bodyHtml = '';

      if (!res.leaderboard || res.leaderboard.length === 0) {
        bodyHtml = '<p style="text-align:center; color:#888; padding: 20px 0;">目前還沒有人達到上榜門檻(累積答對滿 ' +
          res.minCorrect + ' 題以上),繼續加油練習吧!</p>';
      } else {
        bodyHtml += '<table class="lb-table"><tr><th>名次</th><th>姓名</th><th style="text-align:right;">正確率</th><th style="text-align:right;">答對題數</th></tr>';
        res.leaderboard.forEach(function (row, idx) {
          var rank = idx + 1;
          var rankClass = rank === 1 ? 'lb-rank-1' : (rank === 2 ? 'lb-rank-2' : (rank === 3 ? 'lb-rank-3' : ''));
          bodyHtml += '<tr>' +
            '<td class="' + rankClass + '">' + rank + '</td>' +
            '<td>' + escapeHtml(row.name) + '</td>' +
            '<td style="text-align:right;">' + row.accuracy + '%</td>' +
            '<td style="text-align:right;">' + row.correctCount + '</td>' +
            '</tr>';
        });
        bodyHtml += '</table>';
        bodyHtml += '<div class="lb-note">名次依累積正確率排序,需累積答對 ' + res.minCorrect + ' 題以上才會列入</div>';
      }

      var html =
        '<div class="card">' +
        '<h2 style="color:#1F4E79; text-align:center; margin-top:0;">🏆 累積正確率排行榜</h2>' +
        bodyHtml +
        '<div style="text-align:center;">' +
        '<button onclick="renderResult()">返回成績</button>' +
        '<button onclick="renderNameForm()">再測一次</button>' +
        '</div>' +
        '</div>';
      document.getElementById('app').innerHTML = html;
    }

    // 頁面啟動
    clearTimeout(loadTimer);
    renderNameForm();
  </script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

部署成 Web App 後,就會拿到可分享網址。

  1. Apps Script 右上:部署 → 新部署
  2. 類型選:Web app
  3. 執行身分:你
  4. 存取權限:任何人
  5. 完成後複製網址

💬 評論區

老師們的交流與提問

載入評論中...