0. 作品介紹

作者 吳俊成 東門國小

老師只要選取年級、班級,就可以得到全班的輪盤,協助教師在教學的過程中,與學生進行答詢。


44 2 0
班級經營
示範影片

1. AI 提示詞

把這段指令貼給 AI,讓它幫你生成程式碼。

我有一個Google試算表
欄位是:A(年)、B(班)、C(座號)、D(姓名)、E(性別)
我想要建立多個系統
首先建立 【輪盤抽籤系統】
操作步驟與需求說明
 【
1.下拉式選單選取年級、班級。
3.輪盤方式把全班顯示出來(每一個切割範圍要彩色顯示)。
4.點選開始抽籤,輪盤開始旋轉,要有加速、減速的效果,停止後把抽到的學生資料,以卡片的方式顯示在畫面上的一個區塊(中簽區)。
5.被抽到的學生,就從輪盤中移除。
6.重複步驟三,就表示連續抽籤。
7.在中簽區中設置一個按鈕(清除中簽結果),點選後,清除中簽區,恢復全班的輪盤。
8.我希望的風格是校園、活潑、青春。

要把它變成程式碼我要發佈
請完整敘述操作步驟 越詳細越好
作業環境
Google試算表
Google Apps Script
我看不懂程式碼,請給我後端gs完整程式碼、前端index完整程式碼

2. 資料表規則

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

資料表規則圖
第1分頁: 工作表1 A 欄: 年 B 欄: 班 C 欄: 座號 D 欄: 姓名 E 欄: 性別

📄 解析結果(自動表格)

分頁:工作表1
A 欄 B 欄 C 欄 D 欄 E 欄
座號 姓名 性別

3. 開啟 Apps Script

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

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

4. 貼上後端(Code.gs)

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

Code.gs(後端)
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
      .setTitle('✨ 青春校園輪盤抽籤系統 ✨')
      .addMetaTag('viewport', 'width=device-width, initial-scale=1')
      .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// 取得年級與班級清單供下拉選單使用
function getDropdownData() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const data = sheet.getDataRange().getValues();
  const years = new Set();
  const classesByYear = {};

  // 從索引 1 開始,跳過第一列的標題
  for (let i = 1; i < data.length; i++) {
    const year = data[i][0];
    const cls = data[i][1];
    if (year && cls) {
      years.add(year);
      if (!classesByYear[year]) {
        classesByYear[year] = new Set();
      }
      classesByYear[year].add(cls);
    }
  }

  const result = {
    years: Array.from(years).sort(),
    classesByYear: {}
  };
  for (let y in classesByYear) {
    result.classesByYear[y] = Array.from(classesByYear[y]).sort();
  }
  return result;
}

// 根據選取的年級與班級,取得該班所有學生
function getStudents(year, cls) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const data = sheet.getDataRange().getValues();
  const students = [];

  for (let i = 1; i < data.length; i++) {
    if (data[i][0] == year && data[i][1] == cls) {
      students.push({
        seat: data[i][2],
        name: data[i][3],
        gender: data[i][4]
      });
    }
  }
  return students;
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

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

