2025年2月18日 星期二

讓訓育組提早下班系列:社團報名表 - 限制名額、依時間開關 + 一鍵更新社團資料 + 寄送郵錄取通知


擔任資訊組長前的上一個行政工作是訓育組,
當時學校社團並沒有那麼多,反觀現在學生真的很幸福,
但社團愈多就表示承辦的訓育組長工作就會愈繁重!
此時如果沒有一個好用的調查系統,那對承辦人來說真的會是一個問題。
還好現在有好用的 Google Workspace for Education 可以運用, 
用 Google 表單的好處是原本就可以限制校內網域帳號和可否重複報名
如果再加上 Google Apps Script 的加持那就真的無所不能了。
快來看看如何運用 生成式 AI 設定 名額 和 時間限制 的報名表吧!




👉👉 複製報名表單👈👈、👉👉 複製基本資料試算表👈👈

這次運用了 Google表單(使者介面) + 試算表(基本資料) 來設計這個報名表:
1️⃣ 請用下拉式選單設定表單社團選項:如果使用者報名就會扣除一個名額,名額都報名完該選項就會消失,使用者就無法報名以達到名額限制。
2️⃣ 因為我這邊 GAS 程式將社團下拉式選項設定在第三個題目(報名表D欄) 這邊要留意!不然就是表單設計後記得把它移動到 D欄 的位置


3️⃣ 基本資料:這個工作表可以填入社團名稱和最大人數,因為 GAS 程式中是設定梯次和社團名稱對應,所以這邊梯次名稱序號還是需要

4️⃣ 開放、關閉時間格式:2025/02/1 10:00 


5️⃣ 記得將 表單ID試算表ID 替換成自己的版本


 Google 表單 - Apps Script

// ==========================================
// ⚙️ 全域設定區 (只需在這裡修改一次,下方所有函式皆會套用)
// ==========================================
const FORM_ID = 'Google 表單 ID';       // Google 表單 ID
const SPREADSHEET_ID = 'Google 試算表 ID'; // Google 試算表 ID
const QUESTION_TITLE = "選擇梯次";                                     // 表單中的題目名稱
const DATA_SHEET_NAME = '基本資料';                                    // 試算表頁籤名稱
const LOG_SHEET_NAME = '報名記錄';                                     // 試算表頁籤名稱
// ==========================================

function onOpen() {
  FormApp.getUi()
      .createMenu('🎯更新表單')
      .addItem('🚀更新表單', 'updateForm')
      .addToUi();
}

