0. 作品介紹

1.學生可以選填15個志願 2.會將學生結果寄至老師信箱 3.老師除了信箱會收到 雲端硬碟也會有PDF檔按


100 2 0
辦公
示範影片

1. AI 提示詞

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

我用GOOGLE 試算表 跟 app script建立了一個志願表跟時間戳記
目前已經有的東西 students 分頁 裡面有時間戳記 學號 姓名 志願列表
SchoolData分頁 裡面有 職群 學校 科系
然後 我卡關了
1.我希望填寫完會記信件給家長
2.老師可以收到回傳的表單

2. 資料表規則

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

第1分頁: Students A 欄: 時間戳記 B 欄: 班級 C 欄: 學號 D 欄: 姓名 E 欄: 導師Email F 欄: 版本(Version) G 欄: 是否最新(IsLatest) H 欄: 志願列表 I 欄: PDF檔案連結(PdfUrl) J 欄: PDF檔案ID(PdfFileId) 第2分頁: SchoolData A 欄: 職群 B 欄: 學校 C 欄: 科系 第3分頁: Roster A 欄: 班級 B 欄: 學號 C 欄: 姓名 第4分頁: Teachers A 欄: 班級 B 欄: 導師Email C 欄: 導師姓名

📄 解析結果(自動表格)

分頁:Students
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄 G 欄 H 欄 I 欄 J 欄
時間戳記 班級 學號 姓名 導師Email 版本(Version) 是否最新(IsLatest) 志願列表 PDF檔案連結(PdfUrl) PDF檔案ID(PdfFileId)
分頁:SchoolData
A 欄 B 欄 C 欄
職群 學校 科系
分頁:Roster
A 欄 B 欄 C 欄
班級 學號 姓名
分頁:Teachers
A 欄 B 欄 C 欄
班級 導師Email 導師姓名

3. 開啟 Apps Script

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

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

4. 貼上後端(Code.gs)

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

Code.gs(後端)
/**
 * 光華國中適性輔導安置模擬志願選填系統 - 完整版
 * - 取消家長 Email
 * - 班級->學生下拉(Roster)
 * - 選學生後自動載入「最新版本」志願(Students)
 * - 可重填:自動 V1/V2...,舊版 IsLatest 改 false
 * - 產 PDF 存 Drive
 * -(可選)寄給導師含 PDF 附件(Teachers)
 */

/** 是否寄信給導師(Teachers 有填 email 才會寄) */
var SEND_TEACHER_EMAIL = true;

/** 指定 Drive 資料夾(留空 = 自動在根目錄建立同名資料夾) */
var DRIVE_FOLDER_ID = "換成自己的";

/** 自動建立的資料夾名稱 */
var DRIVE_FOLDER_NAME = "光華國中_志願回條PDF";

// -------------------- Web App --------------------
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('光華國中適性輔導安置模擬志願選填')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// -------------------- SchoolData --------------------
function getSchoolOptions() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('SchoolData');
  if (!sheet) throw new Error("找不到 SchoolData 工作表");

  var data = sheet.getDataRange().getValues();
  if (data.length <= 1) return [];
  data.shift(); // header

  return data
    .filter(function(r){ return r[0] || r[1] || r[2]; })
    .map(function(row, idx){
      var group = row[0] ? String(row[0]).trim() : "其他";
      var school = row[1] ? String(row[1]).trim() : "";
      var major  = row[2] ? String(row[2]).trim() : "";
      var displayName = school + " - " + major + " (" + group + ")";
      return {
        id: idx,
        group: group,
        school: school,
        major: major,
        displayName: displayName
      };
    });
}

// -------------------- Roster(班級 / 學生下拉) --------------------
function getClassOptions() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Roster');
  if (!sheet) throw new Error("找不到 Roster 工作表");

  var data = sheet.getDataRange().getValues();
  if (data.length <= 1) return [];
  data.shift();

  var map = {};
  data.forEach(function(r){
    var c = r[0] ? String(r[0]).trim() : "";
    if (c) map[c] = true;
  });

  return Object.keys(map).sort();
}

