/** * 一週三報後的課程行政流 * * 用途: * 將一週三報課程成果延伸成可追蹤的課程行政工作流, * 包含週報紀錄、作業成果、學生追蹤、課程提醒、補交通知草稿與期末成果包。 * * 使用階段: * 下半場 course admin flow 進階實作,適合已完成一週三報的教授與教師。 * * 定位: * 已完成「一週三報」的教授與教師,可用本檔把每週教學紀錄延伸成 * 課程任務、作業成果、學生追蹤、課程提醒、期末成果與課程改善紀錄。 * * 安全原則: * - Gmail 只建立草稿,不自動寄出。 * - Drive 只建立資料夾與文件,不自動公開權限。 * - Calendar 只建立教師個人提醒。 * - AI 提示詞只產生文件,不串接外部 AI API;貼到外部 AI 前請先移除個資。 */ const COURSE_FOLDER_PLACEHOLDER = '由 GAS 自動產生'; const COURSE_STATUSES = ['未開始', '進行中', '待確認', '已完成', '需追蹤']; /** * 建立試算表自訂選單。 * 讀取:無。 * 建立:Google Sheets 的「課程行政流」選單。 * 回寫:無。 */ function onOpen() { SpreadsheetApp.getUi() .createMenu('課程行政流') .addItem('初始化課程行政模板', 'setupCourseAdminTemplate') .addSeparator() .addItem('建立課程資料夾', 'createCourseWorkspace') .addItem('建立週報紀錄表', 'createWeeklyReportSheet') .addItem('建立作業成果追蹤表', 'createAssignmentTrackingSheet') .addItem('建立學生追蹤名單', 'createStudentTrackingSheet') .addItem('從週報產生待辦任務', 'createTasksFromWeeklyReports') .addItem('建立課程提醒', 'createCourseReminders') .addItem('產生補交通知草稿', 'createMissingWorkDraft') .addSeparator() .addItem('產生課程進度摘要', 'generateCourseProgressSummary') .addItem('產生期末課程成果包', 'generateFinalCoursePackage') .addItem('產生 AI 課程改善提示詞', 'createCourseImprovementPrompt') .addToUi(); } /** * 初始化課程行政模板。 * 讀取:目前試算表。 * 建立:課程設定、週報紀錄、作業成果追蹤、學生追蹤名單、課程任務追蹤、期末成果資料庫。 * 回寫:各工作表範例資料。 */ function setupCourseAdminTemplate() { if (!confirmCourseAction_('這會建立或重建課程設定、週報紀錄、作業成果追蹤、學生追蹤名單、課程任務追蹤與期末成果資料庫。是否繼續?')) { return; } createCourseConfigSheet_(); createWeeklyReportSheet(false); createAssignmentTrackingSheet(false); createStudentTrackingSheet(false); createCourseTaskSheet_(); createFinalDatabaseSheet_(); SpreadsheetApp.getUi().alert('已初始化課程行政模板。請先確認「課程設定」內容,再建立課程資料夾。'); } /** * 建立課程資料夾。 * 讀取:課程設定 B2:B6。 * 建立:Drive 課程資料夾與子資料夾。 * 回寫:課程設定 B6 課程資料夾連結;不會自動公開權限。 */ function createCourseWorkspace() { if (!confirmCourseAction_('這會建立課程資料夾與子資料夾,但不會自動公開權限。是否繼續?')) { return; } const config = getCourseConfig_(); if (config.folderUrl && config.folderUrl !== COURSE_FOLDER_PLACEHOLDER) { SpreadsheetApp.getUi().alert('課程資料夾連結已存在。若要重新測試,請先清空「課程設定」B6。'); return; } const folderName = `${config.term}_${config.courseName}_${config.teacher}`; const folder = DriveApp.createFolder(folderName); getCourseSubFolderNames_().forEach(name => folder.createFolder(name)); getCourseConfigSheet_().getRange('B6').setValue(folder.getUrl()); SpreadsheetApp.getUi().alert(`已建立課程資料夾:${folderName}\n\n提醒:Drive 權限請由教師確認後手動分享。`); } /** * 建立週報紀錄表。 * 讀取:無。 * 建立:週報紀錄工作表。 * 回寫:週次、日期、教學主題、待追蹤事項等範例欄位。 */ function createWeeklyReportSheet(showAlert) { const sheet = resetSheet_('週報紀錄', [ '週次', '日期', '教學主題', '本週重點', '作業或成果', '待追蹤事項', '公告文字', '任務已產生', '備註' ], [ [1, new Date('2026/09/15'), '課程導入與工具設定', '建立一週三報格式', '完成第一次週報', '確認尚未繳交名單', '請同學完成第一週學習紀錄。', '', ''], [2, new Date('2026/09/22'), '案例討論與實作', '完成分組與案例選擇', '上傳小組初稿', '追蹤缺交作業', '請各組補齊案例資料。', '', ''] ]); sheet.getRange('B2:B').setNumberFormat('yyyy/mm/dd'); if (showAlert !== false) SpreadsheetApp.getUi().alert('已建立週報紀錄表。'); } /** * 建立作業成果追蹤表。 * 讀取:無。 * 建立:作業成果追蹤工作表。 * 回寫:作業狀態、期限、成果連結等範例欄位。 */ function createAssignmentTrackingSheet(showAlert) { const sheet = resetSheet_('作業成果追蹤', [ '週次', '作業名稱', '學生代碼', '繳交狀態', '期限', '成果連結', '提醒草稿狀態', '備註' ], [ [1, '第一週學習紀錄', 'S001', '未繳交', new Date('2026/09/20'), '', '', '請使用代碼,不放完整個資。'], [1, '第一週學習紀錄', 'S002', '已繳交', new Date('2026/09/20'), '', '', ''] ]); sheet.getRange('E2:E').setNumberFormat('yyyy/mm/dd'); sheet.getRange('D2:D').setDataValidation( SpreadsheetApp.newDataValidation().requireValueInList(['未繳交', '已繳交', '需補件', '已確認'], true).build() ); if (showAlert !== false) SpreadsheetApp.getUi().alert('已建立作業成果追蹤表。'); } /** * 建立學生追蹤名單。 * 讀取:無。 * 建立:學生追蹤名單工作表。 * 回寫:學生代碼、追蹤事項、下次追蹤日、提醒事件 ID 欄位。 */ function createStudentTrackingSheet(showAlert) { const sheet = resetSheet_('學生追蹤名單', [ '學生代碼', '追蹤類型', '追蹤事項', '下次追蹤日', '狀態', '提醒事件ID', '備註' ], [ ['S001', '作業', '第一週作業未繳交', new Date('2026/09/21'), '需追蹤', '', '請勿填入敏感個資。'], ['S002', '學習支持', '確認小組分工狀況', new Date('2026/09/28'), '待確認', '', ''] ]); sheet.getRange('D2:D').setNumberFormat('yyyy/mm/dd'); sheet.getRange('E2:E').setDataValidation( SpreadsheetApp.newDataValidation().requireValueInList(COURSE_STATUSES, true).build() ); if (showAlert !== false) SpreadsheetApp.getUi().alert('已建立學生追蹤名單。'); } /** * 從週報產生待辦任務。 * 讀取:週報紀錄中的待追蹤事項。 * 建立:課程任務追蹤列。 * 回寫:週報紀錄「任務已產生」欄位。 */ function createTasksFromWeeklyReports() { if (!confirmCourseAction_('這會讀取「週報紀錄」中的待追蹤事項,新增到「課程任務追蹤」。已標記任務已產生的週次會略過。是否繼續?')) { return; } const weeklySheet = SpreadsheetApp.getActive().getSheetByName('週報紀錄'); const taskSheet = createCourseTaskSheet_(); if (!weeklySheet) { SpreadsheetApp.getUi().alert('找不到「週報紀錄」工作表。'); return; } const values = weeklySheet.getDataRange().getValues(); let created = 0; values.slice(1).forEach((row, index) => { const week = row[0]; const date = row[1]; const topic = row[2]; const followUp = row[5]; const generated = row[7]; if (!week || !followUp || generated) return; const dueDate = date instanceof Date ? new Date(date) : new Date(); dueDate.setDate(dueDate.getDate() + 7); taskSheet.appendRow([`第${week}週追蹤:${followUp}`, '教師', dueDate, '未開始', `週報主題:${topic}`, 3, '', '']); weeklySheet.getRange(index + 2, 8).setValue('已產生'); created++; }); SpreadsheetApp.getUi().alert(`已從週報產生 ${created} 筆待辦任務。`); } /** * 建立課程提醒。 * 讀取:課程任務追蹤與學生追蹤名單的日期、狀態、事件 ID。 * 建立:Google Calendar 個人提醒。 * 回寫:日曆事件 ID,避免重複建立提醒。 */ function createCourseReminders() { if (!confirmCourseAction_('這會依「課程任務追蹤」與「學生追蹤名單」建立 Calendar 個人提醒,已有事件 ID 的列會略過。是否繼續?')) { return; } const calendar = CalendarApp.getDefaultCalendar(); const taskCreated = createTaskReminders_(calendar); const studentCreated = createStudentReminders_(calendar); SpreadsheetApp.getUi().alert(`已建立課程任務提醒 ${taskCreated} 筆、學生追蹤提醒 ${studentCreated} 筆。`); } /** * 產生補交通知草稿。 * 讀取:作業成果追蹤中的未繳交與需補件資料。 * 建立:寄給自己的 Gmail 草稿。 * 回寫:無;不會自動寄出正式 Email。 */ function createMissingWorkDraft() { if (!confirmCourseAction_('這會根據「作業成果追蹤」建立補交通知草稿,不會自動寄出。是否繼續?')) { return; } const sheet = SpreadsheetApp.getActive().getSheetByName('作業成果追蹤'); if (!sheet) { SpreadsheetApp.getUi().alert('找不到「作業成果追蹤」工作表。'); return; } const missing = sheet.getDataRange().getValues().slice(1) .filter(row => row[0] && ['未繳交', '需補件'].includes(String(row[3] || ''))); const config = getCourseConfig_(); const body = [ `${config.teacher} 您好:`, '', `以下是「${config.courseName}」目前需補交或補件的作業清單:`, ...missing.map(row => `- 第${row[0]}週|${row[1]}|學生代碼:${row[2]}|狀態:${row[3]}|期限:${formatCourseDate_(row[4])}`), '', '本信為 GAS 產生的草稿,請教師確認學生資料、措辭與收件者後再寄出。' ].join('\n'); GmailApp.createDraft(Session.getActiveUser().getEmail(), `[補交通知草稿] ${config.courseName}`, body); SpreadsheetApp.getUi().alert(`已建立補交通知草稿,包含 ${missing.length} 筆需追蹤資料。`); } /** * 產生課程進度摘要。 * 讀取:週報、課程任務、作業成果、學生追蹤統計。 * 建立:期末成果資料庫列。 * 回寫:期末成果資料庫摘要。 */ function generateCourseProgressSummary() { if (!confirmCourseAction_('這會統計週報、任務、作業與學生追蹤狀態,寫入期末成果資料庫。是否繼續?')) { return; } const config = getCourseConfig_(); const summary = calculateCourseSummary_(); const sheet = createFinalDatabaseSheet_(); sheet.appendRow([ Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm'), config.courseName, config.term, summary.weeklyCount, summary.taskCount, summary.completedTaskCount, summary.missingAssignmentCount, summary.studentFollowUpCount, config.folderUrl, '由 GAS 產生,期末送出前請教師確認。' ]); SpreadsheetApp.getUi().alert('已產生課程進度摘要並寫入期末成果資料庫。'); } /** * 產生期末課程成果包。 * 讀取:課程設定與課程摘要統計。 * 建立:Google Docs 期末成果包。 * 回寫:無;若已有課程資料夾,會移動文件到期末成果子資料夾。 */ function generateFinalCoursePackage() { if (!confirmCourseAction_('這會建立期末課程成果包 Google Docs,整理週報、任務與追蹤摘要。是否繼續?')) { return; } const config = getCourseConfig_(); const summary = calculateCourseSummary_(); const doc = DocumentApp.create(`${config.courseName}_期末課程成果包`); const body = doc.getBody(); body.appendParagraph('期末課程成果包').setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(`課程名稱:${config.courseName}`); body.appendParagraph(`學期:${config.term}`); body.appendParagraph(`授課教師:${config.teacher}`); body.appendParagraph(`週報筆數:${summary.weeklyCount}`); body.appendParagraph(`任務總數:${summary.taskCount}`); body.appendParagraph(`已完成任務:${summary.completedTaskCount}`); body.appendParagraph(`待補作業:${summary.missingAssignmentCount}`); body.appendParagraph(`學生追蹤事項:${summary.studentFollowUpCount}`); body.appendParagraph('請教師補上具體成果、學生作品摘要、課程反思與下次改善方向。'); doc.saveAndClose(); moveFileToCourseFolder_(doc.getId(), '06_期末成果'); SpreadsheetApp.getUi().alert(`已建立期末課程成果包:\n${doc.getUrl()}`); } /** * 產生 AI 課程改善提示詞。 * 讀取:課程設定與課程摘要統計。 * 建立:Google Docs 提示詞文件。 * 回寫:無;不串接外部 AI API,貼到外部 AI 前請先移除個資。 */ function createCourseImprovementPrompt() { if (!confirmCourseAction_('這會建立 AI 課程改善提示詞文件。貼到外部 AI 前,請先移除學生姓名、學號、Email、電話與敏感描述。是否繼續?')) { return; } const config = getCourseConfig_(); const summary = calculateCourseSummary_(); const prompt = [ '請你擔任課程改善與教學成果整理助理。', '重要限制:以下資料若包含學生個資或敏感學習紀錄,使用者必須先去識別化後才能貼到外部 AI。', '', `課程名稱:${config.courseName}`, `學期:${config.term}`, `週報筆數:${summary.weeklyCount}`, `任務總數:${summary.taskCount}`, `已完成任務:${summary.completedTaskCount}`, `待補作業:${summary.missingAssignmentCount}`, `學生追蹤事項:${summary.studentFollowUpCount}`, '', '請協助整理:', '1. 本學期課程進度摘要', '2. 學生作業與成果趨勢', '3. 尚未完成或需追蹤事項', '4. 可保留的教學設計', '5. 下次課程可改善的具體建議', '6. 需要教師人工判斷的事項' ].join('\n'); const doc = DocumentApp.create(`${config.courseName}_AI課程改善提示詞`); doc.getBody().setText(prompt); doc.saveAndClose(); moveFileToCourseFolder_(doc.getId(), '06_期末成果'); SpreadsheetApp.getUi().alert(`已建立 AI 課程改善提示詞:\n${doc.getUrl()}`); } function createCourseConfigSheet_() { const sheet = getCourseConfigSheet_(); sheet.clear(); sheet.getRange('A1:B6').setValues([ ['欄位', '內容'], ['課程名稱', '一週三報後的課程行政流示範'], ['學期', '2026 Fall'], ['授課教師', '授課教師'], ['課程日期', new Date('2026/09/15')], ['課程資料夾連結', COURSE_FOLDER_PLACEHOLDER] ]); sheet.getRange('A1:B1').setFontWeight('bold'); sheet.getRange('B5').setNumberFormat('yyyy/mm/dd'); sheet.autoResizeColumns(1, 2); } function getCourseConfigSheet_() { const ss = SpreadsheetApp.getActive(); return ss.getSheetByName('課程設定') || ss.insertSheet('課程設定'); } function getCourseConfig_() { const sheet = getCourseConfigSheet_(); return { courseName: sheet.getRange('B2').getValue() || '課程行政流', term: sheet.getRange('B3').getValue() || '學期', teacher: sheet.getRange('B4').getValue() || '授課教師', courseDate: sheet.getRange('B5').getValue() || new Date(), folderUrl: sheet.getRange('B6').getValue() || '' }; } function createCourseTaskSheet_() { return resetSheet_('課程任務追蹤', [ '任務名稱', '負責人', '期限', '狀態', '來源或輸出', '提醒天數', '日曆事件ID', '備註' ], [ ['確認週報格式', '教師', new Date('2026/09/18'), '待確認', '週報紀錄', 2, '', ''], ['追蹤第一週缺交作業', '教師', new Date('2026/09/21'), '未開始', '作業成果追蹤', 1, '', ''] ]); } function createFinalDatabaseSheet_() { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('期末成果資料庫') || ss.insertSheet('期末成果資料庫'); if (sheet.getLastRow() === 0) { sheet.getRange(1, 1, 1, 10).setValues([[ '產生時間', '課程名稱', '學期', '週報筆數', '任務總數', '已完成任務', '待補作業', '學生追蹤事項', '課程資料夾', '備註' ]]).setFontWeight('bold'); } return sheet; } function resetSheet_(name, headers, rows) { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName(name) || ss.insertSheet(name); sheet.clear(); sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold'); if (rows.length > 0) { sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); } sheet.autoResizeColumns(1, headers.length); return sheet; } function createTaskReminders_(calendar) { const sheet = SpreadsheetApp.getActive().getSheetByName('課程任務追蹤'); if (!sheet) return 0; let created = 0; sheet.getDataRange().getValues().slice(1).forEach((row, index) => { const task = row[0]; const dueDate = row[2]; const status = row[3]; const reminderDays = Number(row[5] || 0); const eventId = row[6]; if (!task || !(dueDate instanceof Date) || status === '已完成' || eventId) return; const reminderDate = new Date(dueDate); reminderDate.setDate(reminderDate.getDate() - reminderDays); const event = calendar.createAllDayEvent(`[課程提醒] ${task}`, reminderDate); sheet.getRange(index + 2, 7).setValue(event.getId()); created++; }); return created; } function createStudentReminders_(calendar) { const sheet = SpreadsheetApp.getActive().getSheetByName('學生追蹤名單'); if (!sheet) return 0; let created = 0; sheet.getDataRange().getValues().slice(1).forEach((row, index) => { const studentCode = row[0]; const issue = row[2]; const date = row[3]; const status = row[4]; const eventId = row[5]; if (!studentCode || !(date instanceof Date) || status === '已完成' || eventId) return; const event = calendar.createAllDayEvent(`[學生追蹤] ${studentCode} ${issue}`, date); sheet.getRange(index + 2, 6).setValue(event.getId()); created++; }); return created; } function calculateCourseSummary_() { const ss = SpreadsheetApp.getActive(); const weekly = ss.getSheetByName('週報紀錄'); const tasks = ss.getSheetByName('課程任務追蹤'); const assignments = ss.getSheetByName('作業成果追蹤'); const students = ss.getSheetByName('學生追蹤名單'); const taskRows = tasks ? tasks.getDataRange().getValues().slice(1).filter(row => row[0]) : []; const assignmentRows = assignments ? assignments.getDataRange().getValues().slice(1).filter(row => row[0]) : []; const studentRows = students ? students.getDataRange().getValues().slice(1).filter(row => row[0]) : []; return { weeklyCount: weekly ? weekly.getDataRange().getValues().slice(1).filter(row => row[0]).length : 0, taskCount: taskRows.length, completedTaskCount: taskRows.filter(row => row[3] === '已完成').length, missingAssignmentCount: assignmentRows.filter(row => ['未繳交', '需補件'].includes(String(row[3] || ''))).length, studentFollowUpCount: studentRows.filter(row => row[4] !== '已完成').length }; } function moveFileToCourseFolder_(fileId, childFolderName) { const folderId = extractCourseDriveId_(getCourseConfig_().folderUrl); if (!folderId) return; const courseFolder = DriveApp.getFolderById(folderId); const childFolder = getOrCreateCourseChildFolder_(courseFolder, childFolderName); const file = DriveApp.getFileById(fileId); childFolder.addFile(file); DriveApp.getRootFolder().removeFile(file); } function getCourseSubFolderNames_() { return ['01_週報紀錄', '02_作業成果', '03_學生追蹤', '04_課程提醒', '05_通知草稿', '06_期末成果']; } function getOrCreateCourseChildFolder_(parentFolder, folderName) { const folders = parentFolder.getFoldersByName(folderName); return folders.hasNext() ? folders.next() : parentFolder.createFolder(folderName); } function extractCourseDriveId_(urlOrId) { const text = String(urlOrId || '').trim(); const folderMatch = text.match(/folders\/([a-zA-Z0-9_-]+)/); if (folderMatch) return folderMatch[1]; const idMatch = text.match(/[-\w]{25,}/); return idMatch ? idMatch[0] : ''; } function formatCourseDate_(value) { if (!(value instanceof Date)) return '未填'; return Utilities.formatDate(value, Session.getScriptTimeZone(), 'yyyy/MM/dd'); } function confirmCourseAction_(message) { const ui = SpreadsheetApp.getUi(); return ui.alert('課程行政流提醒', message, ui.ButtonSet.OK_CANCEL) === ui.Button.OK; }