function updateForm() {
  // 直接使用上方的全域變數,不需再 var formId = ...
  var form = FormApp.openById(FORM_ID);
  var ss = SpreadsheetApp.openById(SPREADSHEET_ID);
 
  var dataSheet = ss.getSheetByName(DATA_SHEET_NAME);
  var registrationSheet = ss.getSheetByName(LOG_SHEET_NAME);

  if (!dataSheet || !registrationSheet) {
      Logger.log("⚠️ 工作表名稱錯誤,請確認 '" + DATA_SHEET_NAME + "' 和 '" + LOG_SHEET_NAME + "' 是否存在");
      return;
  }

  var workshopData = dataSheet.getDataRange().getValues();
  var registrationData = registrationSheet.getDataRange().getValues();

  if (workshopData.length < 2) {
      Logger.log("⚠️ 基本資料工作表無資料,請確認是否已輸入梯次名稱和人數上限");
      return;
  }

  var workshopInfo = {};
  var nameMapping = {};
  var startTime, endTime;
  var currentTime = new Date(); // 取得當前時間

  // 讀取設定與資料
  for (var i = 1; i < workshopData.length; i++) {
      // 第一列讀取時間設定
      if (i == 1) {
          startTime = workshopData[1][3]; // D 欄
          endTime = workshopData[1][4];   // E 欄
      }

      var originalName = (workshopData[i][0] || "").trim().toLowerCase(); // A 欄
      var maxParticipants = parseInt(workshopData[i][1], 10); // B 欄
      var standardName = (workshopData[i][2] || "").trim().toLowerCase(); // C 欄
     
      if (originalName && standardName && !isNaN(maxParticipants)) {
          nameMapping[originalName] = standardName;
          workshopInfo[standardName] = { max: maxParticipants, count: 0 };
      }
  }

  // 時間格式轉換
  if (startTime) startTime = new Date(startTime);
  if (endTime) endTime = new Date(endTime);

  Logger.log("📆 填報開始時間:" + startTime);
  Logger.log("⏳ 填報結束時間:" + endTime);
  Logger.log("🕒 當前時間:" + currentTime);

  // **檢查填報時間**
  if (startTime && currentTime < startTime) {
      Logger.log("🚫 尚未到填報時間,關閉表單填報");
      form.setAcceptingResponses(false);
      return;
  }
 
  if (endTime && currentTime > endTime) {
      Logger.log("🚫 已超過填報結束時間,關閉表單填報");
      form.setAcceptingResponses(false);
      return;
  }

  // 如果時間有效,開啟表單
  form.setAcceptingResponses(true);
  Logger.log("✅ 開放填報");
 
  // 計算報名人數
  if (registrationData.length >= 2) {
      for (var i = 1; i < registrationData.length; i++) {
          var workshop = String(registrationData[i][4] || "").trim().toLowerCase();
          workshop = workshop.replace(/\s*\(.*?\)\s*/g, ""); // 移除 "(還有 X 名額)"
         
          if (nameMapping[workshop]) {
              workshop = nameMapping[workshop];
          }

          if (workshop && workshopInfo.hasOwnProperty(workshop)) {
              workshopInfo[workshop].count++;
          }
      }
  } else {
      Logger.log("⚠️ 報名記錄無資料,視為無人報名");
  }

  Logger.log("📊 梯次報名狀況:" + JSON.stringify(workshopInfo));

  // 更新 Google 表單選項
  var items = form.getItems();
  var targetItem = items.find(item => item.getTitle().trim() === QUESTION_TITLE);

  if (!targetItem) {
      Logger.log("⚠️ 找不到 '" + QUESTION_TITLE + "' 題目,請確認表單是否有該選項");
      return;
  }

  var listItem = targetItem.asListItem();
  var choices = [];

  Object.keys(workshopInfo).forEach(function(option) {
      var max = workshopInfo[option].max;
      var count = workshopInfo[option].count;
      var available = max - count;

      if (available > 0) {
          choices.push(option + ' (剩餘 ' + available + ' 名額)');
      }
  });

  if (choices.length === 0) {
      choices.push('所有梯次已額滿');
  }

  Logger.log("✅ 更新表單選項:" + JSON.stringify(choices));
  listItem.setChoiceValues(choices);
}

// 表單提交時自動執行
function onFormSubmit(e) {
  updateForm();
}

function createTimedTriggers() {
  var ss = SpreadsheetApp.openById(SPREADSHEET_ID);
  var dataSheet = ss.getSheetByName(DATA_SHEET_NAME);

  var startTime = dataSheet.getRange("D2").getValue();
  var endTime = dataSheet.getRange("E2").getValue();  

  if (!(startTime instanceof Date) || !(endTime instanceof Date)) {
      Logger.log("⚠️ 時間格式錯誤,請確認試算表 D2 和 E2 格式");
      return;
  }

  // 先清除舊的觸發器,避免重複建立
  deleteTriggerByName('openForm');
  deleteTriggerByName('closeForm');

  // 建立開啟觸發器
  ScriptApp.newTrigger("openForm")
    .timeBased()
    .at(startTime)
    .create();

  // 建立關閉觸發器
  ScriptApp.newTrigger("closeForm")
    .timeBased()
    .at(endTime)
    .create();

  Logger.log("✅ 已建立定時觸發器:開啟於 " + startTime + ",關閉於 " + endTime);
}

function openForm() {
  var form = FormApp.openById(FORM_ID);
  form.setAcceptingResponses(true);
  Logger.log("✅ 表單已開啟填報");
 
  // 執行完畢後刪除此觸發器
  deleteTriggerByName('openForm');
}

function closeForm() {
  var form = FormApp.openById(FORM_ID);
  form.setAcceptingResponses(false);
  Logger.log("⛔ 表單已關閉填報");
 
  // 執行完畢後刪除此觸發器
  deleteTriggerByName('closeForm');
}