function getStudentsByClass(className) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Roster');
  if (!sheet) throw new Error("找不到 Roster 工作表");

  var data = sheet.getDataRange().getValues();
  if (data.length <= 1) return [];
  data.shift();

  var cls = String(className).trim();
  var list = [];

  data.forEach(function(r){
    var c = r[0] ? String(r[0]).trim() : "";
    if (c !== cls) return;

    var sid = r[1] ? String(r[1]).trim() : "";
    var name = r[2] ? String(r[2]).trim() : "";
    if (sid && name) {
      list.push({ stuId: sid, name: name, display: sid + " " + name });
    }
  });

  list.sort(function(a,b){ return a.stuId.localeCompare(b.stuId); });
  return list;
}

// -------------------- Teachers(導師 Email,可選) --------------------
function getTeacherEmailByClass_(className) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Teachers');
  if (!sheet) return ""; // 沒 Teachers 就不寄信

  var data = sheet.getDataRange().getValues();
  if (data.length <= 1) return "";
  var cls = String(className).trim();

  for (var i=1;i<data.length;i++){
    var c = data[i][0] ? String(data[i][0]).trim() : "";
    if (c === cls) {
      return data[i][1] ? String(data[i][1]).trim() : "";
    }
  }
  return "";
}

// -------------------- Students(讀回最新志願) --------------------
function getLatestSelection(className, stuId) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Students');
  if (!sheet || sheet.getLastRow() <= 1) return { found:false };

  var lastRow = sheet.getLastRow();
  var lastCol = sheet.getLastColumn();
  var values = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();

  var cls = String(className).trim();
  var sid = String(stuId).trim();

  var best = null;

  for (var i=0;i<values.length;i++){
    var rClass = values[i][1] ? String(values[i][1]).trim() : "";
    var rSid   = values[i][2] ? String(values[i][2]).trim() : "";
    if (rClass !== cls || rSid !== sid) continue;

    var ver = parseInt(values[i][5], 10); // 版本在第6欄
    if (isNaN(ver)) ver = 0;

    var choicesText = values[i][7] ? String(values[i][7]) : ""; // 志願列表在第8欄
    if (!best || ver > best.version) best = { version: ver, choicesText: choicesText };
  }

  if (!best) return { found:false };

  var choices = best.choicesText.split("\n").map(function(line){
    return line.replace(/^\s*\d+\.\s*/, "").trim();
  }).filter(function(x){ return x; });

  return { found:true, version: best.version, choices: choices };
}

// -------------------- 主流程:提交(可重填 + PDF + Drive + 可寄老師) --------------------
function processSelection(payload) {
  try {
    validatePayload_(payload);

    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var sheet = ss.getSheetByName('Students') || ss.insertSheet('Students');
    ensureStudentsHeader_(sheet);

    var ts = new Date();
    var teacherEmail = getTeacherEmailByClass_(payload.className);

    var key = makeStudentKey_(payload.className, payload.stuId);
    var version = getNextVersionAndMarkOldNotLatest_(sheet, key);

    var choicesString = payload.choices.map(function(c,i){
      return (i+1) + ". " + c;
    }).join("\n");

    // 產 PDF + 存 Drive
    var pdfInfo = generateAndSavePdf_(payload, ts, teacherEmail, version);

    // 寫入 Students
    sheet.appendRow([
      ts,
      payload.className,
      payload.stuId,
      payload.name,
      teacherEmail,
      version,
      true,
      choicesString,
      pdfInfo.url,
      pdfInfo.fileId
    ]);

    // 寄給導師(可關閉)
    if (SEND_TEACHER_EMAIL && isEmail_(teacherEmail)) {
      MailApp.sendEmail({
        to: teacherEmail,
        subject: "【回傳】志願選填回條(PDF)- " + payload.name + "(" + payload.className + ")V" + version,
        htmlBody: buildEmailHtml_(payload, ts, teacherEmail, version, pdfInfo.url),
        attachments: [pdfInfo.blob],
        name: "光華國中志願選填系統"
      });
    }

    return { success:true, version: version, pdfUrl: pdfInfo.url };
  } catch (e) {
    throw new Error(e.message || String(e));
  }
}

