0. 作品介紹
封面
2. 資料表規則
請照下面規則建立分頁與欄位。
第1分頁: 首頁總覽說明
A 欄: 總覽
B 欄: 公式
📄 解析結果(自動表格)
分頁:首頁總覽說明
| A 欄 | B 欄 |
|---|---|
| 總覽 | 公式 |
3. 開啟 Apps Script
從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 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。
<!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 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論