0. 作品介紹

作者 張桂臻 西門國小

自訂行列並拖曳座位,整合抽籤、計時與同步列印。


16 0 0
班級經營
封面圖 封面

2. 資料表規則

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

第1分頁: 分頁1 A 欄: 班級 B 欄: 座號 C 欄: 姓名 D 欄: 註記 E 欄: 行 F 欄: 列

📄 解析結果(自動表格)

分頁:分頁1
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄
班級 座號 姓名 註記

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.createTemplateFromFile('index')
      .evaluate()
      .setTitle('作伙來上課 - 森林班級管理系統')
      .addMetaTag('viewport', 'width=device-width, initial-scale=1')
      .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// 讀取試算表資料(包含學生名單與班級尺寸設定)
function getSpreadsheetData() {
  try {
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var studentSheet = ss.getSheetByName("學生名單");
    if (!studentSheet) {
      return { success: false, msg: "找不到名為 '學生名單' 的工作表,請檢查名稱。" };
    }
    
    // 🔥 自動檢查並建立「班級設定」分頁(超強防呆)
    var configSheet = ss.getSheetByName("班級設定");
    if (!configSheet) {
      configSheet = ss.insertSheet("班級設定");
      configSheet.appendRow(["班級", "前後排數", "左右人數"]);
    }
    
    // 1. 抓取學生名單
    var studentData = studentSheet.getDataRange().getValues();
    var students = [];
    for (var i = 1; i < studentData.length; i++) {
      var row = studentData[i];
      if (!row[0]) continue; 
      students.push({
        "班級": String(row[0]).trim(),
        "座號": String(row[1]).trim(),
        "姓名": String(row[2]).trim(),
        "註記": row[3] ? String(row[3]).trim() : "",
        "組別": row[4] ? String(row[4]).trim() : "",
        "行": row[5] ? parseInt(row[5]) : "",
        "列": row[6] ? parseInt(row[6]) : ""
      });
    }
    
    // 2. 抓取班級尺寸設定
    var configData = configSheet.getDataRange().getValues();
    var configs = {};
    for (var i = 1; i < configData.length; i++) {
      var cRow = configData[i];
      if (!cRow[0]) continue;
      configs[String(cRow[0]).trim()] = {
        rows: cRow[1] ? parseInt(cRow[1]) : 6, // 預設 6 排
        cols: cRow[2] ? parseInt(cRow[2]) : 6  // 預設 6 人
      };
    }
    
    return { success: true, students: students, configs: configs };
  } catch(e) {
    return { success: false, msg: e.toString() };
  }
}

// 儲存特定班級的座位網格尺寸
function saveClassConfig(className, rows, cols) {
  try {
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var configSheet = ss.getSheetByName("班級設定");
    var data = configSheet.getDataRange().getValues();
    var found = false;
    
    for (var i = 1; i < data.length; i++) {
      if (String(data[i][0]).trim() === String(className).trim()) {
        configSheet.getRange(i + 1, 2).setValue(rows);
        configSheet.getRange(i + 1, 3).setValue(cols);
        found = true;
        break;
      }
    }
    if (!found) {
      configSheet.appendRow([className, rows, cols]);
    }
    return { success: true };
  } catch(e) {
    return { success: false, msg: e.toString() };
  }
}

// 儲存學生座位座標
function saveSeatPositions(positions) {
  try {
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("學生名單");
    var range = sheet.getDataRange();
    var data = range.getValues();
    
    var posMap = {};
    positions.forEach(function(p) {
      posMap[String(p.class).trim() + '_' + String(p.seatNo).trim()] = { r: p.row, c: p.col };
    });
    
    for (var i = 1; i < data.length; i++) {
      var key = String(data[i][0]).trim() + '_' + String(data[i][1]).trim();
      if (posMap[key]) {
        data[i][5] = posMap[key].r; 
        data[i][6] = posMap[key].c; 
      }
    }
    
    range.setValues(data);
    return { success: true };
  } catch(e) {
    return { success: false, msg: e.toString() };
  }
}