function validatePayload_(p) {
  if (!p) throw new Error("payload 不可為空");
  if (!p.className) throw new Error("班級未選");
  if (!p.stuId) throw new Error("學號未選");
  if (!p.name) throw new Error("姓名未帶入");
  if (!p.choices || p.choices.length < 15) throw new Error("需選滿 15 個志願");
}

// -------------------- Students:表頭/版本/最新標記 --------------------
function ensureStudentsHeader_(sheet) {
  if (sheet.getLastRow() !== 0) return;
  sheet.appendRow([
    "時間戳記","班級","學號","姓名","導師Email",
    "版本(Version)","是否最新(IsLatest)","志願列表",
    "PDF檔案連結(PdfUrl)","PDF檔案ID(PdfFileId)"
  ]);
}

function makeStudentKey_(className, stuId) {
  return String(className).trim() + "||" + String(stuId).trim();
}

function getNextVersionAndMarkOldNotLatest_(sheet, key) {
  var lastRow = sheet.getLastRow();
  if (lastRow <= 1) return 1;

  var lastCol = sheet.getLastColumn();
  var values = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();

  var maxVersion = 0;
  var latestSheetRow = -1;

  for (var i=0;i<values.length;i++){
    var cls = values[i][1] ? String(values[i][1]).trim() : "";
    var sid = values[i][2] ? String(values[i][2]).trim() : "";
    var rowKey = cls + "||" + sid;
    if (rowKey !== key) continue;

    var ver = parseInt(values[i][5],10); // 版本第6欄
    if (isNaN(ver)) ver = 0;
    if (ver > maxVersion) maxVersion = ver;

    var isLatest = values[i][6] === true || String(values[i][6]).toLowerCase() === "true"; // 第7欄
    if (isLatest) latestSheetRow = i + 2;
  }

  if (latestSheetRow > 0) {
    sheet.getRange(latestSheetRow, 7).setValue(false); // IsLatest 欄位
  }

  return maxVersion + 1;
}

// -------------------- Drive + PDF --------------------
function getOrCreateFolder_() {
  if (DRIVE_FOLDER_ID && String(DRIVE_FOLDER_ID).trim()) {
    return DriveApp.getFolderById(DRIVE_FOLDER_ID);
  }
  var it = DriveApp.getFoldersByName(DRIVE_FOLDER_NAME);
  if (it.hasNext()) return it.next();
  return DriveApp.createFolder(DRIVE_FOLDER_NAME);
}

function generateAndSavePdf_(payload, ts, teacherEmail, version) {
  var folder = getOrCreateFolder_();

  var docName =
    payload.className + "_" + payload.stuId + "_" + payload.name +
    "_V" + version + "_" + Utilities.formatDate(ts, "Asia/Taipei", "yyyyMMdd_HHmmss");

  var doc = DocumentApp.create(docName);
  var body = doc.getBody();

  body.appendParagraph("光華國中 適性輔導安置模擬志願選填 - 回條").setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph("學生班級:" + payload.className);
  body.appendParagraph("學生姓名:" + payload.name);
  body.appendParagraph("學號:" + payload.stuId);
  body.appendParagraph("提交時間:" + Utilities.formatDate(ts, "Asia/Taipei", "yyyy/MM/dd HH:mm:ss"));
  body.appendParagraph("版本:V" + version);
  if (teacherEmail) body.appendParagraph("導師Email:" + teacherEmail);
  body.appendParagraph("");

  body.appendParagraph("選填志願:").setHeading(DocumentApp.ParagraphHeading.HEADING2);

  var table = [];
  table.push(["序", "志願"]);
  for (var i = 0; i < payload.choices.length; i++) {
    table.push([String(i + 1), payload.choices[i]]);
  }
  body.appendTable(table);

  body.appendParagraph("");
  body.appendParagraph("家長同意簽名:__________________________    日期:______ / ______ / ______");
  body.appendParagraph("老師同意簽名:__________________________    日期:______ / ______ / ______");

  doc.saveAndClose();

  var docFile = DriveApp.getFileById(doc.getId());
  folder.addFile(docFile);
  try { DriveApp.getRootFolder().removeFile(docFile); } catch (e) {} // 有些網域權限會不允許移除,忽略即可

  var pdfBlob = docFile.getAs(MimeType.PDF);
  pdfBlob.setName(docName + ".pdf");

  var pdfFile = folder.createFile(pdfBlob);

  return { fileId: pdfFile.getId(), url: pdfFile.getUrl(), blob: pdfBlob };
}

