切換填答身分:
💡 點擊網格即可切換「方便出席」狀態(綠色代表該時段可出席)
⚠️ 註:特教教師已預先選定可出席時段,普通班導師與家長僅能從特師開放的區段中進行選擇。
📧 家長聯絡 Email(填寫後將自動收到暫定開會時間通知):
🧩 三方時間媒合結果矩陣
🟢 3位皆方便 (最佳建議)
🟡 2位方便
⚪ 僅1位或不便
目前決議之會議時間:
尚未選定
點擊上方矩陣中的時段即可將該時段定案為正式 IEP 會議時間。
確定會議時間後,系統將自動套入正式公文格式。點擊「列印通知單」將同時啟動列印並自動下載 Word 檔。
📊 Google 試算表 API 自動同步設定
將系統問卷資料與當前處理名單自動連動至您的 Google 試算表。
📜 Google Apps Script (doGet & doPost) 部署對應碼:
// 讀取資料 (doGet)
function doGet(e) {
try {
var ss = SpreadsheetApp.getActiveSpreadsheet();
// 1. 讀取圖 4 的原始學生與教師名單 (唯讀,Sheet ID: 922457150)
var sheetData = getSheetByGid(ss, "922457150") || ss.getSheetByName("學生與教師名單");
if (!sheetData) {
return ContentService.createTextOutput(JSON.stringify({ result: "error", message: "找不到原始學生名單工作表 (Sheet ID: 922457150)" }))
.setMimeType(ContentService.MimeType.JSON);
}
var students = [];
var lastRowData = sheetData.getLastRow();
if (lastRowData > 1) {
var rawData = sheetData.getRange(2, 1, lastRowData - 1, 5).getValues(); // 唯讀取 A-E 欄 (姓名、班級、特教、導師、家長)
// 2. 讀取「會議決議時間」工作表 (圖 5 / gid = 1141449692) 的決議時間與 JSON 備份
var sheetSummary = getSheetByGid(ss, "1141449692") || ss.getSheetByName("會議決議時間");
var summaryMap = {};
if (sheetSummary && sheetSummary.getLastRow() > 1) {
var summaryData = sheetSummary.getRange(2, 1, sheetSummary.getLastRow() - 1, 12).getValues();
summaryData.forEach(function(row) {
if (row[0]) {
summaryMap[row[0]] = {
selectedSlot: row[5],
meetingDate: row[6],
semester: row[7],
meetingType: row[8],
location: row[9],
contact: row[10],
responses: row[11]
};
}
});
}
// 3. 讀取圖 1 (三方填答彙整,Sheet ID: 1534495455) 取得三方最新填答明細(120秒時間窗口逆向還原法)
var sheetDetails = getSheetByGid(ss, "1534495455") || ss.getSheetByName("三方填答彙整");
var detailsMap = {};
var latestSessionSec = {}; // 記錄 { "學生姓名_角色": 最新行之秒數 }
if (sheetDetails && sheetDetails.getLastRow() > 1) {
var detailsData = sheetDetails.getRange(2, 1, sheetDetails.getLastRow() - 1, 5).getValues(); // A-E 欄 (含E欄更新時間)
// 輔助解析:將時間戳記轉為秒數
function parseTimeToSec(tStr) {
if (!tStr) return 0;
try {
var d = new Date(tStr);
if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
} catch(e) {}
return 0;
}
// 由下往上(逆向最新)掃描
for (var i = detailsData.length - 1; i >= 0; i--) {
var row = detailsData[i];
var studentName = row[0];
var roleText = row[1];
var slotName = row[2];
var status = row[3];
var updateTime = row[4];
if (studentName && roleText && slotName) {
var roleKey = "";
if (roleText === "特教教師") roleKey = "sped";
else if (roleText === "普通班導師") roleKey = "teacher";
else if (roleText === "學生家長" || roleText === "家長") roleKey = "parent";
if (roleKey) {
var mapKey = studentName + "_" + roleKey;
// 記錄第一個遇到的最新批次時間戳記
if (!latestSessionTimestamp[mapKey]) {
latestSessionTimestamp[mapKey] = updateTime || "LATEST";
}
// 僅讀取屬於最新批次時間戳記的填答行,自動跳過舊批次的歷史存檔
if (latestSessionTimestamp[mapKey] === (updateTime || "LATEST")) {
if (status === "方便出席") {
if (!detailsMap[studentName]) {
detailsMap[studentName] = { sped: {}, teacher: {}, parent: {} };
}
if (!detailsMap[studentName][roleKey]) {
detailsMap[studentName][roleKey] = {};
}
var timeKey = parseSlotNameToKey(slotName);
if (timeKey) {
detailsMap[studentName][roleKey][timeKey] = true;
}
}
}
}
}
}
}
// 合併資料
rawData.forEach(function(row) {
if (row[0]) {
var name = row[0];
var sum = summaryMap[name] || {};
// 優先從三方填答彙整 (detailsMap) 中讀取 responses,如果沒有才從 JSON 讀取備份
var respObj = { sped: {}, teacher: {}, parent: {} };
var parentEmail = "";
if (sum.responses) {
try {
var parsedJson = JSON.parse(sum.responses);
if (parsedJson.responses) {
respObj = parsedJson.responses;
parentEmail = parsedJson.parentEmail || "";
} else {
respObj = parsedJson;
}
} catch(err) {}
}
if (detailsMap[name]) {
respObj = detailsMap[name];
}
students.push({
name: name,
gradeClass: row[1],
spedTeacher: row[2],
teacherName: row[3],
parentName: row[4],
parentEmail: parentEmail,
selectedSlot: sum.selectedSlot || "",
meetingDate: sum.meetingDate || "",
semester: sum.semester || "",
meetingType: sum.meetingType || "",
location: sum.location || "",
contact: sum.contact || "",
responses: respObj
});
}
});
}
return ContentService.createTextOutput(JSON.stringify({ result: "success", students: students }))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ result: "error", message: err.toString() }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// 寫入資料 (doPost)
function doPost(e) {
try {
var data = JSON.parse(e.postData.contents);
var ss = SpreadsheetApp.getActiveSpreadsheet();
// ==========================================
// 1. 寫入「會議決議時間」工作表 (圖 5 / gid = 1141449692)
// ==========================================
var sheetSummary = getSheetByGid(ss, "1141449692") || ss.getSheetByName("會議決議時間");
if (!sheetSummary) {
sheetSummary = ss.insertSheet("會議決議時間");
sheetSummary.appendRow(["學生姓名", "班級", "特教教師", "普通班導師", "家長稱呼", "決議會議時間", "預定日期", "學年度學期", "會議類型", "會議地點", "聯絡電話", "填答紀錄JSON"]);
sheetSummary.getRange("A1:L1").setFontWeight("bold").setBackground("#d1fae5");
} else {
// 確保其名稱為「會議決議時間」
if (sheetSummary.getName() !== "會議決議時間") {
try { sheetSummary.setName("會議決議時間"); } catch(e) {}
}
}
var lastRowSum = sheetSummary.getLastRow();
var existingSummaryNames = [];
if (lastRowSum > 1) {
existingSummaryNames = sheetSummary.getRange(2, 1, lastRowSum - 1, 1).getValues().map(function(r) { return r[0]; });
}
data.students.forEach(function(st) {
var foundIdx = existingSummaryNames.indexOf(st.name);
var respJson = JSON.stringify({
responses: st.responses || {},
parentEmail: st.parentEmail || ""
});
if (foundIdx !== -1) {
// 若已存在,僅更新決議會議時間 (F 欄,第 6 欄)、預定日期 (G 欄) 與 JSON (L 欄,第 12 欄),其餘保持原樣不更動!
var rowNum = foundIdx + 2;
// 決議時間防退寫保護:若試算表中已有決議時間,且前端上傳的為空,則拒絕覆寫,保持試算表原值不變
var currentSavedSlot = sheetSummary.getRange(rowNum, 6).getValue();
var newSelectedSlot = st.selectedSlot || "";
if (currentSavedSlot && currentSavedSlot !== "尚未決議" && (!newSelectedSlot || newSelectedSlot === "尚未決議")) {
st.selectedSlot = currentSavedSlot;
if (st.responses) {
var originalKey = parseSlotNameToKey(currentSavedSlot);
if (originalKey) {
st.responses.decision = originalKey;
}
}
respJson = JSON.stringify({
responses: st.responses || {},
parentEmail: st.parentEmail || ""
});
} else {
sheetSummary.getRange(rowNum, 6).setValue(newSelectedSlot);
sheetSummary.getRange(rowNum, 7).setValue(st.meetingDate || "");
}
sheetSummary.getRange(rowNum, 12).setValue(respJson);
} else {
// 若不存在,新增一行
sheetSummary.appendRow([
st.name,
st.gradeClass,
st.spedTeacher,
st.teacherName,
st.parentName,
st.selectedSlot || "",
st.meetingDate || "",
st.semester || "",
st.meetingType || "",
st.location || "",
st.contact || "",
respJson
]);
existingSummaryNames.push(st.name);
}
});
// ==========================================
// 2. 寫入圖 1 (三方填答彙整,Sheet ID: 1534495455) 工作表
// ==========================================
var sheetDetails = getSheetByGid(ss, "1534495455") || ss.getSheetByName("三方填答彙整");
if (!sheetDetails) {
sheetDetails = ss.insertSheet("三方填答彙整");
sheetDetails.appendRow(["學生姓名", "填答角色", "時段名稱", "出席狀態", "更新時間"]);
sheetDetails.getRange("A1:E1").setFontWeight("bold").setBackground("#e0f2fe");
} else {
// 確保其名稱為「三方填答彙整」
if (sheetDetails.getName() !== "三方填答彙整") {
try { sheetDetails.setName("三方填答彙整"); } catch(e) {}
}
}
var roleNames = { "sped": "特教教師", "teacher": "普通班導師", "parent": "學生家長" };
var batchTimestamp = getFormattedDateTime(); // 產生此批次提交之統一時間戳記
// 純追加歷史紀錄模式 (Append-Only History Retention):不呼叫 deleteRow,完全保留歷史軌跡
if (data.targetStudentName && data.submitterRole && roleNames[data.submitterRole]) {
var targetName = data.targetStudentName;
var targetRoleChinese = roleNames[data.submitterRole];
var targetStData = null;
if (data.students && data.students.length > 0) {
for (var s = 0; s < data.students.length; s++) {
if (data.students[s].name === targetName) {
targetStData = data.students[s];
break;
}
}
}
if (targetStData && targetStData.responses && targetStData.responses[data.submitterRole]) {
var roleResp = targetStData.responses[data.submitterRole];
Object.keys(roleResp).forEach(function(key) {
if (roleResp[key] === true && key.indexOf('_') !== -1) {
sheetDetails.appendRow([targetName, targetRoleChinese, key, "方便出席", batchTimestamp]);
}
});
// 3. 自動寄 email 告知家長目前暫定的會議時間
if (data.submitterRole === "parent" && targetStData.parentEmail) {
var parentSlots = [];
var commonSlots = [];
var spedResp = targetStData.responses.sped || {};
var teachResp = targetStData.responses.teacher || {};
var parentResp = targetStData.responses.parent || {};
Object.keys(parentResp).forEach(function(key) {
if (parentResp[key] === true) {
var displaySlot = getFormattedSlotName(key, targetStData.meetingDate);
parentSlots.push(displaySlot);
if (spedResp[key] === true && teachResp[key] === true) {
commonSlots.push(displaySlot);
}
}
});
if (parentSlots.length > 0) {
var parentSlotsText = " - " + parentSlots.join("\n - ");
var commonSlotsText = commonSlots.length > 0 ? " - " + commonSlots.join("\n - ") : " - 無共同時段(我們將由特教教師主動與您聯繫協商時間)";
var subject = "【吉安國小 IEP 會議時間填答確認】" + targetName + " 的家長您好";
var body = targetName + " 的家長您好:\n\n" +
"感謝您填寫吉安國小 IEP 會議時間問卷!我們已收到您的方便時間,現提供目前暫定的會議時間資訊供您參考。\n\n" +
"【您所勾選的方便出席時段】:\n" + parentSlotsText + "\n\n" +
"【三方共同可出席時段(暫定開會時間)】:\n" + commonSlotsText + "\n\n" +
"特教個案教師將會依據此媒合結果與您確認最終會議時間,並發送正式紙本通知單。\n" +
"如有任何疑問,請直接與特教教師 " + targetStData.spedTeacher + " 聯絡,謝謝您!\n\n" +
"祝 您平安順心\n花蓮縣吉安國小 IEP 會議時間調查系統 敬上";
try {
MailApp.sendEmail(targetStData.parentEmail, subject, body);
} catch(mailErr) {}
}
}
}
}
return ContentService.createTextOutput(JSON.stringify({"result": "success"}))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ result: "error", message: err.toString() }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// 輔助函式:根據 gid 取得工作表物件
function getSheetByGid(ss, gid) {
var sheets = ss.getSheets();
for (var i = 0; i < sheets.length; i++) {
if (sheets[i].getSheetId().toString() === gid.toString()) {
return sheets[i];
}
}
return null;
}
// 輔助函式:產生 YYYY/M/D 上午/下午 HH:MM:SS 格式時間字串
function getFormattedDateTime() {
var d = new Date();
var yyyy = d.getFullYear();
var mm = d.getMonth() + 1;
var dd = d.getDate();
var hh = d.getHours();
var min = d.getMinutes();
var sec = d.getSeconds();
var ampm = hh >= 12 ? "下午" : "上午";
var hh12 = hh % 12;
if (hh12 === 0) hh12 = 12;
var minStr = min < 10 ? "0" + min : min;
var secStr = sec < 10 ? "0" + sec : sec;
return yyyy + "/" + mm + "/" + dd + " " + ampm + " " + hh12 + ":" + minStr + ":" + secStr;
}
// 輔助函式:將中文格式或英文 key 格式的時段名稱,還原為系統 key (如 mon_t0)
function parseSlotNameToKey(slotName) {
if (!slotName) return null;
slotName = slotName.toString().trim();
// 1. 如果本來就是英文 key (含有底線,如 mon_t0)
if (slotName.indexOf('_') !== -1) {
return slotName;
}
// 2. 如果是中文格式,如 "2026-08-03 (週一) 朝會/晨光 (07:55~08:35)"
var dayKey = "";
if (slotName.indexOf("週一") !== -1 || slotName.indexOf("星期一") !== -1) dayKey = "mon";
else if (slotName.indexOf("週二") !== -1 || slotName.indexOf("星期二") !== -1) dayKey = "tue";
else if (slotName.indexOf("週三") !== -1 || slotName.indexOf("星期三") !== -1) dayKey = "wed";
else if (slotName.indexOf("週四") !== -1 || slotName.indexOf("星期四") !== -1) dayKey = "thu";
else if (slotName.indexOf("週五") !== -1 || slotName.indexOf("星期五") !== -1) dayKey = "fri";
var slotKey = "";
if (slotName.indexOf("朝會/晨光") !== -1) slotKey = "t0";
else if (slotName.indexOf("第一節") !== -1) slotKey = "t1";
else if (slotName.indexOf("第二節") !== -1) slotKey = "t2";
else if (slotName.indexOf("第三節") !== -1) slotKey = "t3";
else if (slotName.indexOf("第四節") !== -1) slotKey = "t4";
else if (slotName.indexOf("午休時段") !== -1) slotKey = "tlunch";
else if (slotName.indexOf("第五節") !== -1) slotKey = "t5";
else if (slotName.indexOf("第六節") !== -1) slotKey = "t6";
else if (slotName.indexOf("第七節") !== -1) slotKey = "t7";
if (dayKey && slotKey) {
return dayKey + "_" + slotKey;
}
return null;
}
// 輔助函式:將 time key (如 mon_t0) 轉換為國字與數字之時間標記
function getFormattedSlotName(key, meetingDateStr) {
if (!key) return "";
var parts = key.split('_');
if (parts.length < 2) return key;
var dayId = parts[0];
var slotId = parts[1];
var days = {
"mon": { name: "週一", offset: 0 },
"tue": { name: "週二", offset: 1 },
"wed": { name: "週三", offset: 2 },
"thu": { name: "週四", offset: 3 },
"fri": { name: "週五", offset: 4 }
};
var slots = {
"t0": "朝會/晨光 (07:55~08:35)",
"t1": "第一節 (08:40~09:20)",
"t2": "第二節 (09:30~10:10)",
"t3": "第三節 (10:30~11:10)",
"t4": "第四節 (11:20~12:00)",
"tlunch": "午休時段 (12:35~13:15)",
"t5": "第五節 (13:20~14:00)",
"t6": "第六節 (14:10~14:50)",
"t7": "第七節 (15:10~15:50)"
};
var dayObj = days[dayId];
var slotName = slots[slotId] || slotId;
if (!dayObj) return key;
var dateStr = dayObj.name + " ";
if (meetingDateStr) {
try {
var base = new Date(meetingDateStr);
if (!isNaN(base.getTime())) {
var day = base.getDay();
var diffToMon = day === 0 ? -6 : 1 - day;
var monday = new Date(base);
monday.setDate(base.getDate() + diffToMon);
var targetDate = new Date(monday);
targetDate.setDate(monday.getDate() + dayObj.offset);
var mm = String(targetDate.getMonth() + 1).padStart(2, '0');
var dd = String(targetDate.getDate()).padStart(2, '0');
var yyyy = targetDate.getFullYear();
dateStr = yyyy + "-" + mm + "-" + dd + " (" + dayObj.name + ") ";
}
} catch(e) {}
}
return dateStr + slotName;
}