0. 作品介紹
1. AI 提示詞
把這段指令貼給 AI,讓它幫你生成程式碼。
我用GOOGLE 試算表 跟 app script建立了一個志願表跟時間戳記
目前已經有的東西 students 分頁 裡面有時間戳記 學號 姓名 志願列表
SchoolData分頁 裡面有 職群 學校 科系
然後 我卡關了
1.我希望填寫完會記信件給家長
2.老師可以收到回傳的表單
目前已經有的東西 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 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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。
<!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 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論