Code.gs
/**
* ===================================================================
* FINAL OPTIMIZED BACKEND SCRIPT (VERSION 6.0 - Optimized Base64)
* ===================================================================
* เวอร์ชันปรับปรุงประสิทธิภาพสูงสุด แก้ปัญหา Timeout โดยการสุ่มคำถามก่อน
* แล้วจึงแปลงรูปภาพเป็น Base64 เฉพาะ 10 ข้อที่ต้องการ
*/
// ================== CONFIGURATION (ตั้งค่า) ==================
const SPREADSHEET_ID = "sheet id ของคุณ";
const FOLDER_ID = "โฟลเดอร์ id ของคุณ";
const QUESTIONS_SHEET_NAME = "ชื่อชีทของคุณ";
const SCORES_SHEET_NAME = "Scores";
// ================== WEB APP ROUTING ==================
function doGet(e) {
if (e.parameter.page == "admin") {
return HtmlService.createTemplateFromFile("AdminForm").evaluate().setTitle("ฟอร์มบันทึกคำถาม");
} else {
return HtmlService.createTemplateFromFile("Quiz").evaluate().setTitle("แบบทดสอบออนไลน์");
}
}
// ================== CLIENT-CALLABLE FUNCTIONS ==================
/**
* ดึงข้อมูล, สุ่ม 10 ข้อ, และแปลงรูปภาพเป็น Base64 เฉพาะ 10 ข้อนั้น (Optimized)
*/
function getQuizData() {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName(QUESTIONS_SHEET_NAME);
if (!sheet || sheet.getLastRow() < 2) return [];
const dataRange = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn());
const data = dataRange.getValues();
// 1. สร้างรายการคำถามแบบ "เบา" (มีแค่ ID และข้อความ)
let lightweightQuestions = [];
for (const row of data) {
if (row[1] && row[1].toString().trim() !== "") {
lightweightQuestions.push(row); // เก็บทั้งแถวไปก่อน
}
}
// 2. สุ่มคำถามจากรายการแบบ "เบา"
for (let i = lightweightQuestions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[lightweightQuestions[i], lightweightQuestions[j]] = [lightweightQuestions[j], lightweightQuestions[i]];
}
const selectedRows = lightweightQuestions.slice(0, 10);
Logger.log(`สุ่มคำถามได้ ${selectedRows.length} ข้อ จะเริ่มทำการแปลงรูปภาพ...`);
// 3. สร้างรายการคำถามฉบับสมบูรณ์ โดยแปลงรูปภาพเฉพาะ 10 ข้อที่สุ่มได้
const finalQuestions = [];
for (const row of selectedRows) {
finalQuestions.push({
id: row[0],
questionText: row[1],
questionImage: getImageAsBase64(row[2]),
choices: [
{ text: row[3], image: getImageAsBase64(row[4]) },
{ text: row[5], image: getImageAsBase64(row[6]) },
{ text: row[7], image: getImageAsBase64(row[8]) },
{ text: row[9], image: getImageAsBase64(row[10]) },
],
correctAnswer: parseInt(row[11], 10)
});
}
Logger.log(`แปลงข้อมูลเสร็จสิ้น พร้อมส่งคำถาม ${finalQuestions.length} ข้อ`);
return finalQuestions;
} catch (e) {
Logger.log("เกิดข้อผิดพลาดใน getQuizData: " + e.stack);
return [];
}
}
/**
* Helper function: อ่านไฟล์จาก Drive แล้วแปลงเป็น Base64 Data URL
*/
function getImageAsBase64(fileId) {
if (!fileId || typeof fileId !== 'string' || fileId.trim() === "" || fileId.indexOf("ไฟล์ชื่อ") !== -1) return null;
try {
const file = DriveApp.getFileById(fileId);
const blob = file.getBlob();
const contentType = blob.getContentType();
const base64Data = Utilities.base64Encode(blob.getBytes());
return `data:${contentType};base64,${base64Data}`;
} catch (e) {
Logger.log(`ไม่สามารถแปลง File ID: ${fileId} เป็น Base64 ได้, Error: ${e.message}`);
return null;
}
}
// ฟังก์ชันอื่นๆ (uploadFileToGoogleDrive, saveQuestion, recordScore) ใช้โค้ดเดิมได้ ไม่ต้องแก้ไข
// (โค้ดด้านล่างนี้คือเวอร์ชันสมบูรณ์ล่าสุด)
function uploadFileToGoogleDrive(base64Data, fileName) {
try {
const splitBase64 = base64Data.split(",");
const contentType = splitBase64[0].match(/:(.*?);/)[1];
const bytes = Utilities.base64Decode(splitBase64[1]);
const blob = Utilities.newBlob(bytes, contentType, fileName);
const folder = DriveApp.getFolderById(FOLDER_ID);
const file = folder.createFile(blob);
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
return file.getId();
} catch (e) {
return "Error: " + e.message;
}
}
function saveQuestion(data) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName(QUESTIONS_SHEET_NAME);
if (!sheet) throw new Error(`ไม่พบชีตที่ชื่อว่า "${QUESTIONS_SHEET_NAME}"`);
const newId = sheet.getLastRow() + 1;
sheet.appendRow([
newId, data.questionText, data.questionImageId, data.choice1Text, data.choice1ImageId,
data.choice2Text, data.choice2ImageId, data.choice3Text, data.choice3ImageId,
data.choice4Text, data.choice4ImageId, data.correctAnswer
]);
return "บันทึกข้อมูลคำถามสำเร็จ!";
} catch (e) {
return `เกิดข้อผิดพลาดในการบันทึกข้อมูล: ${e.message}`;
}
}
function recordScore(name, score, total) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const scoreSheet = ss.getSheetByName(SCORES_SHEET_NAME);
if (!scoreSheet) throw new Error(`ไม่พบชีตที่ชื่อว่า "${SCORES_SHEET_NAME}"`);
const timestamp = new Date();
scoreSheet.appendRow([timestamp, name, score, total]);
return "บันทึกคะแนนเรียบร้อย";
} catch (e) {
return "เกิดข้อผิดพลาดในการบันทึกคะแนน";
}
}
Code Quiz
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
html, body {
height: 100%;
margin: 0;
}
body {
background-color: #f0f2f5;
font-family: 'Noto Sans Thai', sans-serif;
display: flex;
justify-content: center;
align-items: center;
}
.main-container {
max-width: 700px;
width: 100%;
margin: 20px;
background: #fff;
padding: 30px;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.choice-item { display: flex; align-items: center; border: 2px solid #dee2e6; border-radius: 12px; padding: 15px; cursor: pointer; min-height: 100px; transition: border-color 0.2s; }
.choice-item:hover { border-color: #007bff; }
.choice-item.selected { background-color: #e7f3ff; border-color: #007bff; }
.choice-text { flex-grow: 1; }
#choices-container { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.choice-image-container { width: 80px; height: 80px; margin-left: 15px; overflow: hidden; border-radius: 8px; }
.choice-image-container img { width: 100%; height: 100%; object-fit: cover; }
.button-container { display: flex; justify-content: flex-end; }
/* --- CSS สำหรับ Modal ซูมรูปภาพ (แก้ไขแล้ว) --- */
.modal-overlay {
display: none; /* ซ่อนไว้เป็นค่าเริ่มต้น (แก้ไขแล้ว) */
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.85);
justify-content: center;
align-items: center;
animation: fadeIn 0.3s;
}
@keyframes fadeIn { from {opacity: 0;} to {opacity: 1;} }
.modal-content {
margin: auto;
display: block;
max-width: 85%;
max-height: 85vh;
border-radius: 8px;
}
.close-button {
position: absolute;
top: 15px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
cursor: pointer;
}
.close-button:hover, .close-button:focus { color: #bbb; text-decoration: none; }
#question-image-container img, .choice-image-container img {
cursor: zoom-in;
transition: transform 0.2s;
}
#question-image-container img:hover, .choice-image-container img:hover {
transform: scale(1.05);
}
/* --- สิ้นสุด CSS สำหรับ Modal --- */
@media (max-width: 768px) {
#choices-container { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="main-container">
<div id="start-screen">
<h2>📘 แบบทดสอบภาษาอังกฤษออนไลน์เตรียม Onet ประถมศึกษา</h2>
<p class="text-muted">จัดทำโดย Teacher Aphsara 🧑🏫</p>
<hr>
<div class="form-group">
<label for="nameInput">กรุณากรอกชื่อของคุณ:</label>
<input type="text" class="form-control" id="nameInput" placeholder="ชื่อ-นามสกุล">
</div>
<button id="startBtn" class="btn btn-primary">เริ่มทำแบบทดสอบ</button>
</div>
<div id="quiz-section" style="display:none">
<h3 id="welcome-message" class="text-center mb-4"></h3>
<div id="loading">🔄 กำลังสุ่มแบบทดสอบให้คุณ...</div>
<div id="quiz-box" style="display:none">
<p id="question-counter"></p>
<h4 id="question-text"></h4>
<div id="question-image-container" style="margin: 15px 0;"></div>
<div id="choices-container"></div>
<div class="button-container mt-4">
<button id="nextBtn" class="btn btn-info" style="display:none">ข้อต่อไป</button>
</div>
</div>
</div>
<div id="result-screen" style="display:none" class="text-center"></div>
</div>
<div id="imageModal" class="modal-overlay">
<span class="close-button">×</span>
<img class="modal-content" id="zoomedImage">
</div>
<script>
let quizQuestions = [], currentQuestionIndex = 0, userAnswers = [], userName = "";
const startScreen = document.getElementById('start-screen'), quizSection = document.getElementById('quiz-section'), resultScreen = document.getElementById('result-screen');
const startBtn = document.getElementById('startBtn'), nameInput = document.getElementById('nameInput'), loadingDiv = document.getElementById('loading');
const quizBox = document.getElementById('quiz-box'), nextBtn = document.getElementById('nextBtn'), choicesContainer = document.getElementById('choices-container');
// --- JavaScript สำหรับควบคุม Modal ---
const imageModal = document.getElementById('imageModal');
const zoomedImage = document.getElementById('zoomedImage');
const closeButton = document.querySelector('.close-button');
document.body.addEventListener('click', function(event) {
if (event.target.tagName === 'IMG' && (event.target.closest('#question-image-container') || event.target.closest('.choice-image-container'))) {
imageModal.style.display = 'flex'; // เปลี่ยน display เป็น flex เพื่อแสดง Modal
zoomedImage.src = event.target.src;
}
});
function closeModal() { imageModal.style.display = 'none'; }
closeButton.addEventListener('click', closeModal);
imageModal.addEventListener('click', function(event) { if (event.target === imageModal) { closeModal(); } });
// --- สิ้นสุดส่วนของ Modal ---
startBtn.addEventListener('click', handleStartClick);
nextBtn.addEventListener('click', handleNextButtonClick);
choicesContainer.addEventListener('click', handleChoiceClick);
function handleStartClick() {
userName = nameInput.value.trim();
if (!userName) return alert("กรุณากรอกชื่อของคุณ");
startScreen.style.display = 'none';
loadQuiz();
}
function loadQuiz() {
quizSection.style.display = 'block';
loadingDiv.style.display = 'block';
quizBox.style.display = 'none';
nextBtn.style.display = 'none';
google.script.run.withSuccessHandler(startQuiz).getQuizData();
}
function startQuiz(questions) {
if (!questions || questions.length === 0) {
loadingDiv.innerHTML = "ขออภัย, ไม่พบชุดคำถาม";
return;
}
quizQuestions = questions;
currentQuestionIndex = 0;
userAnswers = new Array(quizQuestions.length).fill(null);
loadingDiv.style.display = 'none';
quizBox.style.display = 'block';
nextBtn.style.display = 'block';
document.getElementById('welcome-message').innerText = `แบบทดสอบสำหรับคุณ: ${userName}`;
showQuestion(currentQuestionIndex);
}
function showQuestion(index) {
const q = quizQuestions[index];
document.getElementById('question-counter').innerText = `คำถามที่ ${index + 1} จาก ${quizQuestions.length}`;
document.getElementById('question-text').innerText = q.questionText;
const qImgContainer = document.getElementById('question-image-container');
if (q.questionImage) {
qImgContainer.innerHTML = `<img src="${q.questionImage}" style="max-width:100%; border-radius:12px">`;
qImgContainer.style.display = 'block';
} else {
qImgContainer.style.display = 'none';
}
choicesContainer.innerHTML = '';
q.choices.forEach((choice, i) => {
const imageHtml = choice.image ? `<div class='choice-image-container'><img src='${choice.image}'></div>` : '';
choicesContainer.innerHTML += `
<label class="choice-item">
<input type="radio" name="choice" value="${i + 1}" style="display:none">
<span class="choice-text">${choice.text || ''}</span>
${imageHtml}
</label>`;
});
nextBtn.innerText = (index === quizQuestions.length - 1) ? "ส่งคำตอบ" : "ข้อต่อไป";
}
function handleChoiceClick(event) {
const label = event.target.closest('.choice-item');
if (!label) return;
document.querySelectorAll('.choice-item').forEach(i => i.classList.remove('selected'));
label.classList.add('selected');
label.querySelector('input[type="radio"]').checked = true;
}
function handleNextButtonClick() {
const selected = document.querySelector('input[name="choice"]:checked');
if (!selected) return alert("กรุณาเลือกคำตอบ");
userAnswers[currentQuestionIndex] = parseInt(selected.value);
if (++currentQuestionIndex < quizQuestions.length) showQuestion(currentQuestionIndex);
else calculateAndShowScore();
}
function calculateAndShowScore() {
let score = 0;
for (let i = 0; i < quizQuestions.length; i++) {
if (userAnswers[i] === quizQuestions[i].correctAnswer) score++;
}
quizSection.style.display = 'none';
resultScreen.style.display = 'block';
resultScreen.innerHTML = `
<h2 class="mb-3">🎉 เสร็จสิ้นแบบทดสอบ!</h2>
<p>คุณ ${userName} ได้คะแนน:</p>
<h1 class="display-3 my-3">${score} / ${quizQuestions.length}</h1>
<small class="text-muted">บันทึกคะแนนแล้ว...</small><br>
<button id="restartBtn" class="btn btn-success mt-4">เริ่มทำแบบทดสอบใหม่</button>`;
setTimeout(() => {
document.getElementById("restartBtn").addEventListener("click", () => {
resultScreen.style.display = 'none';
nameInput.value = '';
startScreen.style.display = 'block';
});
}, 100);
google.script.run
.withSuccessHandler(r => {})
.withFailureHandler(e => console.error(e))
.recordScore(userName, score, quizQuestions.length);
}
</script>
</body>
</html>
Code AdminForm
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
.container { padding: 20px; max-width: 800px; }
.loader { display: none; }
.form-group { margin-bottom: 1.5rem; }
.choice-section { border-left: 3px solid #eee; padding-left: 15px; margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="container">
<h2>ฟอร์มบันทึกคำถามสำหรับแบบทดสอบ</h2>
<p>กรอกข้อมูลคำถามและตัวเลือกด้านล่าง จากนั้นกดบันทึก</p>
<hr>
<form id="quizForm">
<div class="form-group">
<label for="questionText"><b>คำถามหลัก</b></label>
<input type="text" class="form-control" id="questionText" required>
</div>
<div class="form-group">
<label for="questionImage">รูปภาพประกอบคำถาม (ถ้ามี)</label>
<input type="file" class="form-control-file" id="questionImage" accept="image/*">
</div>
<hr>
<h4><b>ตัวเลือกและคำตอบที่ถูกต้อง</b></h4>
<p><i>เลือก 1 ข้อเป็นคำตอบที่ถูกต้อง</i></p>
<div id="choices-container">
</div>
<button type="submit" class="btn btn-primary btn-lg">บันทึกคำถาม</button>
<div class="spinner-border text-primary loader ml-3" role="status">
<span class="sr-only">Loading...</span>
</div>
<div id="status" class="mt-3"></div>
</form>
</div>
<script>
// === สร้างฟอร์มตัวเลือก 4 ข้อ อัตโนมัติ ===
const choicesContainer = document.getElementById('choices-container');
for (let i = 1; i <= 4; i++) {
const choiceHTML = `
<div class="form-group choice-section">
<div class="custom-control custom-radio">
<input type="radio" id="correctAnswer${i}" name="correctAnswer" value="${i}" class="custom-control-input" required>
<label class="custom-control-label" for="correctAnswer${i}"><b>ตัวเลือกที่ ${i} (เลือกเป็นคำตอบที่ถูก)</b></label>
</div>
<label class="mt-2">ข้อความตัวเลือก:</label>
<input type="text" class="form-control choice-text" placeholder="กรอกข้อความ (ถ้ามี)">
<label class="mt-2">รูปภาพตัวเลือก:</label>
<input type="file" class="form-control-file choice-image" accept="image/*">
</div>
`;
choicesContainer.innerHTML += choiceHTML;
}
// === จัดการการส่งฟอร์ม ===
const form = document.getElementById('quizForm');
const statusDiv = document.getElementById('status');
const loader = document.querySelector('.loader');
form.addEventListener('submit', function(e) {
e.preventDefault(); // ป้องกันไม่ให้หน้าเว็บรีเฟรช
loader.style.display = 'inline-block'; // แสดงสถานะกำลังโหลด
statusDiv.innerHTML = '';
// ฟังก์ชันอ่านไฟล์จาก input แล้วแปลงเป็น Base64
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
if (!file) {
resolve(null); // ถ้าไม่มีไฟล์ ก็ไม่ต้องทำอะไร
return;
}
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, data: reader.result });
reader.onerror = error => reject(error);
reader.readAsDataURL(file);
});
}
const fileInputs = form.querySelectorAll('input[type="file"]');
const filePromises = Array.from(fileInputs).map(input => readFileAsBase64(input.files[0]));
// 1. รอให้ไฟล์ทั้งหมดถูกแปลงเป็น Base64
Promise.all(filePromises).then(files => {
// 2. ส่งไฟล์ Base64 ไปอัปโหลดที่ Google Drive ทีละไฟล์
const fileUploadPromises = files.map(file => {
if (!file) return Promise.resolve(null); // ถ้าไม่มีไฟล์ ก็ข้ามไป
return new Promise((resolve, reject) => {
google.script.run
.withSuccessHandler(fileId => resolve(fileId))
.withFailureHandler(error => reject(error))
.uploadFileToGoogleDrive(file.data, file.name);
});
});
return Promise.all(fileUploadPromises);
// 3. เมื่ออัปโหลดเสร็จทุกไฟล์แล้ว จะได้ ID ของไฟล์กลับมา
}).then(fileIds => {
// fileIds[0] คือ ID รูปคำถาม, fileIds[1-4] คือ ID รูปตัวเลือก
const allTextInputs = document.querySelectorAll('.choice-text');
const formData = {
questionText: document.getElementById('questionText').value,
questionImageId: fileIds[0] || "", // ถ้าไม่มีไฟล์ให้เป็นค่าว่าง
choice1Text: allTextInputs[0].value,
choice1ImageId: fileIds[1] || "",
choice2Text: allTextInputs[1].value,
choice2ImageId: fileIds[2] || "",
choice3Text: allTextInputs[2].value,
choice3ImageId: fileIds[3] || "",
choice4Text: allTextInputs[3].value,
choice4ImageId: fileIds[4] || "",
correctAnswer: form.querySelector('input[name="correctAnswer"]:checked').value
};
// 4. ส่งข้อมูลทั้งหมดไปบันทึกลง Google Sheet
google.script.run
.withSuccessHandler(response => {
statusDiv.innerHTML = `<div class="alert alert-success">${response}</div>`;
form.reset(); //ล้างฟอร์ม
loader.style.display = 'none'; //ซ่อนสถานะโหลด
})
.withFailureHandler(error => {
statusDiv.innerHTML = `<div class="alert alert-danger">${error}</div>`;
loader.style.display = 'none';
})
.saveQuestion(formData);
}).catch(error => { // จัดการหากมีข้อผิดพลาดระหว่างทาง
statusDiv.innerHTML = `<div class="alert alert-danger">เกิดข้อผิดพลาด: ${error}</div>`;
loader.style.display = 'none';
});
});
</script>
</body>
</html>
ไม่มีความคิดเห็น:
แสดงความคิดเห็น