Appscript ทำเวบแอปแบบทดสอบออนไลน์ด้วย GoogleSheet

 

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">&times;</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>

Latest
Previous
Next Post »