index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&family=Nunito:wght@700&display=swap" rel="stylesheet">
  <style>
    :root {
      --primary: #FF8BA7;
      --secondary: #FFC6C7;
      --bg: #FAEEE7;
      --text: #33272A;
      --card-bg: #FFFFFF;
    }
    body {
      font-family: 'Nunito', 'Noto Sans TC', sans-serif;
      background-color: var(--bg);
      background-image: radial-gradient(#FFC6C7 1px, transparent 1px);
      background-size: 20px 20px;
      color: var(--text);
      margin: 0;
      padding: 20px;
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    h1 {
      color: var(--primary);
      text-shadow: 2px 2px 0px #FFF;
      font-size: 2.5em;
      margin-bottom: 20px;
    }
    .control-panel {
      background: var(--card-bg);
      padding: 15px 30px;
      border-radius: 30px;
      box-shadow: 0 8px 15px rgba(255,139,167,0.2);
      margin-bottom: 30px;
      display: flex;
      gap: 15px;
      align-items: center;
    }
    select {
      padding: 10px 15px;
      border: 2px solid var(--secondary);
      border-radius: 15px;
      font-size: 1em;
      font-family: inherit;
      outline: none;
      cursor: pointer;
    }
    select:focus {
      border-color: var(--primary);
    }
    .main-container {
      display: flex;
      gap: 40px;
      width: 100%;
      max-width: 1000px;
      justify-content: center;
      flex-wrap: wrap;
    }
    .wheel-section {
      display: flex;
      flex-direction: column;
      align-items: center;
      position: relative;
    }
    canvas {
      filter: drop-shadow(0 10px 15px rgba(0,0,0,0.1));
    }
    .btn {
      margin-top: 20px;
      padding: 15px 40px;
      font-size: 1.2em;
      font-weight: bold;
      color: white;
      background: linear-gradient(45deg, #FF8BA7, #FF99AA);
      border: none;
      border-radius: 25px;
      cursor: pointer;
      box-shadow: 0 5px 15px rgba(255,139,167,0.4);
      transition: transform 0.1s, box-shadow 0.1s;
    }
    .btn:active {
      transform: translateY(3px);
      box-shadow: 0 2px 5px rgba(255,139,167,0.4);
    }
    .btn:disabled {
      background: #ccc;
      cursor: not-allowed;
      box-shadow: none;
    }
    .result-section {
      background: var(--card-bg);
      padding: 20px;
      border-radius: 20px;
      box-shadow: 0 10px 25px rgba(0,0,0,0.05);
      min-width: 300px;
      display: flex;
      flex-direction: column;
    }
    .result-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      border-bottom: 2px dashed var(--secondary);
      padding-bottom: 10px;
      margin-bottom: 15px;
    }
    .result-header h2 {
      margin: 0;
      color: var(--primary);
    }
    .btn-clear {
      background: #A0C4FF;
      padding: 8px 15px;
      border: none;
      border-radius: 15px;
      color: white;
      cursor: pointer;
      font-weight: bold;
    }
    .cards-container {
      display: flex;
      flex-direction: column;
      gap: 10px;
      max-height: 400px;
      overflow-y: auto;
      padding-right: 5px;
    }
    .student-card {
      display: flex;
      align-items: center;
      background: #FDFFB6;
      padding: 15px;
      border-radius: 15px;
      box-shadow: 0 4px 6px rgba(0,0,0,0.05);
      animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
    }
    .student-card.boy { background: #E4F1EE; border-left: 5px solid #9BF6FF; }
    .student-card.girl { background: #FFF0F3; border-left: 5px solid #FFADAD; }
    .seat-no {
      font-size: 1.5em;
      font-weight: bold;
      color: var(--primary);
      margin-right: 15px;
      min-width: 40px;
    }
    .student-info {
      flex-grow: 1;
    }
    .student-name {
      font-size: 1.2em;
      font-weight: bold;
    }
    
    @keyframes popIn {
      0% { transform: scale(0.8); opacity: 0; }
      100% { transform: scale(1); opacity: 1; }
    }
  </style>
</head>
<body>

  <h1>🎒 青春校園抽籤系統 🎒</h1>

  <div class="control-panel">
    <select id="yearSelect" onchange="updateClasses()">
      <option value="">請選擇年級</option>
    </select>
    <select id="classSelect" onchange="loadStudents()">
      <option value="">請選擇班級</option>
    </select>
  </div>

  <div class="main-container">
    <div class="wheel-section">
      <canvas id="wheelCanvas" width="500" height="500"></canvas>
      <button class="btn" id="spinBtn" onclick="spin()">🎉 開始抽籤 🎉</button>
    </div>

    <div class="result-section">
      <div class="result-header">
        <h2>中籤區 🏆</h2>
        <button class="btn-clear" onclick="resetSystem()">重新來過</button>
      </div>
      <div class="cards-container" id="drawnArea">
        </div>
    </div>
  </div>

  <script>
    let appData = {};
    let originalStudents = [];
    let currentStudents = [];
    
    // 青春活潑的色彩盤
    const colors = ['#FFADAD', '#FFD6A5', '#FDFFB6', '#CAFFBF', '#9BF6FF', '#A0C4FF', '#BDB2FF', '#FFC6FF', '#FFFFFC'];
    
    let canvas, ctx;
    let currentAngle = 0;
    let isSpinning = false;
    let isWaiting = false; // 控制等待三秒的狀態
    let spinVelocity = 0;
    let lastWinnerIndex = -1; // 用來判斷是否經過新的扇形
    
    // 音效處理核心 (Web Audio API)
    let audioCtx;

    // 播放「噠噠噠」的指針聲
    function playTickSound() {
      if (!audioCtx) return;
      const osc = audioCtx.createOscillator();
      const gainNode = audioCtx.createGain();
      osc.connect(gainNode);
      gainNode.connect(audioCtx.destination);
      osc.type = 'triangle';
      osc.frequency.setValueAtTime(600, audioCtx.currentTime);
      gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime);
      gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.05);
      osc.start(audioCtx.currentTime);
      osc.stop(audioCtx.currentTime + 0.05);
    }

    // 播放「登登登登」的中籤音效
    function playWinSound() {
      if (!audioCtx) return;
      const notes = [440, 554.37, 659.25, 880]; // A4, C#5, E5, A5 大調和弦
      let startTime = audioCtx.currentTime;
      notes.forEach((freq, i) => {
        const osc = audioCtx.createOscillator();
        const gainNode = audioCtx.createGain();
        osc.connect(gainNode);
        gainNode.connect(audioCtx.destination);
        osc.type = 'sine';
        osc.frequency.value = freq;
        gainNode.gain.setValueAtTime(0, startTime + i * 0.1);
        gainNode.gain.linearRampToValueAtTime(0.3, startTime + i * 0.1 + 0.05);
        gainNode.gain.linearRampToValueAtTime(0, startTime + i * 0.1 + 0.3);
        osc.start(startTime + i * 0.1);
        osc.stop(startTime + i * 0.1 + 0.3);
      });
    }

    window.onload = function() {
      canvas = document.getElementById('wheelCanvas');
      ctx = canvas.getContext('2d');
      drawEmptyWheel();
      
      google.script.run.withSuccessHandler(function(data) {
        appData = data;
        const yearSelect = document.getElementById('yearSelect');
        data.years.forEach(year => {
          let opt = document.createElement('option');
          opt.value = year;
          opt.innerText = year + ' 年級';
          yearSelect.appendChild(opt);
        });
      }).getDropdownData();
    };

    function updateClasses() {
      if (isSpinning || isWaiting) return;
      const year = document.getElementById('yearSelect').value;
      const classSelect = document.getElementById('classSelect');
      classSelect.innerHTML = '<option value="">請選擇班級</option>';
      
      if(year && appData.classesByYear[year]) {
        appData.classesByYear[year].forEach(cls => {
          let opt = document.createElement('option');
          opt.value = cls;
          opt.innerText = cls + ' 班';
          classSelect.appendChild(opt);
        });
      }
      clearAll();
    }

    function loadStudents() {
      if (isSpinning || isWaiting) return;
      const year = document.getElementById('yearSelect').value;
      const cls = document.getElementById('classSelect').value;
      if(year && cls) {
        google.script.run.withSuccessHandler(function(data) {
          originalStudents = [...data];
          currentStudents = [...data];
          currentAngle = 0;
          document.getElementById('drawnArea').innerHTML = '';
          document.getElementById('spinBtn').disabled = false;
          drawWheel();
        }).getStudents(year, cls);
      } else {
        clearAll();
      }
    }

    function clearAll() {
      originalStudents = [];
      currentStudents = [];
      document.getElementById('drawnArea').innerHTML = '';
      drawEmptyWheel();
    }

    function resetSystem() {
      if (isSpinning || isWaiting || originalStudents.length === 0) return;
      currentStudents = [...originalStudents];
      currentAngle = 0;
      document.getElementById('drawnArea').innerHTML = '';
      document.getElementById('spinBtn').disabled = false;
      drawWheel();
    }

    function drawEmptyWheel() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.beginPath();
      ctx.arc(250, 250, 220, 0, 2 * Math.PI);
      ctx.fillStyle = '#E2E2E2';
      ctx.fill();
      ctx.fillStyle = '#888';
      ctx.font = 'bold 20px Noto Sans TC';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('請先選擇班級', 250, 250);
    }

    function drawWheel() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      let numSlices = currentStudents.length;
      
      if (numSlices === 0) {
        drawEmptyWheel();
        ctx.fillStyle = '#888';
        ctx.fillText('已全數抽出!', 250, 280);
        document.getElementById('spinBtn').disabled = true;
        return;
      }

      let sliceAngle = 2 * Math.PI / numSlices;

      for (let i = 0; i < numSlices; i++) {
        let startAngle = currentAngle + i * sliceAngle;
        let endAngle = startAngle + sliceAngle;

        ctx.beginPath();
        ctx.moveTo(250, 250);
        ctx.arc(250, 250, 220, startAngle, endAngle);
        ctx.closePath();
        ctx.fillStyle = colors[i % colors.length];
        ctx.fill();
        ctx.lineWidth = 2;
        ctx.strokeStyle = '#FFF';
        ctx.stroke();

        ctx.save();
        ctx.translate(250, 250);
        ctx.rotate(startAngle + sliceAngle / 2);
        ctx.textAlign = 'right';
        ctx.textBaseline = 'middle';
        ctx.fillStyle = '#33272A';
        ctx.font = 'bold 16px Noto Sans TC';
        let displayName = currentStudents[i].name;
        if(displayName.length > 4) displayName = displayName.substring(0,4) + '..';
        ctx.fillText(displayName, 200, 0);
        ctx.restore();
      }

      // 指標
      ctx.beginPath();
      ctx.moveTo(480, 250);
      ctx.lineTo(500, 235);
      ctx.lineTo(500, 265);
      ctx.closePath();
      ctx.fillStyle = '#FF5252';
      ctx.fill();
      ctx.strokeStyle = '#FFF';
      ctx.lineWidth = 2;
      ctx.stroke();
      
      // 中心點
      ctx.beginPath();
      ctx.arc(250, 250, 15, 0, 2 * Math.PI);
      ctx.fillStyle = '#FFF';
      ctx.fill();
    }

    function spin() {
      if (isSpinning || isWaiting || currentStudents.length === 0) return;
      
      // 初始化並喚醒音效引擎 (瀏覽器規定必須由使用者點擊後才能啟用音效)
      if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
      if (audioCtx.state === 'suspended') {
        audioCtx.resume();
      }

      isSpinning = true;
      document.getElementById('spinBtn').disabled = true;
      spinVelocity = Math.random() * 0.15 + 0.35; 
      lastWinnerIndex = -1;
      
      animateSpin();
    }

    function animateSpin() {
      if (spinVelocity > 0.001) {
        currentAngle += spinVelocity;
        spinVelocity *= 0.985; 
        
        let numSlices = currentStudents.length;
        let sliceAngle = 2 * Math.PI / numSlices;
        let normalizedAngle = currentAngle % (2 * Math.PI);
        // 計算目前指針位在哪個扇形區域
        let currentWinnerIndex = Math.floor((2 * Math.PI - normalizedAngle) / sliceAngle) % numSlices;
        
        // 當指針切換到新的扇形時,發出噠噠聲
        if (currentWinnerIndex !== lastWinnerIndex) {
          if (lastWinnerIndex !== -1) playTickSound(); 
          lastWinnerIndex = currentWinnerIndex;
        }

        drawWheel();
        requestAnimationFrame(animateSpin);
      } else {
        // 輪盤停止轉動
        isSpinning = false;
        isWaiting = true; // 進入等待三秒模式,此時按鈕依舊被鎖住
        spinVelocity = 0;
        
        // 播放中籤音效
        playWinSound();
        
        // 暫停3秒後,才把學生移到中籤區
        setTimeout(() => {
          determineWinner();
          isWaiting = false;
          // 如果還有學生,才恢復按鈕可以點擊
          if(currentStudents.length > 0) {
             document.getElementById('spinBtn').disabled = false;
          }
        }, 3000);
      }
    }

    function determineWinner() {
      let numSlices = currentStudents.length;
      let sliceAngle = 2 * Math.PI / numSlices;
      let normalizedAngle = currentAngle % (2 * Math.PI);
      let winnerIndex = Math.floor((2 * Math.PI - normalizedAngle) / sliceAngle) % numSlices;

      let winner = currentStudents[winnerIndex];
      addWinnerCard(winner);

      currentStudents.splice(winnerIndex, 1);
      drawWheel();
    }

    function addWinnerCard(student) {
      const container = document.getElementById('drawnArea');
      const card = document.createElement('div');
      
      let genderClass = '';
      if(student.gender === '男') genderClass = 'boy';
      else if(student.gender === '女') genderClass = 'girl';

      card.className = `student-card ${genderClass}`;
      card.innerHTML = `
        <div class="seat-no">${student.seat}</div>
        <div class="student-info">
          <div class="student-name">${student.name}</div>
        </div>
      `;
      container.insertBefore(card, container.firstChild);
    }
  </script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

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

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

💬 評論區

老師們的交流與提問

載入評論中...