// -------------------- Email HTML(導師用) --------------------
function buildEmailHtml_(payload, ts, teacherEmail, version, pdfUrl) {
  var timeText = Utilities.formatDate(ts, "Asia/Taipei", "yyyy/MM/dd HH:mm:ss");
  var listHtml = payload.choices.map(function(c, i){
    return "<tr><td style='border:1px solid #ddd;padding:6px 10px;text-align:center;width:50px'>"+(i+1)+"</td>"
      + "<td style='border:1px solid #ddd;padding:6px 10px'>" + escapeHtml_(c) + "</td></tr>";
  }).join("");

  return ""
    + "<div style='font-family:Arial,Microsoft JhengHei,sans-serif;line-height:1.7'>"
    + "<h2 style='margin:0 0 10px'>志願選填回傳</h2>"
    + "<div style='padding:12px;border:1px solid #eee;border-radius:12px;background:#fff'>"
    + "<b>班級:</b>" + escapeHtml_(payload.className) + "<br>"
    + "<b>姓名:</b>" + escapeHtml_(payload.name) + "(學號:" + escapeHtml_(payload.stuId) + ")<br>"
    + "<b>提交時間:</b>" + timeText + "<br>"
    + "<b>版本:</b>V" + version + "<br>"
    + "</div>"
    + "<h3 style='margin:14px 0 8px'>志願列表</h3>"
    + "<table style='border-collapse:collapse;width:100%;max-width:900px'>"
    + "<tr><th style='border:1px solid #ddd;background:#f3f3f3;padding:6px 10px;width:50px'>序</th>"
    + "<th style='border:1px solid #ddd;background:#f3f3f3;padding:6px 10px'>志願</th></tr>"
    + listHtml
    + "</table>"
    + "<p style='margin-top:14px'>📎 本信已附上回條 PDF,可直接列印簽名。</p>"
    + (pdfUrl ? "<p>🔗 PDF 連結:" + pdfUrl + "</p>" : "")
    + "<p style='color:#777;font-size:12px'>※ 學生若重填,系統會以最新版本為準。</p>"
    + "</div>";
}

function isEmail_(s) {
  if (!s) return false;
  s = String(s).trim();
  return s.indexOf("@") > -1 && s.indexOf(".") > -1;
}