// 🛠️ 刪除指定名稱的觸發器 (修正後的邏輯)
function deleteTriggerByName(functionName) {
  var triggers = ScriptApp.getProjectTriggers();
 
  triggers.forEach(function(trigger) {
    if (trigger.getHandlerFunction() === functionName) {
      ScriptApp.deleteTrigger(trigger);
      Logger.log("🗑️ 已刪除觸發器: " + functionName);
    }
  });
}


6️⃣ 設定好表單的 Apps Script 後還需要設定二個觸發條件
6-1、觸發條件1:提出表單後執行 updateForm 程式
  • 選擇您要執行的功能 - onFormSumit
  • 將會執行的部署作業 - 上端
  • 選取活動來源 - 表單
  • 選取活動類型 - 提交表單時
  • 錯誤通知設定 - 每天通知我

6-2、觸發條件2:有設定開放和停止時間,所以需要每分鐘都檢查程式設定的時間
  • 選擇您要執行的功能 - updateForm
  • 將會執行的部署作業 - 上端
  • 選取活動來源 - 時間驅動
  • 選取時間型觸發條件類型 - 分鐘計時器
  • 選取分鐘間隔 - 每分鐘
  • 錯誤通知設定 - 每天通知我


7️⃣ 全部設定好後 再發佈表單後,就可以 🎯更新表單 ➜ 試算表社團資料到表單中


 Google 試算表 - 寄送郵錄取通知

/**
 * Google Sheet 自動寄發錄取通知信 (修正版)
 * 修正點:強制清除 Email 前後空白與換行,避免 Invalid To header 錯誤
 */
function sendEmailOnAdmit(e) {
  // 防止直接在編輯器執行
  if (!e) {
    Logger.log("錯誤:請勿直接按執行。請到試算表勾選 F 欄位來觸發。");
    return;
  }

  var range = e.range;
  var sheet = range.getSheet();
  var row = range.getRow();
  var col = range.getColumn();
  var value = range.getValue();

  // 觸發條件:F欄 (第6欄) + 不是標題列 + 被勾選 (TRUE)
  if (col === 6 && row > 1 && value === true) {

    var statusCell = sheet.getRange(row, 7); // G欄:狀態欄

    // 1. 檢查是否已寄過
    if (statusCell.getValue() != "") {
      SpreadsheetApp.getActive().toast("此列 G 欄已有文字,略過不寄信。", "略過");
      return;
    }

    // 2. 取得資料 (並進行資料清理)
    var name = sheet.getRange(row, 2).getValue();      // B欄
    var rawEmail = sheet.getRange(row, 4).getValue();  // D欄
    var rawCourse = sheet.getRange(row, 5).getValue(); // E欄

    // *** 強制轉文字並修剪空白 (關鍵修正) ***
    var email = String(rawEmail).trim();
    var courseName = String(rawCourse).replace(/\s*[(\(]剩餘.*?[)\)]/g, "").trim();

    // 3. 嚴格檢查 Email 是否有效
    if (email === "" || email.indexOf("@") === -1) {
      statusCell.setValue("錯誤:Email 無效 (" + email + ")");
      SpreadsheetApp.getActive().toast("Email 格式錯誤,無法寄信", "失敗");
      return;
    }

    // 4. 設定信件內容
    var subject = "【錄取通知】" + courseName;
    var body = "親愛的 " + name + " 您好:\n\n" +
               "恭喜您!您報名的「" + courseName + "」已經確定錄取。\n" +
               "請依照規定時間準時出席。\n\n" +
               "(此為系統自動發送,請勿直接回覆)";

    // 5. 嘗試寄信
    try {
      GmailApp.sendEmail(email, subject, body);
     
      // 成功後寫入 G 欄
      var timeString = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "MM/dd HH:mm");
      statusCell.setValue("已於 " + timeString + " 寄出");
      SpreadsheetApp.getActive().toast("已寄信給 " + name, "成功");
     
    } catch (err) {
      // 如果還是失敗,將詳細錯誤寫入 G 欄以便除錯
      statusCell.setValue("寄送失敗:" + err.message);
      SpreadsheetApp.getActive().toast("寄送失敗,請看 G 欄原因", "錯誤");
      Logger.log("寄送失敗:" + err);
    }
  }
}


快來試試吧~



沒有留言:

張貼留言

歡迎大家一起留言討論!