/** * 進階行政工作流小系統 * * 用途: * 從一鍵建案升級成可管理報名/填報流程的行政工作流小系統。 * 在 basic 版之上加入專案類型、Google Form 表單建立、表單連結回寫、 * 應填名單、報名/填報狀況檢查、未填提醒草稿、文件清單、逾期檢查、 * 每日觸發器、報名狀況摘要、結案資料庫與 AI 結案提示詞文件。 * * 使用階段: * 下半場 advanced GAS 進階行政工作流實作。 * * 安全原則: * - Gmail 只建立草稿,不自動寄出。 * - Drive 與 Google Form 只建立資源,不自動公開權限。 * - AI 提示詞只保存成 Google Docs,使用前請人工移除個資與敏感資料。 * - 應填名單若含學生資料,建議使用代碼或去識別化。 * - 表單連結公開前需由承辦人確認,報名狀況摘要也需人工確認後才對外提供。 * - 大量修改 Sheets、Drive、Calendar、Gmail 或 Script 觸發器前,都先跳出確認提醒。 */ const ADVANCED_FOLDER_PLACEHOLDER = '由 GAS 自動產生'; const ADVANCED_DEFAULT_PROJECT_TYPE = '教師研習'; const ADVANCED_TASK_STATUSES = ['未開始', '進行中', '待確認', '已完成', '暫停', '逾期']; /** * 建立試算表自訂選單。 * 讀取:無。 * 建立:Google Sheets 的「行政工作流」選單。 * 回寫:無。 */ function onOpen() { SpreadsheetApp.getUi() .createMenu('行政工作流') .addItem('初始化進階模板', 'setupAdvancedTemplate') .addSeparator() .addItem('依專案類型建立任務表', 'createTaskSheet') .addItem('建立專案資料夾', 'createProjectWorkspace') .addItem('建立報名/回覆表單', 'createResponseForm') .addItem('取得並回寫表單連結', 'writeFormLinksToSheet') .addItem('建立應填名單表', 'createExpectedRespondentSheet') .addItem('檢查報名/填報狀況', 'checkRegistrationStatus') .addItem('產生未填提醒草稿', 'createMissingResponseDraft') .addItem('建立文件清單與 Docs 草稿', 'createDocumentListAndDrafts') .addItem('建立 Calendar 提醒', 'createCalendarReminders') .addItem('檢查逾期任務', 'checkOverdueTasksFromMenu') .addItem('建立每日檢查觸發器', 'createDailyTrigger') .addItem('刪除每日檢查觸發器', 'deleteDailyTrigger') .addSeparator() .addItem('產生報名狀況摘要', 'generateRegistrationSummary') .addItem('產生結案摘要', 'generateClosureSummary') .addItem('產生 AI 結案提示詞', 'createAiClosurePrompt') .addToUi(); } function getConfigSheet() { const ss = SpreadsheetApp.getActive(); return ss.getSheetByName('專案設定') || ss.getSheets()[0]; } function setupTemplate() { setupBasicTemplate(); } /** * 初始化基礎模板。 * 讀取:目前試算表。 * 建立:專案設定表與基礎任務追蹤表。 * 回寫:專案設定 B2:B5 與任務追蹤 A:I。 */ function setupBasicTemplate() { if (!confirmAdvancedAction_('這會清空「專案設定」並重建基礎任務追蹤表。是否繼續?')) { return; } resetConfigSheet_(false); resetTaskSheet_(getBasicTaskRows_()); SpreadsheetApp.getUi().alert('已初始化基礎模板。'); } /** * 初始化進階模板。 * 讀取:目前試算表。 * 建立:專案設定、任務追蹤、文件清單、表單管理、應填名單、結案資料庫。 * 回寫:專案設定 B2:B10,並在 B6/B8 加入固定選項。 */ function setupAdvancedTemplate() { if (!confirmAdvancedAction_('這會清空「專案設定」,並重建「任務追蹤」、「文件清單」、「表單管理」、「應填名單」、「結案資料庫」工作表。是否繼續?')) { return; } resetConfigSheet_(true); createTaskSheet(false); resetDocumentListSheet_(true); resetFormManagementSheet_(true); resetExpectedRespondentSheet_(true); resetClosureDatabaseSheet_(true); SpreadsheetApp.getUi().alert('已初始化進階模板。請確認 B2:B10 後再建立專案資料夾與報名/回覆表單。'); } /** * 建立 Drive 專案資料夾。 * 讀取:專案設定 B2:B7,包含 B7 根資料夾 ID。 * 建立:Drive 專案資料夾與標準子資料夾。 * 回寫:B5 專案資料夾連結;不會自動開放 Drive 權限。 */ function createProjectWorkspace() { if (!confirmAdvancedAction_('這會建立專案資料夾與標準子資料夾,但不會自動開放 Drive 權限。若 B7 有填根資料夾 ID,會建立在該資料夾底下。是否繼續?')) { return; } const sheet = getConfigSheet(); const config = getProjectConfig_(); if (config.folderUrl && config.folderUrl !== ADVANCED_FOLDER_PLACEHOLDER) { SpreadsheetApp.getUi().alert('此專案已建立資料夾。若要重新建立,請先清空 B5。'); return; } const yyyyMMdd = Utilities.formatDate(config.projectDate, Session.getScriptTimeZone(), 'yyyyMMdd'); const folderName = `${yyyyMMdd}_${config.projectName}_${config.unit}`; let rootFolder; try { rootFolder = createProjectRootFolder_(folderName, config.rootFolderId); } catch (error) { SpreadsheetApp.getUi().alert(`無法建立專案資料夾,請確認 B7 根資料夾 ID 是否正確。\n\n錯誤訊息:${error.message}`); return; } getSubFolderNames_().forEach(name => getOrCreateChildFolder_(rootFolder, name)); sheet.getRange('B5').setValue(rootFolder.getUrl()); SpreadsheetApp.getUi().alert(`已建立專案資料夾:${folderName}\n\n權限提醒:本程式不會自動開放權限,請由承辦人確認後手動分享。`); } /** * 建立報名/回覆 Google Form。 * 讀取:專案設定 B2 專案名稱、B6 專案類型、B8 表單用途、B9 截止時間。 * 建立:Google Form 與回應試算表。 * 回寫:表單管理的填寫連結、編輯連結、回應表連結、開放狀態與檢查時間。 */ function createResponseForm() { if (!confirmAdvancedAction_('這會建立 Google Form 與回應試算表,並把連結回寫到「表單管理」。不會自動寄信,也不會自動公開權限。是否繼續?')) { return; } const config = getProjectConfig_(); const sheet = resetFormManagementSheet_(false); const formRow = getFormManagementRow_(sheet); const existingEditUrl = formRow ? formRow.values[3] : ''; if (existingEditUrl) { SpreadsheetApp.getUi().alert('表單管理已有表單編輯連結。若要更新連結,請執行「取得並回寫表單連結」;若要重建表單,請先清空該列連結。'); return; } const formTitle = getResponseFormTitle_(config); const fullFormName = `${config.projectName}_${formTitle}`; const form = FormApp.create(fullFormName); form.setTitle(fullFormName); form.setDescription([ `專案名稱:${config.projectName}`, `專案類型:${config.projectType}`, `表單用途:${config.formPurpose}`, '提醒:表單連結公開前,請由承辦人確認欄位、權限與個資蒐集範圍。' ].join('\n')); form.setCollectEmail(false); form.setAcceptingResponses(true); form.addTextItem().setTitle('姓名').setRequired(true); form.addTextItem().setTitle('單位/班級').setRequired(true); form.addTextItem().setTitle('Email').setRequired(true); form.addParagraphTextItem().setTitle('回覆事項').setRequired(true); form.addParagraphTextItem().setTitle('備註').setRequired(false); const responseSpreadsheet = SpreadsheetApp.create(`${fullFormName}_回應`); form.setDestination(FormApp.DestinationType.SPREADSHEET, responseSpreadsheet.getId()); moveFileToProjectFolder_(responseSpreadsheet.getId(), '03_名冊表單'); const rowIndex = formRow ? formRow.rowIndex : Math.max(sheet.getLastRow() + 1, 2); writeFormRecord_(sheet, rowIndex, { formName: fullFormName, purpose: config.formPurpose, publishedUrl: form.getPublishedUrl(), editUrl: form.getEditUrl(), responseUrl: responseSpreadsheet.getUrl(), status: '開放中', deadline: config.formDeadline, responseCount: 0, checkedAt: formatAdvancedDateTime_(new Date()), note: '表單連結公開前需由承辦人確認;本程式不自動寄信、不自動公開權限。' }); SpreadsheetApp.getUi().alert(`已建立表單:${formTitle}\n\n填寫連結:${form.getPublishedUrl()}\n\n提醒:請人工確認表單權限與內容後再對外提供。`); } /** * 取得目前表單連結並回寫表單管理。 * 讀取:表單管理編輯連結。 * 建立:無。 * 回寫:填寫連結、編輯連結、回應表連結、回覆數與最後檢查時間。 */ function writeFormLinksToSheet() { if (!confirmAdvancedAction_('這會讀取目前專案表單,並更新「表單管理」中的填寫連結、編輯連結與回應表連結。是否繼續?')) { return; } const sheet = resetFormManagementSheet_(false); const formRow = getFormManagementRow_(sheet); if (!formRow || !formRow.values[3]) { SpreadsheetApp.getUi().alert('尚未找到表單編輯連結。請先執行「建立報名/回覆表單」。'); return; } let form; try { form = FormApp.openByUrl(formRow.values[3]); } catch (error) { SpreadsheetApp.getUi().alert(`無法開啟表單,請確認「表單管理」中的編輯連結是否正確。\n\n錯誤訊息:${error.message}`); return; } const responseSpreadsheet = getResponseSpreadsheetFromForm_(form); const checkedAt = formatAdvancedDateTime_(new Date()); writeFormRecord_(sheet, formRow.rowIndex, { formName: form.getTitle(), purpose: formRow.values[1] || getProjectConfig_().formPurpose, publishedUrl: form.getPublishedUrl(), editUrl: form.getEditUrl(), responseUrl: responseSpreadsheet ? responseSpreadsheet.getUrl() : formRow.values[4], status: form.isAcceptingResponses() ? '開放中' : '已關閉', deadline: formRow.values[6], responseCount: form.getResponses().length, checkedAt, note: appendNote_(formRow.values[9], `已於 ${checkedAt} 更新表單連結。`) }); SpreadsheetApp.getUi().alert('已更新表單管理連結與最後檢查時間。'); } /** * 建立或重建應填名單工作表。 * 讀取:無。 * 建立:應填名單工作表。 * 回寫:三筆範例資料;正式使用前請改成自己的應填名單。 */ function createExpectedRespondentSheet() { if (!confirmAdvancedAction_('這會清空並重建「應填名單」範例資料。正式使用前請改成自己的應填名單。是否繼續?')) { return; } resetExpectedRespondentSheet_(true); SpreadsheetApp.getUi().alert('已建立「應填名單」工作表。請正式使用前改成自己的應填名單;若含學生資料,建議使用代碼或去識別化。'); } /** * 檢查報名/填報狀況。 * 讀取:表單管理回應表連結與應填名單。 * 建立:無。 * 回寫:應填名單是否已填、填寫時間;表單管理回覆數與最後檢查時間。 */ function checkRegistrationStatus() { if (!confirmAdvancedAction_('這會比對「應填名單」與表單回應資料,標記已填/未填並更新回覆數。是否繼續?')) { return; } const formSheet = resetFormManagementSheet_(false); const formRow = getFormManagementRow_(formSheet); const expectedSheet = SpreadsheetApp.getActive().getSheetByName('應填名單'); if (!expectedSheet) { SpreadsheetApp.getUi().alert('找不到「應填名單」工作表,請先執行「建立應填名單表」。'); return; } const responseSpreadsheet = getResponseSpreadsheetFromRecord_(formRow); if (!responseSpreadsheet) { SpreadsheetApp.getUi().alert('找不到表單回應表。請先執行「建立報名/回覆表單」,或確認「表單管理」中的回應表連結。'); return; } const responseSheet = responseSpreadsheet.getSheets()[0]; const responseValues = responseSheet.getDataRange().getValues(); const responseMap = buildResponseMap_(responseValues); const expectedValues = expectedSheet.getDataRange().getValues(); let filled = 0; let missing = 0; expectedValues.slice(1).forEach((row, index) => { const name = String(row[0] || '').trim(); const email = String(row[2] || '').trim().toLowerCase(); if (!name && !email) { return; } const response = responseMap[email] || responseMap[name]; if (response) { expectedSheet.getRange(index + 2, 5).setValue('已填'); expectedSheet.getRange(index + 2, 6).setValue(response.timestamp); filled++; } else { expectedSheet.getRange(index + 2, 5).setValue('未填'); expectedSheet.getRange(index + 2, 6).setValue(''); missing++; } }); const checkedAt = formatAdvancedDateTime_(new Date()); if (formRow) { formSheet.getRange(formRow.rowIndex, 8).setValue(Math.max(responseValues.length - 1, 0)); formSheet.getRange(formRow.rowIndex, 9).setValue(checkedAt); } SpreadsheetApp.getUi().alert(`填報狀況檢查完成。\n已填:${filled}\n未填:${missing}\n\n提醒:報名狀況摘要對外提供前需人工確認。`); } /** * 產生未填提醒 Gmail 草稿。 * 讀取:應填名單是否已填與 Email。 * 建立:寄給自己的 Gmail 草稿。 * 回寫:提醒狀態「已產生草稿」;不會自動寄給未填者。 */ function createMissingResponseDraft() { if (!confirmAdvancedAction_('這會依「應填名單」產生一封寄給自己的未填提醒草稿,不會寄給未填者,也不會自動寄出。是否繼續?')) { return; } const sheet = SpreadsheetApp.getActive().getSheetByName('應填名單'); if (!sheet) { SpreadsheetApp.getUi().alert('找不到「應填名單」工作表。'); return; } const config = getProjectConfig_(); const rows = sheet.getDataRange().getValues(); const missing = []; rows.slice(1).forEach((row, index) => { const status = row[4]; const email = row[2]; if (status === '未填' && email) { missing.push({ rowIndex: index + 2, name: row[0], unit: row[1], email, role: row[3] }); } }); if (!missing.length) { SpreadsheetApp.getUi().alert('目前沒有「未填且 Email 不為空」的資料可產生提醒草稿。'); return; } const body = [ '以下為尚未完成報名/填報的人員,請承辦人確認後再決定是否寄出正式提醒:', '', ...missing.map(item => `- ${item.name || '未填姓名'}|${item.unit || '未填單位'}|${item.role || '未填身分'}|${item.email}`), '', '提醒文字建議:', `請協助於期限前完成「${config.formPurpose}」。如已填寫,請忽略此提醒。`, '', '本信由 GAS 產生為草稿,不會自動寄出,也不會自動寄給未填者。' ].join('\n'); GmailApp.createDraft( Session.getActiveUser().getEmail(), `[草稿待確認] ${config.projectName} 未填提醒`, body ); missing.forEach(item => sheet.getRange(item.rowIndex, 7).setValue('已產生草稿')); SpreadsheetApp.getUi().alert(`已建立未填提醒 Gmail 草稿,共列出 ${missing.length} 筆未填名單。`); } /** * 產生報名/填報狀況摘要文件。 * 讀取:表單管理與應填名單。 * 建立:Google Docs 報名狀況摘要。 * 回寫:表單管理備註欄加入摘要文件連結。 */ function generateRegistrationSummary() { if (!confirmAdvancedAction_('這會依「應填名單」產生報名/填報狀況摘要 Docs。摘要對外提供前仍需人工確認。是否繼續?')) { return; } const config = getProjectConfig_(); const formSheet = resetFormManagementSheet_(false); const formRow = getFormManagementRow_(formSheet); const expectedSheet = SpreadsheetApp.getActive().getSheetByName('應填名單'); if (!expectedSheet) { SpreadsheetApp.getUi().alert('找不到「應填名單」工作表。'); return; } const rows = expectedSheet.getDataRange().getValues().slice(1).filter(row => row[0] || row[2]); const total = rows.length; const filledRows = rows.filter(row => row[4] === '已填'); const missingRows = rows.filter(row => row[4] !== '已填'); const completionRate = total ? Math.round((filledRows.length / total) * 1000) / 10 : 0; const checkedAt = formRow ? formRow.values[8] || formatAdvancedDateTime_(new Date()) : formatAdvancedDateTime_(new Date()); const formName = formRow ? formRow.values[0] : getResponseFormTitle_(config); const publishedUrl = formRow ? formRow.values[2] : ''; const doc = DocumentApp.create(`${config.projectName}_報名填報狀況摘要`); const body = doc.getBody(); body.appendParagraph('報名/填報狀況摘要').setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(`專案名稱:${config.projectName}`); body.appendParagraph(`表單名稱:${formName || '未建立'}`); body.appendParagraph(`填寫連結:${publishedUrl || '未建立'}`); body.appendParagraph(`應填人數:${total}`); body.appendParagraph(`已填人數:${filledRows.length}`); body.appendParagraph(`未填人數:${missingRows.length}`); body.appendParagraph(`完成率:${completionRate}%`); body.appendParagraph(`最後檢查時間:${checkedAt}`); body.appendParagraph('未填名單').setHeading(DocumentApp.ParagraphHeading.HEADING2); body.appendParagraph(missingRows.length ? missingRows.map(row => `- ${row[0] || '未填姓名'}|${row[1] || '未填單位'}|${row[2] || '未填 Email'}`).join('\n') : '目前沒有未填名單。'); body.appendParagraph('後續提醒建議').setHeading(DocumentApp.ParagraphHeading.HEADING2); body.appendParagraph('請承辦人確認應填名單、回應資料與個資內容後,再決定是否對外提供摘要或寄出提醒。'); doc.saveAndClose(); const projectFolder = getProjectFolderFromConfig_(config); if (projectFolder) { const docsFolder = getOrCreateChildFolder_(projectFolder, '06_成果結案'); const file = DriveApp.getFileById(doc.getId()); docsFolder.addFile(file); DriveApp.getRootFolder().removeFile(file); } if (formRow) { formSheet.getRange(formRow.rowIndex, 10).setValue(appendNote_(formRow.values[9], `報名狀況摘要:${doc.getUrl()}`)); } SpreadsheetApp.getUi().alert(`已建立報名/填報狀況摘要:\n${doc.getUrl()}\n\n提醒:摘要對外提供前請人工確認。`); } /** * 依專案類型建立任務表。 * 讀取:專案設定 B6 專案類型與 B3 專案日期。 * 建立:任務追蹤工作表。 * 回寫:任務追蹤 A:I,I 欄保留給日曆事件 ID。 */ function createTaskSheet(showAlert) { const shouldShowAlert = showAlert !== false; if (shouldShowAlert && !confirmAdvancedAction_('這會依 B6 專案類型清空並重建「任務追蹤」工作表。是否繼續?')) { return; } const projectType = getProjectConfig_().projectType; const rows = getTaskRowsByProjectType_(projectType); resetTaskSheet_(rows); if (shouldShowAlert) { SpreadsheetApp.getUi().alert(`已依「${projectType}」建立任務追蹤表。`); } } /** * 取得指定專案類型的任務範本。 * 讀取:專案類型文字。 * 建立:無。 * 回寫:無;供講義模組導讀與客製擴充參考。 */ function getTaskTemplatesByType(projectType) { return getTaskRowsByProjectType_(projectType || ADVANCED_DEFAULT_PROJECT_TYPE); } /** * 依專案類型建立任務表。 * 讀取:專案設定 B6 專案類型。 * 建立:任務追蹤工作表。 * 回寫:任務追蹤 A:I。 */ function createTaskSheetByProjectType() { createTaskSheet(); } /** * 取得建案根資料夾。 * 讀取:根資料夾 ID。 * 建立:無。 * 回寫:無;B7 空白時回傳我的雲端硬碟根目錄。 */ function getRootFolder(rootFolderId) { if (!rootFolderId) { return DriveApp.getRootFolder(); } return DriveApp.getFolderById(extractDriveId_(rootFolderId)); } /** * 在指定根資料夾建立專案工作區。 * 讀取:專案設定 B2:B7。 * 建立:Drive 專案資料夾與子資料夾。 * 回寫:B5 專案資料夾連結。 */ function createProjectWorkspaceInRoot() { createProjectWorkspace(); } /** * 建立文件清單工作表。 * 讀取:無。 * 建立:文件清單工作表與範例列。 * 回寫:文件清單欄位;不建立 Docs 草稿。 */ function createDocumentListSheet() { resetDocumentListSheet_(false); SpreadsheetApp.getUi().alert('已建立或確認「文件清單」工作表。'); } /** * 建立文件清單與 Google Docs 草稿。 * 讀取:專案設定 B5 專案資料夾連結與文件清單。 * 建立:Google Docs 草稿與必要子資料夾。 * 回寫:文件清單狀態與 Docs 連結;不會自動公開權限。 */ function createDocumentListAndDrafts() { if (!confirmAdvancedAction_('這會建立「文件清單」工作表與至少四份 Google Docs 草稿,並把連結回寫到文件清單。已有連結的文件會略過。是否繼續?')) { return; } const config = getProjectConfig_(); const projectFolder = getProjectFolderFromConfig_(config); if (!projectFolder) { SpreadsheetApp.getUi().alert('請先執行「建立專案資料夾」,讓 B5 有專案資料夾連結。'); return; } const sheet = ensureDocumentListSheet_(); const values = sheet.getDataRange().getValues(); let created = 0; let skipped = 0; values.slice(1).forEach((row, index) => { const documentName = row[0]; const documentType = row[1]; const folderName = row[2] || '02_工作文件'; const existingUrl = row[5]; if (!documentName || existingUrl) { skipped++; return; } const docsFolder = getOrCreateChildFolder_(projectFolder, folderName); const doc = DocumentApp.create(`${config.projectName}_${documentName}`); const docFile = DriveApp.getFileById(doc.getId()); docsFolder.addFile(docFile); DriveApp.getRootFolder().removeFile(docFile); writeDraftDocument_(doc, config, documentName, documentType); sheet.getRange(index + 2, 4).setValue('已建立草稿'); sheet.getRange(index + 2, 6).setValue(doc.getUrl()); created++; }); SpreadsheetApp.getUi().alert(`已建立 ${created} 份 Docs 草稿,略過 ${skipped} 筆。\n\n提醒:本程式不會自動開放文件權限。`); } /** * 建立 Calendar 提醒。 * 讀取:任務追蹤 A 任務名稱、C 期限、D 狀態、F 提醒天數、I 日曆事件ID。 * 建立:Google Calendar 全天事件。 * 回寫:任務追蹤 I 欄日曆事件 ID,用來避免重複建立事件。 */ function createCalendarReminders() { if (!confirmAdvancedAction_('這會依任務期限建立 Calendar 提醒。已有日曆事件ID的列會略過,避免重複事件。是否繼續?')) { return; } const ss = SpreadsheetApp.getActive(); const config = getProjectConfig_(); const sheet = ss.getSheetByName('任務追蹤'); if (!sheet) { SpreadsheetApp.getUi().alert('找不到「任務追蹤」工作表。'); return; } const rows = sheet.getDataRange().getValues(); const calendar = CalendarApp.getDefaultCalendar(); let created = 0; let skipped = 0; rows.slice(1).forEach((row, index) => { const taskName = row[0]; const owner = row[1]; const dueDate = row[2]; const status = row[3]; const output = row[4]; const reminderDays = Number(row[5] || 0); const risk = row[6]; const note = row[7]; const eventId = row[8]; if (!taskName || status === '已完成' || !(dueDate instanceof Date) || eventId) { skipped++; return; } const reminderDate = new Date(dueDate); reminderDate.setDate(reminderDate.getDate() - reminderDays); const event = calendar.createAllDayEvent(`[行政提醒] ${taskName}`, reminderDate, { description: [ `專案名稱:${config.projectName}`, `專案類型:${config.projectType}`, `負責人:${owner || '未填'}`, `原始期限:${formatAdvancedDate_(dueDate)}`, `輸出文件:${output || '未填'}`, `提醒天數:${reminderDays}`, `風險提醒:${risk || '無'}`, `備註:${note || '無'}` ].join('\n') }); sheet.getRange(index + 2, 9).setValue(event.getId()); created++; }); SpreadsheetApp.getUi().alert(`已建立 ${created} 個日曆提醒,略過 ${skipped} 筆。`); } /** * 產生 Gmail 建案通知草稿。 * 讀取:專案設定 B2:B6 與 B5 資料夾連結。 * 建立:寄給自己的 Gmail 草稿。 * 回寫:無;不會自動寄出正式 Email。 */ function createNotificationDraft() { if (!confirmAdvancedAction_('這會建立一封寄給自己的 Gmail 草稿,不會自動寄出。是否繼續?')) { return; } const config = getProjectConfig_(); const subject = `[草稿待確認] ${config.projectName} 行政工作流建案通知`; const body = [ `${config.unit} 您好:`, '', `「${config.projectName}」已建立進階行政工作流資料。`, `專案類型:${config.projectType}`, config.folderUrl ? `專案資料夾:${config.folderUrl}` : '專案資料夾:請補上連結', '', '寄出前請人工確認:', '1. 收件者與副本是否正確', '2. 是否包含學生、家長或教職員個資', '3. 正式公告文字是否已完成校內核定', '4. Drive 權限是否只開放給必要人員', '', '本信為 GAS 產生的草稿,不會自動寄出。' ].join('\n'); GmailApp.createDraft(Session.getActiveUser().getEmail(), subject, body); SpreadsheetApp.getUi().alert('已建立 Gmail 草稿,請到 Gmail 草稿匣確認後再寄出。'); } /** * 選單用逾期檢查包裝函式。 * 讀取:任務追蹤期限與狀態。 * 建立:Gmail 逾期檢查草稿。 * 回寫:逾期狀態與備註檢查時間。 */ function checkOverdueTasksFromMenu() { if (!confirmAdvancedAction_('這會檢查「任務追蹤」期限,將逾期且未完成的任務標記為「逾期」,並建立寄給自己的 Gmail 草稿。是否繼續?')) { return; } const count = checkOverdueTasks(); SpreadsheetApp.getUi().alert(`逾期檢查完成。已標記 ${count} 筆,並建立 Gmail 草稿。`); } /** * 逾期檢查核心函式,可由每日觸發器執行。 * 讀取:任務追蹤 C 期限、D 狀態、H 備註。 * 建立:寄給自己的 Gmail 草稿。 * 回寫:D 欄「逾期」與 H 欄檢查時間;不使用 UI alert,避免觸發器失敗。 */ function checkOverdueTasks() { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('任務追蹤'); if (!sheet) { return 0; } const rows = sheet.getDataRange().getValues(); const today = startOfDay_(new Date()); const checkedAt = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm'); const overdueTasks = []; rows.slice(1).forEach((row, index) => { const taskName = row[0]; const owner = row[1]; const dueDate = row[2]; const status = row[3]; const note = row[7] || ''; if (!taskName || !(dueDate instanceof Date) || status === '已完成') { return; } if (startOfDay_(dueDate).getTime() < today.getTime()) { sheet.getRange(index + 2, 4).setValue('逾期'); sheet.getRange(index + 2, 8).setValue(`${note}\n逾期檢查:${checkedAt}`.trim()); overdueTasks.push([taskName, owner || '未填', formatAdvancedDate_(dueDate), status || '未填']); } }); createOverdueDraft_(overdueTasks, checkedAt); return overdueTasks.length; } /** * 建立每日逾期檢查觸發器。 * 讀取:現有 Apps Script 觸發器。 * 建立:每天上午 8 點執行 checkOverdueTasks 的時間觸發器。 * 回寫:無;會避免重複建立相同觸發器。 */ function createDailyTrigger() { if (!confirmAdvancedAction_('這會建立每天上午 8 點執行 checkOverdueTasks 的時間觸發器。已有相同觸發器時不會重複建立。是否繼續?')) { return; } const exists = ScriptApp.getProjectTriggers() .some(trigger => trigger.getHandlerFunction() === 'checkOverdueTasks'); if (exists) { SpreadsheetApp.getUi().alert('已存在 checkOverdueTasks 觸發器,未重複建立。'); return; } ScriptApp.newTrigger('checkOverdueTasks') .timeBased() .everyDays(1) .atHour(8) .create(); SpreadsheetApp.getUi().alert('已建立每日檢查觸發器:每天上午 8 點執行。'); } /** * 刪除每日逾期檢查觸發器。 * 讀取:現有 Apps Script 觸發器。 * 建立:無。 * 回寫:無;刪除 checkOverdueTasks 相關觸發器。 */ function deleteDailyTrigger() { if (!confirmAdvancedAction_('這會刪除所有 checkOverdueTasks 的每日檢查觸發器。是否繼續?')) { return; } let deleted = 0; ScriptApp.getProjectTriggers().forEach(trigger => { if (trigger.getHandlerFunction() === 'checkOverdueTasks') { ScriptApp.deleteTrigger(trigger); deleted++; } }); SpreadsheetApp.getUi().alert(`已刪除 ${deleted} 個 checkOverdueTasks 觸發器。`); } /** * 產生結案摘要。 * 讀取:任務追蹤任務狀態與風險提醒。 * 建立:結案資料庫工作表列。 * 回寫:結案資料庫統計資料。 */ function generateClosureSummary() { if (!confirmAdvancedAction_('這會統計任務狀態並寫入「結案資料庫」。是否繼續?')) { return; } const ss = SpreadsheetApp.getActive(); const taskSheet = ss.getSheetByName('任務追蹤'); if (!taskSheet) { SpreadsheetApp.getUi().alert('找不到「任務追蹤」工作表。'); return; } const config = getProjectConfig_(); const rows = taskSheet.getDataRange().getValues().slice(1).filter(row => row[0]); const total = rows.length; const completed = rows.filter(row => row[3] === '已完成').length; const overdue = rows.filter(row => row[3] === '逾期').length; const pendingReview = rows.filter(row => row[3] === '待確認').length; const riskCount = rows.filter(row => String(row[6] || '').trim()).length; const generatedAt = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm'); const sheet = resetClosureDatabaseSheet_(false); sheet.appendRow([ generatedAt, config.projectName, config.projectType, total, completed, overdue, pendingReview, riskCount, config.folderUrl || '', '由 GAS 統計,正式結案前請人工確認。' ]); sheet.autoResizeColumns(1, 10); SpreadsheetApp.getUi().alert('已寫入結案資料庫。'); } /** * 產生 AI 結案提示詞文件。 * 讀取:專案設定與任務追蹤摘要。 * 建立:Google Docs 提示詞文件。 * 回寫:無;不串接外部 AI API,貼到外部 AI 前請先移除個資。 */ function createAiClosurePrompt() { if (!confirmAdvancedAction_('這會產生一份可複製到 AI 的結案整理提示詞,並建立 Google Docs 保存。請在送交外部 AI 前人工移除個資與敏感資料。是否繼續?')) { return; } const config = getProjectConfig_(); const prompt = buildAiClosurePrompt_(); const doc = DocumentApp.create(`${config.projectName}_AI結案提示詞`); doc.getBody().setText(prompt); doc.saveAndClose(); const projectFolder = getProjectFolderFromConfig_(config); if (projectFolder) { const docsFolder = getOrCreateChildFolder_(projectFolder, '06_成果結案'); const file = DriveApp.getFileById(doc.getId()); docsFolder.addFile(file); DriveApp.getRootFolder().removeFile(file); } SpreadsheetApp.getUi().alert(`已建立 AI 結案提示詞文件:\n${doc.getUrl()}\n\n提醒:複製到外部 AI 前,請人工刪除個資與敏感資料。`); } function resetConfigSheet_(advanced) { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('專案設定') || ss.getActiveSheet(); sheet.setName('專案設定'); sheet.clear(); const values = [ ['欄位', '內容'], ['專案名稱', '教師研習報名流程示範'], ['專案日期', new Date('2026/09/15')], ['承辦單位', '教務處'], ['專案資料夾連結', ADVANCED_FOLDER_PLACEHOLDER] ]; if (advanced) { values.push(['專案類型', ADVANCED_DEFAULT_PROJECT_TYPE]); values.push(['根資料夾 ID', '']); values.push(['表單用途', '研習報名']); values.push(['報名/填報截止時間', new Date('2026/09/10 17:00')]); values.push(['人數上限或預期回覆數', 30]); } sheet.getRange(1, 1, values.length, 2).setValues(values); sheet.getRange('A1:B1').setFontWeight('bold'); sheet.getRange('B3').setNumberFormat('yyyy/mm/dd'); if (advanced) { sheet.getRange('B6').setDataValidation( SpreadsheetApp.newDataValidation() .requireValueInList(getProjectTypes_(), true) .build() ); sheet.getRange('B8').setDataValidation( SpreadsheetApp.newDataValidation() .requireValueInList(getFormPurposes_(), true) .build() ); sheet.getRange('B9').setNumberFormat('yyyy/mm/dd hh:mm'); } sheet.autoResizeColumns(1, 2); } function resetTaskSheet_(rows) { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('任務追蹤') || ss.insertSheet('任務追蹤'); const headers = ['任務名稱', '負責人', '期限', '狀態', '輸出文件', '提醒天數', '風險提醒', '備註', '日曆事件ID']; sheet.clear(); sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold'); sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); sheet.getRange(2, 3, rows.length, 1).setNumberFormat('yyyy/mm/dd'); sheet.getRange('D2:D').setDataValidation( SpreadsheetApp.newDataValidation() .requireValueInList(ADVANCED_TASK_STATUSES, true) .build() ); sheet.autoResizeColumns(1, headers.length); } function resetDocumentListSheet_(clearSheet) { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('文件清單') || ss.insertSheet('文件清單'); const headers = ['文件名稱', '文件類型', '存放資料夾', '狀態', '負責人', '連結', '備註']; const rows = [ ['專案摘要', 'Google Docs', '02_工作文件', '待建立', '承辦人', '', '說明專案目的、對象、時程與待確認事項。'], ['通知草稿', 'Google Docs', '04_通知公告', '待建立', '承辦人', '', '正式寄出前需人工確認收件者與內容。'], ['會議紀錄', 'Google Docs', '05_會議與協調', '待建立', '承辦人', '', '記錄決議、待辦與確認者。'], ['結案報告草稿', 'Google Docs', '06_成果結案', '待建立', '承辦人', '', '活動或案件完成後補上實際成果。'] ]; if (clearSheet) { sheet.clear(); } if (sheet.getLastRow() > 0 && !clearSheet) { return sheet; } sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold'); sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); sheet.autoResizeColumns(1, headers.length); return sheet; } function ensureDocumentListSheet_() { return resetDocumentListSheet_(false); } function resetFormManagementSheet_(clearSheet) { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('表單管理') || ss.insertSheet('表單管理'); const headers = ['表單名稱', '用途', '填寫連結', '編輯連結', '回應表連結', '開放狀態', '截止時間', '回覆數', '最後檢查時間', '備註']; if (clearSheet) { sheet.clear(); } if (sheet.getLastRow() === 0 || clearSheet) { const config = getProjectConfig_(); sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold'); sheet.getRange(2, 1, 1, headers.length).setValues([[ `${config.projectName}_${getResponseFormTitle_(config)}`, config.formPurpose, '', '', '', '未建立', config.formDeadline || '', 0, '', '表單連結公開前需由承辦人確認;本程式不自動寄信、不自動公開權限。' ]]); sheet.getRange('G2:G').setNumberFormat('yyyy/mm/dd hh:mm'); } sheet.autoResizeColumns(1, headers.length); return sheet; } function resetExpectedRespondentSheet_(clearSheet) { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('應填名單') || ss.insertSheet('應填名單'); const headers = ['姓名', '單位/班級', 'Email', '身分', '是否已填', '填寫時間', '提醒狀態', '備註']; const rows = [ ['王小明', '教務處', 'teacher01@example.com', '教師', '未填', '', '', '範例資料,正式使用前請替換。'], ['李小華', '一年一班', 'student01@example.com', '學生', '未填', '', '', '如含學生資料,建議使用代碼或去識別化。'], ['陳主任', '學務處', 'staff01@example.com', '行政', '未填', '', '', '提醒草稿只寄給自己,不直接寄給未填者。'] ]; if (clearSheet) { sheet.clear(); } if (sheet.getLastRow() === 0 || clearSheet) { sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold'); sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); sheet.getRange('E2:E').setDataValidation( SpreadsheetApp.newDataValidation() .requireValueInList(['未填', '已填'], true) .build() ); sheet.getRange('F2:F').setNumberFormat('yyyy/mm/dd hh:mm'); } sheet.autoResizeColumns(1, headers.length); return sheet; } function resetClosureDatabaseSheet_(clearSheet) { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('結案資料庫') || ss.insertSheet('結案資料庫'); if (clearSheet) { sheet.clear(); } if (sheet.getLastRow() === 0 || clearSheet) { const headers = ['產生時間', '專案名稱', '專案類型', '任務總數', '已完成數', '逾期數', '待確認數', '含風險提醒數', '專案資料夾', '備註']; sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold'); } return sheet; } function getProjectConfig_() { const sheet = getConfigSheet(); return { projectName: sheet.getRange('B2').getValue() || '行政專案', projectDate: normalizeDate_(sheet.getRange('B3').getValue()), unit: sheet.getRange('B4').getValue() || '承辦單位', folderUrl: sheet.getRange('B5').getValue() || '', projectType: sheet.getRange('B6').getValue() || ADVANCED_DEFAULT_PROJECT_TYPE, rootFolderId: sheet.getRange('B7').getValue() || '', formPurpose: sheet.getRange('B8').getValue() || '研習報名', formDeadline: sheet.getRange('B9').getValue() || '', expectedResponses: Number(sheet.getRange('B10').getValue() || 0) }; } function getTaskRowsByProjectType_(projectType) { const due = baseDueDates_(); const templates = { '教師研習': [ ['確認研習主題與講師', '教學組', due[0], '待確認', '研習企畫', 7, '需確認講師時間與鐘點費', '', ''], ['建立報名表與研習代碼', '教學組', due[1], '未開始', '報名表', 5, '報名欄位勿蒐集非必要個資', '', ''], ['寄送研習通知草稿', '承辦人', due[2], '未開始', '通知草稿', 3, '寄出前需主管確認', '', ''], ['彙整簽到與時數資料', '承辦人', due[3], '未開始', '簽到表', 1, '需核對參與名冊', '', ''], ['完成研習成果與結案', '承辦人', due[4], '未開始', '結案報告', 3, '需補照片與回饋摘要', '', ''] ], '活動報名': [ ['確認活動資訊與報名欄位', '承辦人', due[0], '待確認', '活動資訊摘要', 7, '表單公開前需確認個資蒐集範圍', '', ''], ['建立報名/回覆表單', '承辦人', due[1], '未開始', 'Google Form', 5, '表單連結公開前需人工確認', '', ''], ['建立應填或邀請名單', '承辦人', due[2], '未開始', '應填名單', 3, '如含學生資料建議使用代碼', '', ''], ['檢查報名狀況並產生提醒草稿', '承辦人', due[3], '未開始', '未填提醒草稿', 1, '只建立草稿,不自動寄出', '', ''], ['產生報名狀況摘要', '承辦人', due[4], '未開始', '報名狀況摘要', 1, '對外提供前需人工確認', '', ''] ], '成果收件': [ ['確認成果收件格式與期限', '承辦人', due[0], '待確認', '收件規格', 7, '需確認檔案格式與授權', '', ''], ['建立成果回報表', '承辦人', due[1], '未開始', '成果回報表', 5, '避免蒐集非必要個資', '', ''], ['建立應繳成果名單', '承辦人', due[2], '未開始', '應填名單', 3, '可用代碼或組別取代姓名', '', ''], ['檢查成果繳交狀況', '承辦人', due[3], '未開始', '填報狀況', 1, '提醒草稿需人工確認', '', ''], ['彙整成果摘要', '承辦人', due[4], '未開始', '成果摘要', 1, '公開前需確認授權與個資', '', ''] ], '公文填報': [ ['確認公文填報欄位與期限', '承辦人', due[0], '待確認', '填報規格', 3, '需確認主管機關要求', '', ''], ['建立填報回覆表', '承辦人', due[1], '未開始', '填報回覆表', 2, '表單連結公開前需確認', '', ''], ['建立應填單位名單', '承辦人', due[2], '未開始', '應填名單', 2, '需確認權責單位', '', ''], ['檢查填報狀況與產生提醒草稿', '承辦人', due[3], '未開始', '未填提醒草稿', 1, '不自動寄出正式 Email', '', ''], ['產生填報狀況摘要', '承辦人', due[4], '未開始', '填報摘要', 1, '送出前需人工核對', '', ''] ], '校外教學': [ ['確認地點與課程目標', '學年主任', due[0], '待確認', '活動企畫', 10, '需確認安全與學習目標', '', ''], ['彙整家長同意書', '導師群', due[1], '未開始', '同意書清單', 7, '含學生個資,注意權限', '', ''], ['確認交通與保險', '總務處', due[2], '未開始', '交通與保險資料', 5, '需核對名冊與保險資料', '', ''], ['建立行前通知草稿', '承辦人', due[3], '未開始', '通知草稿', 3, '需確認集合時間與攜帶物品', '', ''], ['彙整活動成果', '承辦人', due[4], '未開始', '成果資料夾', 2, '照片公開需注意授權', '', ''] ], '校慶活動': [ ['確認活動流程與場地', '學務處', due[0], '待確認', '流程表', 10, '需跨處室協調', '', ''], ['彙整班級與社團節目', '訓育組', due[1], '未開始', '節目清單', 7, '注意彩排時間衝突', '', ''], ['建立工作分配表', '承辦人', due[2], '未開始', '工作分配表', 5, '需確認人力與備援', '', ''], ['產生對外公告草稿', '承辦人', due[3], '未開始', '公告草稿', 3, '公告前需主管確認', '', ''], ['完成成果與檢討紀錄', '承辦人', due[4], '未開始', '檢討紀錄', 3, '需保存可交接資料', '', ''] ], '競賽報名': [ ['確認競賽規程與資格', '教學組', due[0], '待確認', '競賽規程摘要', 7, '需確認報名資格與期限', '', ''], ['彙整參賽名單', '指導教師', due[1], '未開始', '參賽名單', 5, '含學生個資,注意權限', '', ''], ['準備報名文件', '承辦人', due[2], '未開始', '報名文件', 3, '需核對附件格式', '', ''], ['送出報名並保存證明', '承辦人', due[3], '未開始', '報名證明', 1, '需截圖或下載確認信', '', ''], ['追蹤賽程與成果', '指導教師', due[4], '未開始', '成果紀錄', 3, '得獎公告前需確認姓名公開', '', ''] ], '公文追蹤': [ ['讀取公文主旨與期限', '承辦人', due[0], '待確認', '公文摘要', 3, '需確認法定期限', '', ''], ['拆解需回覆事項', '承辦人', due[1], '未開始', '待辦清單', 2, '需釐清權責單位', '', ''], ['彙整附件與佐證資料', '相關處室', due[2], '未開始', '附件清單', 2, '可能含敏感資料', '', ''], ['產生回覆草稿', '承辦人', due[3], '未開始', '回覆草稿', 1, '正式回覆需主管核定', '', ''], ['封存辦理紀錄', '承辦人', due[4], '未開始', '結案紀錄', 1, '需保留可稽核紀錄', '', ''] ] }; return templates[projectType] || templates[ADVANCED_DEFAULT_PROJECT_TYPE]; } function getBasicTaskRows_() { const due = baseDueDates_(); return [ ['確認專案需求與對象', '承辦人', due[0], '待確認', '專案摘要', 3, '需確認主管期待', '', ''], ['整理參與名冊', '承辦人', due[1], '未開始', '名冊 Sheet', 3, '含個資,注意權限', '', ''], ['產生通知草稿', '承辦人', due[2], '未開始', '通知 Docs/Gmail 草稿', 1, '寄送前需人工確認', '', ''], ['彙整成果資料', '承辦人', due[3], '未開始', '成果資料夾', 0, '活動後容易漏', '', ''], ['完成結案紀錄', '承辦人', due[4], '未開始', '結案報告草稿', 3, '需補上實際成果', '', ''] ]; } function baseDueDates_() { const configDate = getProjectConfig_().projectDate; return [-14, -10, -7, -3, 3].map(offset => { const date = new Date(configDate); date.setDate(date.getDate() + offset); return date; }); } function getProjectTypes_() { return ['教師研習', '活動報名', '成果收件', '公文填報', '校外教學', '校慶活動', '競賽報名', '公文追蹤']; } function getFormPurposes_() { return ['研習報名', '活動報名', '成果回報', '填報回覆']; } function getSubFolderNames_() { return [ '01_原始資料', '02_工作文件', '03_名冊表單', '04_通知公告', '05_會議與協調', '06_成果結案', '99_封存與舊版' ]; } function createProjectRootFolder_(folderName, rootFolderId) { if (!rootFolderId) { return DriveApp.createFolder(folderName); } const parentFolder = DriveApp.getFolderById(rootFolderId); return getOrCreateChildFolder_(parentFolder, folderName); } function getOrCreateChildFolder_(parentFolder, folderName) { const folders = parentFolder.getFoldersByName(folderName); if (folders.hasNext()) { return folders.next(); } return parentFolder.createFolder(folderName); } function getResponseFormTitle_(config) { if (config.projectType === '教師研習' || config.formPurpose === '研習報名') { return '研習報名表'; } if (config.projectType === '活動報名' || config.formPurpose === '活動報名') { return '活動報名表'; } if (config.projectType === '成果收件' || config.formPurpose === '成果回報') { return '成果回報表'; } if (config.projectType === '公文填報' || config.formPurpose === '填報回覆') { return '填報回覆表'; } return `${config.formPurpose || '報名/回覆'}表單`; } function getFormManagementRow_(sheet) { const values = sheet.getDataRange().getValues(); if (values.length < 2) { return null; } return { rowIndex: 2, values: values[1] }; } function writeFormRecord_(sheet, rowIndex, record) { sheet.getRange(rowIndex, 1, 1, 10).setValues([[ record.formName || '', record.purpose || '', record.publishedUrl || '', record.editUrl || '', record.responseUrl || '', record.status || '', record.deadline || '', record.responseCount || 0, record.checkedAt || '', record.note || '' ]]); sheet.autoResizeColumns(1, 10); } function getResponseSpreadsheetFromForm_(form) { const destinationId = form.getDestinationId(); if (!destinationId) { return null; } return SpreadsheetApp.openById(destinationId); } function getResponseSpreadsheetFromRecord_(formRow) { if (!formRow) { return null; } if (formRow.values[3]) { try { const form = FormApp.openByUrl(formRow.values[3]); const responseSpreadsheet = getResponseSpreadsheetFromForm_(form); if (responseSpreadsheet) { return responseSpreadsheet; } } catch (error) { // Fall through to response URL parsing. } } const responseId = extractDriveId_(formRow.values[4]); return responseId ? SpreadsheetApp.openById(responseId) : null; } function buildResponseMap_(responseValues) { const map = {}; if (responseValues.length < 2) { return map; } const headers = responseValues[0].map(header => String(header || '').trim()); const timestampIndex = findHeaderIndex_(headers, ['時間戳記', 'Timestamp']); const nameIndex = findHeaderIndex_(headers, ['姓名']); const emailIndex = findHeaderIndex_(headers, ['Email', '電子郵件地址']); responseValues.slice(1).forEach(row => { const timestamp = timestampIndex >= 0 ? row[timestampIndex] : ''; const name = nameIndex >= 0 ? String(row[nameIndex] || '').trim() : ''; const email = emailIndex >= 0 ? String(row[emailIndex] || '').trim().toLowerCase() : ''; const record = { timestamp }; if (email) { map[email] = record; } if (name) { map[name] = record; } }); return map; } function findHeaderIndex_(headers, candidates) { return headers.findIndex(header => candidates.some(candidate => header.indexOf(candidate) !== -1)); } function moveFileToProjectFolder_(fileId, folderName) { const config = getProjectConfig_(); const projectFolder = getProjectFolderFromConfig_(config); if (!projectFolder) { return; } const targetFolder = getOrCreateChildFolder_(projectFolder, folderName); const file = DriveApp.getFileById(fileId); targetFolder.addFile(file); DriveApp.getRootFolder().removeFile(file); } function appendNote_(original, addition) { const existing = String(original || '').trim(); return existing ? `${existing}\n${addition}` : addition; } function getProjectFolderFromConfig_(config) { const folderId = extractDriveId_(config.folderUrl); if (!folderId) { return null; } return DriveApp.getFolderById(folderId); } function extractDriveId_(urlOrId) { const text = String(urlOrId || '').trim(); if (!text || text === ADVANCED_FOLDER_PLACEHOLDER) { return ''; } 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 writeDraftDocument_(doc, config, documentName, documentType) { const body = doc.getBody(); body.clear(); body.appendParagraph(documentName).setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(`專案名稱:${config.projectName}`); body.appendParagraph(`專案類型:${config.projectType}`); body.appendParagraph(`承辦單位:${config.unit}`); body.appendParagraph(`文件類型:${documentType}`); body.appendParagraph(''); body.appendParagraph('待補內容').setHeading(DocumentApp.ParagraphHeading.HEADING2); body.appendParagraph('請由承辦人補上正式內容,並確認個資、權限、公告文字與簽核流程。'); doc.saveAndClose(); } function createOverdueDraft_(overdueTasks, checkedAt) { const config = getProjectConfig_(); const body = [ `檢查時間:${checkedAt}`, `專案名稱:${config.projectName}`, `專案類型:${config.projectType}`, '', overdueTasks.length ? '逾期任務:' : '本次未發現逾期任務。', ...overdueTasks.map(task => `- ${task[0]}|負責人:${task[1]}|期限:${task[2]}|原狀態:${task[3]}`), '', '本信為 GAS 產生的草稿,未自動寄出。請人工確認後再處理。' ].join('\n'); GmailApp.createDraft( Session.getActiveUser().getEmail(), `[逾期檢查草稿] ${config.projectName}`, body ); } function buildAiClosurePrompt_() { const config = getProjectConfig_(); const taskSheet = SpreadsheetApp.getActive().getSheetByName('任務追蹤'); const rows = taskSheet ? taskSheet.getDataRange().getValues().slice(1).filter(row => row[0]) : []; const taskSummary = rows.map(row => { return `- 任務:${row[0]}|狀態:${row[3] || '未填'}|期限:${row[2] instanceof Date ? formatAdvancedDate_(row[2]) : '未填'}|輸出:${row[4] || '未填'}|風險:${row[6] || '無'}`; }).join('\n'); return [ '請你擔任學校行政專案結案助理。', '重要限制:以下資料若包含學生、家長、教職員個資或敏感資料,使用者必須先人工刪除或改寫後,才能貼到外部 AI。', '', `專案名稱:${config.projectName}`, `專案類型:${config.projectType}`, `承辦單位:${config.unit}`, `專案日期:${formatAdvancedDate_(config.projectDate)}`, `專案資料夾:${config.folderUrl || '未建立'}`, '', '任務摘要:', taskSummary || '尚無任務資料。', '', '請協助產生:', '1. 專案背景與目的', '2. 執行時程摘要', '3. 主要工作項目', '4. 已完成成果', '5. 未完成或待追蹤事項', '6. 風險與改善建議', '7. 下次承辦可直接沿用的工作流', '8. 需要人工確認或不宜由 AI 判斷的事項', '', '請用正式但簡潔的行政文件語氣撰寫,並把不確定內容標示為「待確認」。' ].join('\n'); } function confirmAdvancedAction_(message) { const ui = SpreadsheetApp.getUi(); const result = ui.alert('行政工作流提醒', message, ui.ButtonSet.OK_CANCEL); return result === ui.Button.OK; } function normalizeDate_(value) { if (value instanceof Date && !isNaN(value.getTime())) { return value; } return new Date(); } function startOfDay_(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function formatAdvancedDate_(date) { return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd'); } function formatAdvancedDateTime_(date) { if (!(date instanceof Date) || isNaN(date.getTime())) { return ''; } return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm'); }