// 重置特定班級座位紀錄
function clearClassPositions(className) {
  try {
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("學生名單");
    var range = sheet.getDataRange();
    var data = range.getValues();
    
    for (var i = 1; i < data.length; i++) {
      if (String(data[i][0]).trim() === String(className).trim()) {
        data[i][5] = ""; 
        data[i][6] = ""; 
      }
    }
    
    range.setValues(data);
    return { success: true };
  } catch(e) {
    return { success: false, msg: e.toString() };
  }
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

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

index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <title>作伙來上課 - 森林管理系統</title>
  <style>
    :root {
      --forest-green: #3A5A40;
      --forest-dark: #273E2B;
      --wood-brown: #8B5A2B;
      --bark-dark: #4A3525;
      --honey-orange: #E9C46A;
      --paper-cream: #F4F0E6; 
      --card-bg: #FFFDF9;
      --reset-red: #BC4749; 
    }
    
    body {
      font-family: "Chalkboard SE", "Comic Sans MS", "Comic Sans", "華康少女體", "Noto Sans TC", sans-serif;
      background-color: var(--paper-cream);
      color: var(--bark-dark);
      margin: 0;
      padding: 20px;
      background-image: radial-gradient(rgba(74, 53, 37, 0.04) 1px, transparent 0); 
      background-size: 24px 24px;
    }

    header {
      background-color: var(--forest-green);
      color: white;
      padding: 15px 25px;
      border: 3px solid var(--bark-dark);
      border-radius: 255px 15px 225px 15px/15px 225px 15px 255px; 
      display: flex;
      justify-content: space-between;
      align-items: center;
      box-shadow: 4px 4px 0px var(--bark-dark);
      margin-bottom: 25px;
    }

    header h1 {
      margin: 0;
      font-size: 26px;
      color: var(--honey-orange);
      display: flex;
      align-items: center;
      gap: 10px;
      text-shadow: 2px 2px 0px rgba(0,0,0,0.2);
    }

    .controls {
      display: flex;
      gap: 10px;
      align-items: center;
      font-size: 14px;
      font-weight: bold;
    }

    .size-input-group {
      display: flex;
      align-items: center;
      gap: 4px;
      background: rgba(255,255,255,0.15);
      padding: 4px 8px;
      border-radius: 10px;
      border: 1.5px solid var(--bark-dark);
    }

    select, input[type="text"], input[type="number"] {
      padding: 6px 10px;
      border: 2px solid var(--bark-dark);
      border-radius: 20px 10px 20px 10px/10px 20px 10px 20px; 
      font-size: 14px;
      outline: none;
      background-color: #FFF;
      font-family: inherit;
    }

    select:focus, input:focus {
      background-color: #FFFDF0;
    }

    button {
      background-color: var(--honey-orange);
      color: var(--bark-dark);
      border: 2px solid var(--bark-dark);
      padding: 8px 14px;
      border-radius: 15px 5px 15px 5px/5px 15px 5px 15px;
      font-weight: bold;
      font-family: inherit;
      cursor: pointer;
      box-shadow: 2px 2px 0px var(--bark-dark);
      transition: all 0.1s;
    }

    button:hover {
      transform: translate(-1px, -1px);
      box-shadow: 3px 3px 0px var(--bark-dark);
    }
    
    button:active {
      transform: translate(1px, 1px);
      box-shadow: 1px 1px 0px var(--bark-dark);
    }

    .main-container {
      display: grid;
      grid-template-columns: 3fr 1fr;
      gap: 25px;
    }

    .classroom-box {
      background: #FFFDF4;
      padding: 25px;
      border: 3px solid var(--bark-dark);
      border-radius: 30px 15px 40px 20px / 20px 40px 15px 30px;
      box-shadow: 5px 5px 0px var(--bark-dark);
      display: flex;
      flex-direction: column;
      position: relative;
    }
    
    .classroom-box::after {
      content: "🦊🌳🐰🍃";
      position: absolute;
      top: -18px;
      right: 25px;
      font-size: 24px;
      background: var(--paper-cream);
      padding: 0 8px;
    }

    .podium {
      background-color: #D2B48C;
      color: var(--bark-dark);
      text-align: center;
      padding: 10px;
      border: 3px solid var(--bark-dark);
      border-radius: 60px 40px 70px 50px / 15px 15px 15px 15px;
      font-weight: bold;
      margin: 25px auto 0 auto;
      width: 220px;
      box-shadow: 3px 3px 0px var(--bark-dark);
      order: 2;
      background-image: repeating-radial-gradient(circle, #C5A070, #D2B48C 20px);
    }

    /* 🔥 移除固定的 6x6 改由 JS 控管動態直排橫列數 */
    .grid-dynamic {
      display: grid;
      gap: 14px;
      order: 1;
    }

    .seat-slot {
      background-color: #EAF2E8;
      border: 2px dashed #A2B997;
      border-radius: 12px;
      aspect-ratio: 1 / 1;
      display: flex;
      justify-content: center;
      align-items: center;
      position: relative;
    }

    .student-card {
      background-color: var(--card-bg);
      border: 2.5px solid var(--bark-dark);
      border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
      width: 100%;
      height: 100%;
      box-shadow: 3px 3px 0px rgba(74, 53, 37, 0.15);
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      cursor: grab;
      user-select: none;
      transition: transform 0.1s, box-shadow 0.1s;
      box-sizing: border-box;
      padding: 4px;
      text-align: center;
    }

    .student-card:active {
      cursor: grabbing;
      transform: scale(1.03) rotate(2deg);
    }

    .student-card.picked {
      animation: flash-card 0.4s ease 5;
      background-color: #FFF0F0;
      border-color: #E53E3E;
    }

    @keyframes flash-card {
      0%, 100% { background-color: var(--card-bg); transform: scale(1); }
      50% { background-color: #FFC0CB; transform: scale(1.08) rotate(-2deg); }
    }

    .student-no {
      font-size: 11px;
      color: #8B7355;
      font-weight: bold;
    }

    .student-name {
      font-size: 17px;
      font-weight: bold;
      margin: 1px 0;
      color: var(--bark-dark);
    }

    .student-note {
      font-size: 11px;
      color: #C0392B;
      background-color: #FDF2F2;
      border: 1px solid #F5C6CB;
      padding: 1px 5px;
      border-radius: 10px;
      max-width: 90%;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .hide-notes .student-note {
      display: none !important;
    }

    .sidebar {
      display: flex;
      flex-direction: column;
      gap: 20px;
    }

    .tool-card {
      background: #FFFDF9;
      padding: 20px;
      border: 2.5px solid var(--bark-dark);
      border-radius: 15px 25px 12px 20px / 20px 12px 25px 15px;
      box-shadow: 4px 4px 0px var(--bark-dark);
    }

    .tool-card h3 {
      margin-top: 0;
      border-bottom: 3px dashed var(--primary);
      padding-bottom: 6px;
      margin-bottom: 15px;
      color: var(--forest-green);
      display: flex;
      align-items: center;
      gap: 5px;
    }

    .timer-display {
      font-size: 38px;
      font-weight: bold;
      text-align: center;
      font-family: monospace;
      margin: 10px 0;
      color: var(--bark-dark);
      background: #F4EFE3;
      padding: 5px;
      border-radius: 8px;
      border: 1.5px solid var(--bark-dark);
    }
    
    .timer-alert {
      animation: blink-bg 0.5s infinite alternate;
    }
    @keyframes blink-bg {
      0% { background-color: #FFFDF9; }
      100% { background-color: #FFD2D2; }
    }

    .timer-inputs {
      display: flex;
      gap: 5px;
      justify-content: center;
      margin-bottom: 10px;
      align-items: center;
    }
    .timer-inputs input {
      width: 45px;
      text-align: center;
    }

    .btn-group {
      display: flex;
      gap: 5px;
      justify-content: center;
      flex-wrap: wrap;
    }

    .lucky-result {
      font-size: 19px;
      font-weight: bold;
      color: #A0522D;
      text-align: center;
      margin-top: 12px;
      min-height: 27px;
    }
    
    .pool-count {
      font-size: 12px;
      color: #7A6048;
      text-align: center;
      margin-bottom: 8px;
    }

    #print-zone {
      display: none;
    }

    @media print {
      body * {
        visibility: hidden;
      }
      #print-zone, #print-zone * {
        visibility: visible;
      }
      #print-zone {
        display: block;
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
      }
      .print-page {
        page-break-after: always;
        padding: 20px;
        display: flex;
        flex-direction: column;
      }
      .print-grid {
        display: grid;
        gap: 15px;
        order: 1;
      }
      .print-card {
        border: 2px solid #000;
        border-radius: 10px;
        padding: 10px 5px;
        text-align: center;
        aspect-ratio: 1/1;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
      }
      .print-title {
        font-size: 24px;
        font-weight: bold;
        text-align: center;
        margin-bottom: 20px;
      }
      .print-podium {
        text-align: center;
        font-weight: bold;
        margin-top: 20px;
        padding: 8px;
        border: 2px solid black;
        border-radius: 20px;
        width: 160px;
        margin-left: auto;
        margin-right: auto;
        order: 2;
      }
    }
  </style>
</head>
<body>

  <header id="header-bar">
    <h1>🌲 作伙來上課 <span id="title-class-sub"></span></h1>
    <div class="controls">
      <select id="class-select" onchange="switchClass()">
        <option value="">讀取森林班級中...</option>
      </select>
      
      <div class="size-input-group">
        <span>前後排數:</span>
        <input type="number" id="class-rows" min="1" max="10" value="6" style="width: 40px;" onchange="updateClassSizeConfig()">
        <span style="margin-left:5px;">左右人數:</span>
        <input type="number" id="class-cols" min="1" max="10" value="6" style="width: 40px;" onchange="updateClassSizeConfig()">
      </div>

      <input type="text" id="subject-input" placeholder="🐿️ 填寫科目(如:閩語)" oninput="updateTitle()">
      <button onclick="toggleNotes()">🍃 顯示註記</button>
      <button onclick="resetClassSeats()" style="background-color: var(--reset-red); color: white;">🔄 重置本班座位</button>
      <button onclick="openPrintMenu()">🖨️ 列印座位表</button>
    </div>
  </header>

  <div class="main-container" id="main-view">
    <div class="classroom-box">
      <div class="podium">🪵 講台</div>
      <div class="grid-dynamic" id="seating-grid"></div>
    </div>

    <div class="sidebar">
      <div class="tool-card" id="timer-card">
        <h3>⏱️ 森林計時器</h3>
        <div class="timer-inputs">
          <input type="number" id="timer-min" min="0" value="5">分
          <input type="number" id="timer-sec" min="0" max="59" value="0">秒
        </div>
        <div class="timer-display" id="timer-clock">05:00</div>
        <div class="btn-group">
          <button onclick="startTimer(true)">倒數</button>
          <button onclick="startTimer(false)">正計</button>
          <button onclick="pauseTimer()">暫停</button>
          <button onclick="resetTimer()">重置</button>
        </div>
      </div>

      <div class="tool-card">
        <h3>🎰 幸運抽松果</h3>
        <div class="pool-count" id="pool-info">剩餘動物池:0 人</div>
        <div style="display: flex; gap: 8px; margin-bottom: 10px; justify-content: center; font-size: 13px;">
          <label><input type="number" id="draw-num" value="1" min="1" style="width: 35px;"> 人</label>
          <label><input type="checkbox" id="no-repeat-chk" checked> 不重複</label>
        </div>
        <div class="btn-group">
          <button onclick="drawLucky()" style="background-color: #E76F51; color:white;">開始抽籤</button>
          <button onclick="resetLuckyPool()">重置籤池</button>
        </div>
        <div class="lucky-result" id="lucky-display"></div>
      </div>
    </div>
  </div>

  <div id="print-zone"></div>

  <script>
    let allRawData = [];
    let allConfigs = {}; // 存放各班級的行列尺寸對照表
    let currentClass = "";
    let studentPool = [];
    let timerInterval = null;
    let timeRemaining = 300;
    let isCountdown = true;
    let audioCtx = null;

    window.onload = function() {
      google.script.run.withSuccessHandler(function(response) {
        if(response.success) {
          allRawData = response.students;
          allConfigs = response.configs;
          populateClassSelect();
        } else {
          alert("讀取失敗:" + response.msg);
        }
      }).getSpreadsheetData();
    };

    // 建立指定尺寸的動態格子
    function createDynamicGrid(rows, cols) {
      const grid = document.getElementById('seating-grid');
      grid.innerHTML = '';
      grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; // 動態設定寬度
      
      let totalSlots = rows * cols;
      for (let i = 0; i < totalSlots; i++) {
        const slot = document.createElement('div');
        slot.className = 'seat-slot';
        slot.dataset.slotIndex = i;
        slot.addEventListener('dragover', allowDrop);
        slot.addEventListener('drop', handleDrop);
        grid.appendChild(slot);
      }
    }

    function populateClassSelect() {
      const select = document.getElementById('class-select');
      select.innerHTML = '';
      const classes = [...new Set(allRawData.map(item => item.班級))];
      
      if(classes.length === 0) {
        select.innerHTML = '<option>尚無班級資料</option>';
        return;
      }
      
      classes.forEach(c => {
        const opt = document.createElement('option');
        opt.value = c;
        opt.innerText = c + ' 班';
        select.appendChild(opt);
      });
      switchClass();
    }

    function switchClass() {
      currentClass = document.getElementById('class-select').value;
      updateTitle();
      if (!currentClass) return;

      document.getElementById('seating-grid').innerHTML = '<div style="grid-column: span 3; text-align:center; padding:20px; color:#7A6048;">🌲 森林夥伴報到中...</div>';

      google.script.run.withSuccessHandler(function(response) {
        if(response.success) {
          allRawData = response.students;
          allConfigs = response.configs;
          
          // 載入該班級專屬尺寸設定(若無則預設 6x6)
          let clsConfig = allConfigs[currentClass] || { rows: 6, cols: 6 };
          document.getElementById('class-rows').value = clsConfig.rows;
          document.getElementById('class-cols').value = clsConfig.cols;
          
          resetLuckyPool();
          renderSeats();
        } else {
          alert("同步失敗:" + response.msg);
        }
      }).getSpreadsheetData();
    }

    // 當老師在網頁上修改前後排數或左右人數時觸發
    function updateClassSizeConfig() {
      if(!currentClass) return;
      let rows = parseInt(document.getElementById('class-rows').value) || 6;
      let cols = parseInt(document.getElementById('class-cols').value) || 6;
      
      // 更新本機快取
      allConfigs[currentClass] = { rows: rows, cols: cols };
      
      // 存回試算表背景
      google.script.run.withSuccessHandler(function(res){
        if(res.success) {
          renderSeats(); // 重新畫圖對齊
        }
      }).saveClassConfig(currentClass, rows, cols);
    }

    function updateTitle() {
      const sub = document.getElementById('subject-input').value;
      const subText = sub ? ` - 【${sub}】` : '';
      document.getElementById('title-class-sub').innerText = `[ ${currentClass} 班 ] ${subText}`;
    }

    function renderSeats() {
      let totalRows = parseInt(document.getElementById('class-rows').value) || 6;
      let totalCols = parseInt(document.getElementById('class-cols').value) || 6;
      
      createDynamicGrid(totalRows, totalCols);
      
      const students = allRawData.filter(item => item.班級 == currentClass);
      const slots = document.querySelectorAll('.seat-slot');
      let unassigned = [];
      
      students.forEach(stu => {
        let r = parseInt(stu.行);
        let c = parseInt(stu.列);
        
        // 檢查是否有儲存紀錄,且不能超出老師剛剛自訂的尺寸界限
        if(r >= 1 && r <= totalRows && c >= 1 && c <= totalCols) {
          let gridRowIndex = totalRows - r; // 核心排列模型:第1排在網格底部
          let idx = gridRowIndex * totalCols + (c - 1);
          if (idx >= 0 && idx < slots.length) {
            const card = createStudentCard(stu);
            slots[idx].appendChild(card);
          } else {
            unassigned.push(stu);
          }
        } else {
          unassigned.push(stu);
        }
      });

      unassigned.sort((a, b) => parseInt(a.座號) - parseInt(b.座號));

      unassigned.forEach(stu => {
        let placed = false;
        for (let r = 1; r <= totalRows; r++) {
          let gridRowIndex = totalRows - r;
          for (let c = 1; c <= totalCols; c++) {
            let idx = gridRowIndex * totalCols + (c - 1);
            if (idx < slots.length && !slots[idx].firstElementChild) {
              const card = createStudentCard(stu);
              slots[idx].appendChild(card);
              stu.行 = r;
              stu.列 = c;
              placed = true;
              break;
            }
          }
          if (placed) break;
        }
      });
    }

    function resetClassSeats() {
      if(!currentClass) return;
      if(!confirm(`確定要重置 [ ${currentClass} 班 ] 的座位排列嗎?\n這會清除此班級在試算表中的行列紀錄,並重新從第一排(最下方)依序排起。`)) return;
      
      document.getElementById('seating-grid').innerHTML = '<div style="text-align:center; padding:20px; color:#7A6048;">正在清除舊紀錄並重新排列...</div>';
      
      google.script.run.withSuccessHandler(function(response) {
        if(response.success) {
          google.script.run.withSuccessHandler(function(res) {
            if(res.success) {
              allRawData = res.students;
              renderSeats();
            }
          }).getSpreadsheetData();
        } else {
          alert("重置失敗:" + response.msg);
        }
      }).clearClassPositions(currentClass);
    }

    function createStudentCard(stu) {
      const card = document.createElement('div');
      card.className = 'student-card';
      card.id = `stu-${stu.座號}`;
      card.draggable = true;
      card.dataset.seatNo = stu.座號;
      card.dataset.name = stu.姓名;
      
      card.innerHTML = `
        <div class="student-no">N°.${stu.座號}</div>
        <div class="student-name">${stu.姓名 || '無姓名'}</div>
        <div class="student-note" title="${stu.註記 || ''}">${stu.註記 || ''}</div>
      `;
      
      card.addEventListener('dragstart', handleDragStart);
      return card;
    }

    function toggleNotes() {
      document.getElementById('main-view').classList.toggle('hide-notes');
    }

    let draggedCard = null;
    function handleDragStart(e) {
      draggedCard = this;
      e.dataTransfer.setData('text/plain', this.id);
    }

    function allowDrop(e) {
      e.preventDefault();
    }

    function handleDrop(e) {
      e.preventDefault();
      let slot = e.target;
      while(slot && !slot.classList.contains('seat-slot')) {
        slot = slot.parentElement;
      }
      if(!slot || !draggedCard) return;

      if(slot.firstElementChild) {
        let occupant = slot.firstElementChild;
        let originSlot = draggedCard.parentElement;
        originSlot.appendChild(occupant);
        slot.appendChild(draggedCard);
      } else {
        slot.appendChild(draggedCard);
      }
      autoSavePositions();
    }

    function autoSavePositions() {
      let totalRows = parseInt(document.getElementById('class-rows').value) || 6;
      let totalCols = parseInt(document.getElementById('class-cols').value) || 6;
      
      let positions = [];
      const slots = document.querySelectorAll('.seat-slot');
      slots.forEach(slot => {
        if(slot.firstElementChild) {
          let idx = parseInt(slot.dataset.slotIndex);
          let gridRowIndex = Math.floor(idx / totalCols);
          let r = totalRows - gridRowIndex; // 基於動態排數的反轉換算
          let c = (idx % totalCols) + 1;
          let seatNo = slot.firstElementChild.dataset.seatNo;
          positions.push({
            class: currentClass,
            seatNo: seatNo,
            row: r,
            col: c
          });
          let match = allRawData.find(item => item.班級 == currentClass && item.座號 == seatNo);
          if(match) { match.行 = r; match.列 = c; }
        }
      });
      if(positions.length > 0) {
        google.script.run.saveSeatPositions(positions);
      }
    }

    // --- 工具箱功能保持成功完美版 ---
    function startTimer(countdownMode) {
      clearInterval(timerInterval);
      isCountdown = countdownMode;
      document.getElementById('timer-card').classList.remove('timer-alert');
      
      if(isCountdown) {
        let mins = parseInt(document.getElementById('timer-min').value) || 0;
        let secs = parseInt(document.getElementById('timer-sec').value) || 0;
        timeRemaining = mins * 60 + secs;
      } else {
        timeRemaining = 0;
      }
      
      updateTimerDisplay();
      timerInterval = setInterval(function() {
        if(isCountdown) {
          if(timeRemaining <= 0) {
            clearInterval(timerInterval);
            triggerAlarm();
          } else {
            timeRemaining--;
          }
        } else {
          timeRemaining++;
        }
        updateTimerDisplay();
      }, 1000);
    }

    function pauseTimer() { clearInterval(timerInterval); }
    function resetTimer() {
      clearInterval(timerInterval);
      document.getElementById('timer-card').classList.remove('timer-alert');
      document.getElementById('timer-min').value = 5;
      document.getElementById('timer-sec').value = 0;
      document.getElementById('timer-clock').innerText = "05:00";
    }
    function updateTimerDisplay() {
      let m = Math.floor(Math.abs(timeRemaining) / 60);
      let s = Math.abs(timeRemaining) % 60;
      document.getElementById('timer-clock').innerText = (m < 10 ? '0':'') + m + ':' + (s < 10 ? '0':'') + s;
    }
    function triggerAlarm() {
      document.getElementById('timer-card').classList.add('timer-alert');
      try {
        if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        let osc = audioCtx.createOscillator();
        let gain = audioCtx.createGain();
        osc.connect(gain);
        gain.connect(audioCtx.destination);
        osc.type = 'sine';
        osc.frequency.value = 800; 
        gain.gain.setValueAtTime(0.5, audioCtx.currentTime);
        osc.start();
        osc.stop(audioCtx.currentTime + 1);
      } catch(e) {}
    }

    function resetLuckyPool() {
      const students = allRawData.filter(item => item.班級 == currentClass);
      studentPool = students.map(s => ({ seatNo: s.座號, name: s.姓名 }));
      updatePoolInfo();
      document.getElementById('lucky-display').innerText = "";
    }
    function updatePoolInfo() { document.getElementById('pool-info').innerText = `剩餘動物池:${studentPool.length} 人`; }
    function drawLucky() {
      document.querySelectorAll('.student-card').forEach(c => c.classList.remove('picked'));
      const students = allRawData.filter(item => item.班級 == currentClass);
      if(students.length === 0) return;
      
      let count = parseInt(document.getElementById('draw-num').value) || 1;
      let noRepeat = document.getElementById('no-repeat-chk').checked;
      let winners = [];

      if(noRepeat) {
        if(studentPool.length === 0) { alert("抽籤池已空!系統為您自動重置。"); resetLuckyPool(); }
        let realCount = Math.min(count, studentPool.length);
        for(let i=0; i<realCount; i++) {
          let randIdx = Math.floor(Math.random() * studentPool.length);
          winners.push(studentPool.splice(randIdx, 1)[0]);
        }
        updatePoolInfo();
      } else {
        for(let i=0; i<count; i++) {
          let randIdx = Math.floor(Math.random() * students.length);
          winners.push({ seatNo: students[randIdx].座號, name: students[randIdx].姓名 });
        }
      }
      if(winners.length > 0) {
        document.getElementById('lucky-display').innerText = "🎉 中籤者:" + winners.map(w => w.name).join(', ');
        winners.forEach(w => {
          let card = document.getElementById(`stu-${w.seatNo}`);
          if(card) card.classList.add('picked');
        });
      }
    }

    function openPrintMenu() {
      const classes = [...new Set(allRawData.map(item => item.班級))];
      let promptMsg = "請輸入您想列印的班級(多個班級請用逗號隔開)\n\n可選班級有:" + classes.join(', ') + "\n";
      let inputClasses = prompt(promptMsg, currentClass);
      if(!inputClasses) return;
      
      let targetClasses = inputClasses.split(/[,,]/).map(c => c.trim());
      const sub = document.getElementById('subject-input').value;
      const subText = sub ? ` - 【${sub}】` : '';
      const isNotesHidden = document.getElementById('main-view').classList.contains('hide-notes');

      let printZone = document.getElementById('print-zone');
      printZone.innerHTML = '';

      targetClasses.forEach(cls => {
        const students = allRawData.filter(item => item.班級 == cls);
        if(students.length === 0) return;

        // 🌟 列印時動態讀取各班專屬尺寸
        let clsConfig = allConfigs[cls] || { rows: 6, cols: 6 };
        let pRows = clsConfig.rows;
        let pCols = clsConfig.cols;

        let page = document.createElement('div');
        page.className = 'print-page';
        page.innerHTML = `<div class="print-title">🎒 作伙來上課 - [ ${cls} 班 ] ${subText} 座位表</div>`;
        
        let printGrid = document.createElement('div');
        printGrid.className = 'print-grid';
        printGrid.style.gridTemplateColumns = `repeat(${pCols}, 1fr)`; // 動態寬度

        let totalSlots = pRows * pCols;
        let gridArray = Array(totalSlots).fill(null);
        let unassigned = [];

        students.forEach(stu => {
          let r = parseInt(stu.行);
          let c = parseInt(stu.列);
          if(r >= 1 && r <= pRows && c >= 1 && c <= pCols) {
            let gridRowIndex = pRows - r;
            let idx = gridRowIndex * pCols + (c - 1);
            if(idx >= 0 && idx < totalSlots) gridArray[idx] = stu;
          } else {
            unassigned.push(stu);
          }
        });
        
        unassigned.sort((a, b) => parseInt(a.座號) - parseInt(b.座號));
        unassigned.forEach(stu => {
          for(let i=0; i<totalSlots; i++) {
            if(!gridArray[i]) { gridArray[i] = stu; break; }
          }
        });

        for(let i=0; i<totalSlots; i++) {
          let pCard = document.createElement('div');
          pCard.className = 'print-card';
          let stu = gridArray[i];
          if(stu) {
            let noteHtml = (!isNotesHidden && stu.註記) ? `<div style="font-size:11px; color:#555; border:1px solid #ccc; border-radius:3px; margin-top:2px; padding:0 2px;">${stu.註記}</div>` : '';
            pCard.innerHTML = `
              <div style="font-size:12px;">${stu.座號}</div>
              <div style="font-size:18px; font-weight:bold;">${stu.姓名}</div>
              ${noteHtml}
            `;
          }
          printGrid.appendChild(pCard);
        }

        page.appendChild(printGrid);
        let pPodium = document.createElement('div');
        pPodium.className = 'print-podium';
        pPodium.innerText = '講台';
        page.appendChild(pPodium);
        
        printZone.appendChild(page);
      });
      window.print();
    }
  </script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

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

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

💬 評論區

老師們的交流與提問

載入評論中...