function escapeHtml_(s) {
  s = (s === null || s === undefined) ? "" : String(s);
  return s.replace(/&/g, "&amp;")
          .replace(/</g, "&lt;")
          .replace(/>/g, "&gt;")
          .replace(/"/g, "&quot;")
          .replace(/'/g, "&#39;");
}

function authorizeDriveOnce() {
  var it = DriveApp.getFoldersByName("___auth_test___");
  Logger.log(it.hasNext());
}

function authorizeDocsOnce() {
  // 只為了觸發 Documents 授權
  var doc = DocumentApp.create("___auth_doc_test___");
  doc.getBody().appendParagraph("auth test");
  doc.saveAndClose();

  // 立即丟到垃圾桶(避免試算表/雲端留下測試檔)
  DriveApp.getFileById(doc.getId()).setTrashed(true);
}

function authorizeMailOnce() {
  // 只為了觸發 Gmail/Email 授權(不會真的寄信)
  MailApp.getRemainingDailyQuota();
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

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

index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link href="https://fonts.googleapis.com/css2?family=Noto+Serif+TC:wght@700&family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
  <style>
    :root { --bg-warm:#fdfcf8; --academic-blue:#2c3e50; --vintage-gold:#d4af37; }

    .g-設計 { background-color:#ffcccc !important; color:#b71c1c; font-weight:bold; }
    .g-學術 { background-color:#d1e3ff !important; color:#0d47a1; font-weight:bold; }
    .g-機械 { background-color:#eeeeee !important; color:#333; }
    .g-動力機械 { background-color:#d1d9ff !important; color:#1a237e; }
    .g-電機電子 { background-color:#b2dfdb !important; color:#004d40; }
    .g-化工 { background-color:#b2ebf2 !important; color:#006064; }
    .g-土木建築 { background-color:#d7ccc8 !important; color:#3e2723; }
    .g-商業管理 { background-color:#fff9c4 !important; color:#f57f17; font-weight:bold; }
    .g-外語 { background-color:#e1bee7 !important; color:#4a148c; font-weight:bold; }
    .g-農業 { background-color:#dcedc8 !important; color:#33691e; font-weight:bold; }
    .g-食品 { background-color:#f0f4c3 !important; color:#827717; font-weight:bold; }
    .g-家政 { background-color:#f8bbd0 !important; color:#880e4f; font-weight:bold; }
    .g-餐旅 { background-color:#ffe0b2 !important; color:#e65100; font-weight:bold; }
    .g-水產 { background-color:#b3e5fc !important; color:#01579b; font-weight:bold; }
    .g-海事 { background-color:#c8e6c9 !important; color:#1b5e20; font-weight:bold; }
    .g-藝術 { background-color:#f5f5f5 !important; color:#616161; font-weight:bold; }
    .g-其他 { background-color:#ffffff !important; color:#000; }

    body { font-family:'Noto Sans TC',sans-serif; background:var(--bg-warm); padding:15px; color:#333; }
    .container { max-width:950px; margin:auto; background:#fff; padding:25px; border:3px double var(--academic-blue); border-radius:12px; box-shadow:0 5px 15px rgba(0,0,0,0.1); }
    h2 { font-family:'Noto Serif TC',serif; color:var(--academic-blue); text-align:center; border-bottom:2px solid var(--vintage-gold); padding-bottom:10px; margin-bottom:25px; }

    .info-input { display:flex; gap:20px; margin-bottom:18px; background:#f4f1ea; padding:18px; border-radius:8px; border:1px solid #ddd; flex-wrap:wrap; }
    .info-input div { flex:1; min-width:240px; }
    .info-input label { display:block; font-weight:bold; margin-bottom:8px; color:var(--academic-blue); }
    .info-input select { width:100%; padding:12px; border:1px solid #ccc; border-radius:6px; font-size:16px; box-sizing:border-box; }

    .hint { margin:-8px 0 18px; color:#666; font-size:13px; }
    .hint b { color:#2c3e50; }

    .volunteer-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(420px, 1fr)); gap:20px; }
    .volunteer-box { border:1px solid #ddd; border-radius:10px; position:relative; background:#fff; transition:0.3s; }
    .v-header { background:var(--academic-blue); color:#fff; padding:10px 15px; font-weight:bold; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; }

    .custom-select { position:relative; width:100%; }
    .select-trigger { padding:15px; border:1px solid #ccc; background:#fff; border-radius:0 0 10px 10px; font-size:16px; min-height:24px; display:block; border-top:none; cursor:pointer; }
    .options-list {
      position:absolute; top:100%; left:0; right:0; background:#fff; border:1px solid #ccc;
      z-index:999; max-height:300px; overflow-y:auto; display:none; border-radius:0 0 8px 8px;
      box-shadow:0 10px 25px rgba(0,0,0,0.2);
    }
    .options-list.show { display:block; }
    .option-item { padding:12px 15px; border-bottom:1px solid #eee; transition:0.1s; font-size:15px; cursor:pointer; }
    .option-item:hover { filter:brightness(0.9); }
    .option-item.hidden { display:none; }

    #submitBtn { width:100%; padding:20px; background:var(--academic-blue); color:var(--vintage-gold); border:none; border-radius:8px; font-size:1.5em; cursor:pointer; margin-top:40px; font-weight:bold; }
    #submitBtn:disabled { background:#ccc; cursor:not-allowed; }

    .error-msg { color:#d32f2f; background:#ffebee; padding:15px; border-radius:8px; margin-bottom:20px; display:none; border:1px solid #d32f2f; font-weight:bold; }
  </style>
</head>
<body>

<div class="container">
  <h2>🎓 光華國中適性輔導安置模擬志願選填</h2>

  <div class="info-input">
    <div>
      <label>學生班級</label>
      <select id="className" onchange="onClassChange()">
        <option value="">-- 請選擇班級 --</option>
      </select>
    </div>

    <div>
      <label>學生(學號 + 姓名)</label>
      <select id="studentSelect" onchange="onStudentChange()" disabled>
        <option value="">-- 請先選班級 --</option>
      </select>
    </div>
  </div>

  <div class="hint">
    ✅ 選擇學生後,系統會自動載入「上次填寫的最新志願」供修改。<br>
    ✅ 若需重填,直接修改後再提交即可(系統會自動產生 <b>V2/V3…</b> 並以最新版本為準)。
  </div>

  <div id="msg" class="error-msg"></div>
  <div class="volunteer-grid" id="v-list"></div>

  <button id="submitBtn" onclick="submitForm()">確認提交志願表</button>
</div>

<script>
  let schools = [];
  let selections = Array(16).fill(null);

  let rosterStudents = []; // [{stuId,name,display}]

  window.onload = () => {
    // 班級清單
    google.script.run
      .withSuccessHandler(classes => {
        const sel = document.getElementById('className');
        (classes || []).forEach(c => {
          const op = document.createElement('option');
          op.value = c;
          op.textContent = c;
          sel.appendChild(op);
        });
      })
      .withFailureHandler(err => {
        alert("❌ 載入班級失敗:" + (err && err.message ? err.message : err));
      })
      .getClassOptions();

    // 志願資料
    google.script.run
      .withSuccessHandler(data => {
        schools = data || [];
        renderVolunteerCards();
      })
      .withFailureHandler(err => {
        alert("❌ 載入志願資料失敗:" + (err && err.message ? err.message : err));
      })
      .getSchoolOptions();

    window.onclick = (e) => {
      if (!e.target.closest('.custom-select')) {
        document.querySelectorAll('.options-list').forEach(l => l.classList.remove('show'));
      }
    };
  };

  function renderVolunteerCards() {
    const list = document.getElementById('v-list');
    list.innerHTML = "";
    for (let i=1; i<=15; i++) {
      list.innerHTML += `
      <div class="volunteer-box" id="box-${i}">
        <div class="v-header"><span>第 ${i} 志願</span><span id="label-${i}" style="font-size:0.8em; opacity:0.8"></span></div>
        <div class="custom-select" id="container-${i}">
          <div class="select-trigger" id="trigger-${i}" onclick="toggleDropdown(${i})">-- 點擊展開選擇 --</div>
          <div class="options-list" id="list-${i}">
            <div class="option-item" style="background:#fff" onclick="selectOption(${i}, null)">清除選擇</div>
            ${schools.map(s => {
              const gKey = getGroupKey(s.group);
              return `<div class="option-item g-${gKey}" data-id="${s.id}" onclick="selectOption(${i}, ${s.id})">${s.displayName}</div>`;
            }).join('')}
          </div>
        </div>
      </div>`;
    }
  }

  function getGroupKey(groupName) {
    const list = ["設計","學術","機械","動力機械","電機電子","化工","土木建築","商業管理","外語","農業","食品","家政","餐旅","水產","海事","藝術"];
    for (let key of list) {
      if (groupName && groupName.includes(key)) return key;
    }
    return "其他";
  }

  function onClassChange(){
    const className = document.getElementById('className').value.trim();
    const stuSel = document.getElementById('studentSelect');
    stuSel.innerHTML = "";
    rosterStudents = [];
    clearAllSelections();

    if (!className) {
      stuSel.disabled = true;
      stuSel.innerHTML = `<option value="">-- 請先選班級 --</option>`;
      return;
    }

    stuSel.disabled = true;
    stuSel.innerHTML = `<option value="">載入中...</option>`;

    google.script.run
      .withSuccessHandler(list => {
        rosterStudents = list || [];
        stuSel.innerHTML = `<option value="">-- 請選學生 --</option>`;
        rosterStudents.forEach((s, idx) => {
          const op = document.createElement('option');
          op.value = idx;
          op.textContent = s.display;
          stuSel.appendChild(op);
        });
        stuSel.disabled = false;
      })
      .withFailureHandler(err => {
        alert("❌ 載入學生名單失敗:" + (err && err.message ? err.message : err));
        stuSel.disabled = true;
        stuSel.innerHTML = `<option value="">-- 載入失敗 --</option>`;
      })
      .getStudentsByClass(className);
  }

  function onStudentChange(){
    const className = document.getElementById('className').value.trim();
    const idx = document.getElementById('studentSelect').value;
    clearAllSelections();
    if (idx === "") return;

    const s = rosterStudents[parseInt(idx,10)];
    if (!s) return;

    google.script.run
      .withSuccessHandler(res => {
        if (res && res.found && res.choices && res.choices.length) {
          fillSelectionsFromChoiceTexts(res.choices);
          alert("已載入上次填寫:V" + res.version + "(可修改後再提交)");
        } else {
          // 沒填過就保持空白
        }
      })
      .withFailureHandler(err => {
        alert("❌ 讀取舊志願失敗:" + (err && err.message ? err.message : err));
      })
      .getLatestSelection(className, s.stuId);
  }

  function toggleDropdown(idx) {
    const list = document.getElementById('list-' + idx);
    const isOpen = list.classList.contains('show');
    document.querySelectorAll('.options-list').forEach(l => l.classList.remove('show'));
    if (!isOpen) {
      list.classList.add('show');
      updateOptionVisibility();
    }
  }

  function selectOption(volIdx, schoolId) {
    const trigger = document.getElementById('trigger-' + volIdx);
    const label = document.getElementById('label-' + volIdx);

    if (schoolId === null) {
      selections[volIdx] = null;
      trigger.innerText = "-- 點擊展開選擇 --";
      trigger.className = "select-trigger";
      label.innerText = "";
    } else {
      const school = schools.find(s => s.id === schoolId);
      selections[volIdx] = school;
      trigger.innerText = school.displayName;
      const gKey = getGroupKey(school.group);
      trigger.className = "select-trigger g-" + gKey;
      label.innerText = school.group;
    }
    checkRules();
  }

  function updateOptionVisibility() {
    const pickedIds = selections.filter(s => s !== null).map(s => s.id);
    document.querySelectorAll('.option-item').forEach(opt => {
      const id = parseInt(opt.getAttribute('data-id'));
      if (!isNaN(id)) {
        const currentVolIdx = opt.closest('.custom-select').id.split('-')[1];
        const isCurrent = selections[currentVolIdx] && selections[currentVolIdx].id === id;
        opt.classList.toggle('hidden', pickedIds.includes(id) && !isCurrent);
      }
    });
  }

  function checkRules() {
    const picked = selections.filter(s => s !== null);
    const msg = document.getElementById('msg');
    msg.style.display = "none";
    if (picked.length === 0) return true;

    // 前三志願同職群
    if (selections[1] && selections[2] && selections[3]) {
      if (selections[1].group !== selections[2].group || selections[2].group !== selections[3].group) {
        msg.innerHTML = "⚠️ <b>規則提示:</b>前三個志願必須屬於同一職群(顏色需相同)!";
        msg.style.display = "block"; return false;
      }
    }
    // 職群總數<=4
    const groupSet = new Set(picked.map(s => s.group));
    if (groupSet.size > 4) {
      msg.innerHTML = "⚠️ <b>規則提示:</b>選填職群總數不可超過 4 個!";
      msg.style.display = "block"; return false;
    }
    return true;
  }

  function clearAllSelections(){
    for (let i=1;i<=15;i++){
      selections[i] = null;
      const trigger = document.getElementById('trigger-' + i);
      const label = document.getElementById('label-' + i);
      if (trigger) {
        trigger.innerText = "-- 點擊展開選擇 --";
        trigger.className = "select-trigger";
      }
      if (label) label.innerText = "";
    }
    const msg = document.getElementById('msg');
    msg.style.display = "none";
  }

  function fillSelectionsFromChoiceTexts(choiceTexts){
    // 依 displayName 回填(必須與 SchoolData displayName 一致)
    let unmatched = [];
    for (let i=0;i<15;i++){
      const text = choiceTexts[i];
      if (!text) continue;

      const school = schools.find(x => x.displayName === text);
      if (!school) {
        unmatched.push((i+1) + ". " + text);
        continue;
      }

      selections[i+1] = school;
      const trigger = document.getElementById('trigger-' + (i+1));
      const label = document.getElementById('label-' + (i+1));
      trigger.innerText = school.displayName;
      const gKey = getGroupKey(school.group);
      trigger.className = "select-trigger g-" + gKey;
      label.innerText = school.group;
    }

    checkRules();

    if (unmatched.length) {
      alert("⚠️ 有部分舊志願在目前 SchoolData 找不到對應,可能是學校科系名稱被改過:\n\n" + unmatched.join("\n"));
    }
  }

  function buildPayload(){
    const className = document.getElementById('className').value.trim();
    const idx = document.getElementById('studentSelect').value;

    if (!className) throw new Error("請先選班級");
    if (idx === "") throw new Error("請先選學生");

    const s = rosterStudents[parseInt(idx,10)];
    if (!s) throw new Error("學生資料不存在,請重新選擇");

    const picked = selections.slice(1).map(x => x ? x.displayName : null);
    if (picked.filter(x=>x).length < 15) throw new Error("請選滿 15 個志願");
    if (!checkRules()) throw new Error("志願選填不符規則,請修正");

    return { className: className, stuId: s.stuId, name: s.name, choices: picked };
  }

  function submitForm() {
    const btn = document.getElementById('submitBtn');
    btn.disabled = true;

    let payload;
    try {
      payload = buildPayload();
    } catch (e) {
      alert("❌ " + e.message);
      btn.disabled = false;
      return;
    }

    if (!confirm("確定提交嗎?(可重填,系統會保留版本)")) {
      btn.disabled = false;
      return;
    }

    google.script.run
      .withSuccessHandler((res) => {
        const ver = (res && res.version) ? res.version : "";
        alert("✅ 提交成功!版本 V" + ver);
        btn.disabled = false;

        // 提交後重新讀回最新,確保畫面就是最新版本
        onStudentChange();
      })
      .withFailureHandler((err) => {
        const msg = (err && err.message) ? err.message : String(err);
        alert("❌ 提交失敗:\n" + msg);
        btn.disabled = false;
      })
      .processSelection(payload);
  }
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

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

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

💬 評論區

老師們的交流與提問

載入評論中...