/** * 第二堂課進階版:活動專案管理 Sheet 範本綁定程式碼 * * 正式產品定位: * 這份程式碼必須綁定在「活動專案管理 Sheet 範本」中使用。 * 學員複製已綁定 Apps Script 的範本 Sheet 後,直接從自訂選單啟動平台。 * * 這份程式碼不是用來建立另一份空白 Sheet,也不是單獨貼到獨立 Apps Script * 專案後再產生新平台。主流程一律使用目前開啟、已綁定腳本的 Spreadsheet。 * * 系統協助學校把公文或活動需求轉成可追蹤、可整理、可交接、可產生成果 * 的活動型行政專案;工具用來整理初稿與提示缺漏,Apps Script 用來展開行政流程。 * 正式公告、經費核銷、公文回覆、成果送出、個資處理、交通安全與校內程序, * 仍須由承辦人或權責單位確認。 * * 正式判斷、對外公告與成果送出仍由主任、承辦人或權責單位負責。 * * 主要流程: * 收到公文或活動需求 → 填寫公文專案啟動表/活動建案表 → * 建立 Drive 專案資料夾與標準子資料夾 → 產生三個核心文件 → * 建立 Calendar 期限提醒 → Gmail 通知承辦人 → * NotebookLM 建立專案知識庫 → Gemini 協助草稿 → 人工確認正式內容。 * * 學員主要流程是複製這份已綁定腳本的 Sheet 範本,授權後直接從同一份 * Sheet 執行「一鍵建立我的平台資料夾」,讓目前 Sheet 成為活動專案管理中樞。 * * 正式流程: * 1. 學員複製已綁定 Apps Script 的活動專案管理 Sheet 範本。 * 2. 開啟副本並授權。 * 3. 點選「活動專案管理」選單。 * 4. 點選「一鍵建立我的平台資料夾」。 * 5. 建立活動建案表單、收多筆活動、批次產包與追蹤。 * * 注意: * Script 建立的新 Docs / Sheets 是活動資料包產物,不需要自訂選單。 */ const CONFIG = { ROOT_FOLDER_ID: '', CALENDAR_ID: '', TIMEZONE: 'Asia/Taipei', AI_MODE: 'TEMPLATE_WITH_PROMPTS', MODEL_NAME: 'gemini-2.5-flash' }; const AI_MODES = { TEMPLATE_WITH_PROMPTS: 'TEMPLATE_WITH_PROMPTS', AI_MODE: 'AI_MODE' }; const PLATFORM_NAME = '活動型行政專案管理平台'; const INTAKE_FORM_TITLE = '公文專案啟動表/活動建案表'; const BATCH_LIMIT = 10; const HUMAN_CONFIRMATION_NOTICE = '本系統僅協助建檔、提醒、整理與產出草稿。正式公告、經費核銷、公文回覆、成果送出、交通安全、個資處理與校內程序仍須由承辦人或權責單位確認。'; const CALENDAR_FAILURE_NOTICE = 'Calendar 提醒建立失敗,請人工確認或補建。'; const GMAIL_FAILURE_NOTICE = 'Gmail 通知未寄出,請人工通知承辦人。'; const PLATFORM_SHEETS = [ '平台首頁', '活動清單', '活動資料', '文件清單', 'WBS任務追蹤', '通知管理', '風險檢查', 'NotebookLM匯出資料', '家長聯絡資料', '家長通知草稿', '寄送名單', '寄送紀錄', '家長回覆摘要', '系統設定', '操作說明' ]; const PLATFORM_SUBFOLDER_NAMES = { GUIDE: '00_平台說明', HUB: '01_AI中樞', PACKAGES: '02_活動專案資料包', NOTEBOOK: '03_NotebookLM匯出', OUTCOMES: '04_成果整理', SAMPLES: '99_範例資料' }; const ACTIVITY_LIST_HEADERS = [ '活動ID', '活動/專案名稱', '專案類型', '公文文號', '公文來源', '活動日期', '截止日期', '重要期限', '活動地點', '對象年級/班級', '預估人數', '承辦處室', '承辦人', '承辦人 Email', '建立狀態', '專案資料夾連結', '專案記錄連結', '待辦追蹤表連結', '成果檢核表連結', '標準資料夾狀態', 'Calendar 狀態', 'Gmail 狀態', 'NotebookLM資料包連結', '家長聯絡表單連結', '家長通知草稿連結', '寄送名單狀態', '最近寄送時間', '家長回覆摘要連結', '活動建案摘要連結', 'WBS任務清單連結', '家長通知單連結', '導師行前提醒連結', '風險檢查表連結', '通知管理表連結', '承辦人交接摘要連結', '建立時間', '最後更新時間', '備註' ]; const ACTIVITY_STATUSES = ['草稿', '已建案', '文件已產生', '進行中', '已完成', '已封存']; const ACTIVITY_FIELDS = [ ['專案類型', '校外教學'], ['活動/專案名稱', '五年級校外教學(示範案例,可改成實際專案名稱)'], ['公文文號', ''], ['公文來源', ''], ['公文連結', ''], ['附件連結', ''], ['承辦處室', '學務處'], ['承辦人', ''], ['承辦人 Email', ''], ['協辦人', ''], ['活動日期', '2026/07/10'], ['截止日期', '2026/07/24'], ['重要期限', '同意書回收、保險名冊確認、成果資料回收、核銷或結案'], ['對象年級/班級', '五年級學生'], ['預估人數', '120'], ['活動地點', '國立自然科學博物館'], ['經費來源', '待確認'], ['是否需要表單回覆', '是'], ['是否需要成果報告', '是'], ['是否需要核銷', '是'], ['是否需要 NotebookLM 資料包', '是'], ['交通方式', '遊覽車'], ['是否收費', '是'], ['是否需要保險', '是'], ['是否需要家長同意書', '是'], ['備註', '交通安全、家長回覆、經費核銷'], ['目前最擔心的問題', '交通安全、家長回覆、經費核銷'] ]; const FORM_FIELDS = [ '專案類型', '活動/專案名稱', '公文文號', '公文來源', '公文連結', '附件連結', '承辦處室', '承辦人', '承辦人 Email', '協辦人', '活動日期', '截止日期', '重要期限', '對象年級/班級', '預估人數', '活動地點', '經費來源', '是否需要表單回覆', '是否需要成果報告', '是否需要核銷', '是否需要 NotebookLM 資料包', '交通方式', '是否收費', '是否需要保險', '是否需要家長同意書', '備註', '目前最擔心的問題' ]; const RESPONSE_OUTPUT_HEADERS = [ '活動ID', '處理狀態', '資料包資料夾連結', '專案記錄連結', '待辦追蹤表連結', '成果檢核表連結', '活動建案摘要連結', 'WBS任務清單連結', '家長通知單連結', '導師行前提醒連結', '風險檢查表連結', '通知管理表連結', 'NotebookLM資料包連結', '承辦人交接摘要連結', 'Calendar 狀態', 'Gmail 狀態', '產生時間', '錯誤訊息' ]; const FILE_LIST_HEADERS = ['專案 ID', '專案名稱', '文件類型', '文件名稱', '文件網址', '建立時間', '備註']; const WBS_HEADERS = ['活動ID', '階段', '工作包名稱', '具體任務', '負責角色', '建議完成時間', '需要文件或表單', '是否需要提醒', '狀態', '備註']; const NOTIFICATION_HEADERS = ['活動ID', '通知對象', '通知內容', '建議時間', '通知管道', '是否需回覆', '負責人', '狀態', '備註']; const RISK_HEADERS = ['活動ID', '風險類別', '風險內容', '可能造成的問題', '預防方式', '建議新增欄位', '是否需要提醒', '負責人', '狀態', '備註']; const NOTEBOOK_HEADERS = ['活動ID', '區塊', '內容', '是否可匯入 NotebookLM', '備註']; const PARENT_CONTACT_HEADERS = ['活動ID', '活動/專案名稱', '學生姓名', '班級', '座號', '家長姓名', '家長 Email', '家長電話', '是否同意 Email 通知', '是否參加', '特殊需求', '家長備註', '是否含個資', '是否可提供 Gemini', '是否可提供 NotebookLM', '最後更新時間']; const PARENT_NOTICE_DRAFT_HEADERS = ['活動ID', '活動/專案名稱', '通知類型', 'Email 主旨', 'Email 內容', 'LINE 簡短版', '紙本通知版', 'AI 模式', '是否由 AI 產生', '承辦人確認狀態', '確認人', '確認時間', '錯誤訊息', '最後更新時間']; const PARENT_MAILING_LIST_HEADERS = ['活動ID', '活動/專案名稱', '通知類型', '學生姓名', '班級', '座號', '家長 Email', '是否寄送', '寄送狀態', '寄送時間', '錯誤訊息', '備註']; const PARENT_EMAIL_LOG_HEADERS = ['寄送時間', '活動ID', '活動/專案名稱', '通知類型', '收件人 Email', '學生姓名', 'Email 主旨', '寄送結果', '錯誤訊息', '寄送者', '備註']; const PARENT_RESPONSE_SUMMARY_HEADERS = ['活動ID', '活動/專案名稱', '摘要時間', '回覆總數', '參加人數', '不參加人數', '未回覆人數', '特殊需求摘要', '需要導師確認', '需要承辦人確認', 'AI 摘要內容', '人工確認狀態', '備註']; const PROJECT_SUBFOLDER_NAMES = [ '00_原始公文與附件', '01_計畫書與核定資料', '02_工作分工與會議紀錄', '03_表單與回覆資料', '04_通知與聯繫資料', '05_經費與採購核銷資料', '06_活動照片與照片說明', '07_成果資料與成果報告', '08_簡報與成果展示', '09_檢討與下次改進', '99_系統產生文件' ]; const TASK_STATUS_VALUES = ['未開始', '進行中', '待確認', '已完成', '暫緩']; const TASK_PRIORITY_VALUES = ['高', '中', '低']; const OUTCOME_STATUS_VALUES = ['未完成', '進行中', '待確認', '已完成', '不適用']; const YES_NO_REVIEW_VALUES = ['是', '否', '待確認', '不適用']; const PARENT_NOTICE_TYPES = ['初次通知', '活動前提醒', '未回覆提醒', '活動異動通知', '成果回饋通知']; const PARENT_NOTICE_CONFIRMATION_VALUES = ['草稿', '待確認', '已確認', '退回修改', '不寄送']; const PARENT_SEND_VALUES = ['是', '否', '待確認']; const PARENT_SEND_STATUS_VALUES = ['待寄送', '已寄送', '失敗', '略過']; const PARENT_PARTICIPATION_VALUES = ['參加', '不參加', '需再確認', '未回覆']; const PARENT_EMAIL_CONSENT_VALUES = ['同意', '不同意', '待確認']; const GEMINI_DRAFT_TYPES = [ '公文重點摘要', '校內公告', '待辦清單', '表單題目', '會議追蹤表', '成果報告初稿', '成果簡報大綱', '下次辦理建議' ]; const WBS_STAGES = [ '活動規劃', '行政核准', '家長通知', '學生名冊', '交通與安全', '經費與核銷', '活動當天執行', '成果整理與結案' ]; const RISK_CATEGORIES = [ '時程風險', '人員風險', '學生安全風險', '交通風險', '家長溝通風險', '經費核銷風險', '成果結案風險' ]; const PROMPTS = { parentNotice: [ '你是一位熟悉台灣國小行政語氣的學校行政助理。', '', '請根據以下活動資料,產出一份給家長的校外教學或活動通知單草稿。', '', '請注意:', '1. 語氣正式、清楚、親切', '2. 日期、費用、集合時間若資料不足,請用【待確認】標示', '3. 涉及收費、保險、交通、安全者,請提醒承辦人正式確認', '4. 不要自行編造不存在的資料', '5. 請使用繁體中文', '6. 最後加上「承辦人確認事項」', '', '活動資料:', '(貼上活動建案資料)' ].join('\n'), teacherReminder: [ '請根據以下活動資料,產出一份給導師的行前提醒。', '', '請分成:', '1. 活動前一週', '2. 活動前一天', '3. 活動當天集合前', '4. 活動進行中', '5. 返校後', '', '每一階段請列出導師需要提醒學生、檢查名冊、確認安全與回報的事項。', '', '請使用清楚、條列、方便轉貼到校內群組的格式。', '', '活動資料:', '(貼上活動建案資料)' ].join('\n'), riskCheck: [ '你是一位協助學校行政人員進行活動風險盤點的 AI 助理。', '', '請根據以下活動資料,找出可能風險。', '', '請分成:', '1. 時程風險', '2. 人員風險', '3. 學生安全風險', '4. 交通風險', '5. 家長溝通風險', '6. 經費核銷風險', '7. 成果結案風險', '', '每一項請列出:', '- 風險內容', '- 可能造成的問題', '- 建議預防方式', '- 建議新增的表單欄位', '- 是否需要 Calendar 提醒', '', '活動資料:', '(貼上活動建案資料)' ].join('\n'), wbs: [ '你是一位熟悉學校活動行政流程的 AI 專案管理助理。', '', '請根據以下活動資料,協助我建立 WBS 工作拆解表。', '', '請分成:', '1. 活動規劃', '2. 行政核准', '3. 家長通知', '4. 學生名冊', '5. 交通與安全', '6. 經費與核銷', '7. 活動當天執行', '8. 成果整理與結案', '', '每一項請包含:工作包名稱、具體任務、負責角色、建議完成時間、需要文件或表單、是否需要提醒。', '', '活動資料:', '(貼上活動建案資料)' ].join('\n'), handoff: [ '你是一位協助學校行政人員交接活動專案的 AI 助理。', '', '請根據以下活動資料,整理一份承辦人交接摘要。', '', '請包含:活動基本資料、目前已知資訊、尚未確認事項、優先處理順序、需主管確認事項、不能交給 AI 自動決定的內容。', '', '活動資料:', '(貼上活動建案資料)' ].join('\n'), workflowReview: [ '你是一位協助學校行政人員檢查 Google Workspace 工作流的 AI 顧問。', '', '請根據以下表單欄位、資料夾架構、文件清單與提醒節點,檢查這套活動行政工作流是否完整。', '', '請用表格回答:', '1. 已經對應到的專案管理產物', '2. 可能缺漏的欄位、文件、提醒或風險', '3. 建議補強方式', '4. 哪些部分仍需要承辦人或主管正式確認', '', '工作流資料:', '(貼上表單欄位、資料夾架構、文件清單、提醒節點)' ].join('\n') }; /** * 平台 Sheet 開啟時建立操作選單。AI 專用項目只在 AI_MODE 啟用後顯示。 */ function onOpen() { buildActivityAdminMenu_(); } function buildActivityAdminMenu_() { try { const menu = SpreadsheetApp.getUi() .createMenu('活動專案管理') .addItem('建立我的平台資料夾', 'installMyActivityAdminPlatform') .addItem('建立活動建案表', 'createActivityIntakeForm') .addItem('檢查系統設定', 'updatePlatformDashboard') .addItem('檢查 Gemini API Key', 'checkGeminiApiKey') .addSeparator() .addItem('產生示範活動資料包', 'createActivityFromActivityData') .addItem('批次產生行政專案資料包', 'batchGenerateProjectPackagesFromFormResponses') .addItem('重新整理活動清單連結', 'updatePlatformDashboard'); if (isAiModeReady_()) { menu .addSeparator() .addItem('AI 產生專案記錄草稿', 'aiGenerateProjectRecordDraft') .addItem('AI 補齊待辦追蹤表', 'aiFillTaskTrackingSheet') .addItem('AI 產生成果檢核項目', 'aiGenerateOutcomeChecklist') .addItem('AI 產生校內公告草稿', 'aiGenerateAnnouncementDraft') .addItem('AI 產生表單題目', 'aiGenerateFormQuestions') .addItem('AI 產生成果報告初稿', 'aiGenerateOutcomeReportDraft') .addItem('AI 產生 NotebookLM 匯入摘要', 'aiGenerateNotebookLmSummary') .addItem('AI 檢查風險與缺漏', 'aiCheckRisksAndGaps'); } menu .addSeparator() .addItem('建立家長聯絡表單', 'createParentContactForm') .addItem('建立家長通知草稿', 'generateParentNoticeDraft') .addItem('建立活動前提醒草稿', 'generatePreActivityReminderDraft') .addItem('建立未回覆提醒草稿', 'generateNonResponseReminderDraft') .addItem('建立寄送名單', 'buildParentMailingList') .addItem('預覽家長通知信', 'previewParentNoticeEmail') .addItem('寄送測試信給自己', 'sendParentTestEmail') .addItem('承辦人確認後批次寄送', 'sendParentEmailsAfterConfirmation') .addItem('查看寄送紀錄', 'openParentEmailLog'); if (isAiModeReady_()) { menu.addItem('AI 摘要家長回覆與特殊需求', 'summarizeParentResponsesWithAi'); } menu .addSeparator() .addItem('匯出 NotebookLM 資料包', 'exportSelectedActivityNotebookLMPackage'); if (isAiModeReady_()) { menu.addItem('AI 產生 NotebookLM 建議提問', 'generateNotebookLmSuggestedQuestions'); } menu .addSeparator() .addItem('開啟操作說明', 'showAboutWorkflow') .addItem('偵錯:檢查表單回應分頁', 'debugFormResponseSheets') .addItem('偵錯:檢查 AI 模式', 'debugAiMode') .addToUi(); } catch (error) { Logger.log('onOpen 無法建立選單:' + error.message); Logger.log('若是在 Apps Script 編輯器手動執行 onOpen,可忽略此訊息。請開啟已綁定腳本的平台 Sheet 並重新整理。'); } } /** * 學員主要入口:在目前這份已綁定 Apps Script 的 Sheet 中建立平台資料夾。 */ function installMyActivityAdminPlatform() { try { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) { throw new Error('請先開啟已綁定本 Apps Script 的活動專案管理 Sheet。'); } initializePlatformSpreadsheet(); const props = PropertiesService.getScriptProperties(); const existingId = props.getProperty('PLATFORM_ROOT_FOLDER_ID') || getSystemSettingValue_('平台根資料夾 ID'); let platformRoot = null; if (existingId) { try { const existingFolder = DriveApp.getFolderById(existingId); const ui = SpreadsheetApp.getUi(); const answer = ui.alert( '已找到既有平台資料夾', '是否沿用既有平台資料夾?\n\n' + existingFolder.getUrl() + '\n\n選「否」才會重新建立一組平台資料夾。', ui.ButtonSet.YES_NO ); if (answer !== ui.Button.NO) { platformRoot = existingFolder; } } catch (error) { Logger.log('既有平台資料夾無法開啟,將重新建立:' + error.message); } } if (!platformRoot) { const parentFolder = CONFIG.ROOT_FOLDER_ID ? DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID) : DriveApp.getRootFolder(); const timestamp = Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'yyyyMMdd_HHmm'); platformRoot = parentFolder.createFolder('活動型行政專案管理平台_' + timestamp); } const folders = createPlatformSubfolders_(platformRoot); const readmeDoc = createPlatformReadmeDoc_(folders.GUIDE, ss, platformRoot, folders); moveFileToFolder_(ss.getId(), folders.HUB); writePlatformFolderSettings_(ss, platformRoot, folders, readmeDoc); updatePlatformDashboard(); const message = [ '平台資料夾建立完成。', '平台根資料夾:' + platformRoot.getUrl(), '活動專案管理 Sheet:' + ss.getUrl(), 'README:' + readmeDoc.getUrl(), '', '下一步:建立活動建案表單。' ].join('\n'); alert_(message); return { spreadsheetUrl: ss.getUrl(), folderUrl: platformRoot.getUrl(), readmeUrl: readmeDoc.getUrl() }; } catch (error) { handleError_('一鍵建立我的平台資料夾失敗', error); } } /** * 舊版相容函式。 * * 本課程現在採用「已綁定 Apps Script 的 Sheet 範本」模式: * 學員先複製範本 Sheet,再在副本中執行選單。 * 因此這個舊函式不再複製新 Sheet,只會初始化目前這份已綁定腳本的 Sheet。 */ function installActivityProjectManagementPlatform() { alert_('本版本是活動專案管理 Sheet 範本的綁定程式碼。請先複製已綁定 Apps Script 的範本 Sheet,開啟副本後從「活動專案管理」選單執行「一鍵建立我的平台資料夾」。現在將改為初始化目前這份 Sheet。'); return installMyActivityAdminPlatform(); } /** * 初始化目前這份已綁定 Apps Script 的平台 Sheet。 */ function initializePlatformSpreadsheet() { try { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) { throw new Error('請先開啟已綁定本 Apps Script 的平台 Sheet。'); } PLATFORM_SHEETS.forEach(function(name) { getOrCreateSheet_(ss, name); }); initializeHomeSheet_(ss.getSheetByName('平台首頁')); initializeActivityListSheet_(ss.getSheetByName('活動清單')); initializeActivityDataSheet_(ss.getSheetByName('活動資料')); initializeFileListSheet_(ss.getSheetByName('文件清單')); initializeWbsTrackingSheet_(ss.getSheetByName('WBS任務追蹤')); initializeNotificationSheet_(ss.getSheetByName('通知管理')); initializeRiskSheet_(ss.getSheetByName('風險檢查')); initializeNotebookExportSheet_(ss.getSheetByName('NotebookLM匯出資料')); initializeParentContactSheet_(ss.getSheetByName('家長聯絡資料')); initializeParentNoticeDraftSheet_(ss.getSheetByName('家長通知草稿')); initializeParentMailingListSheet_(ss.getSheetByName('寄送名單')); initializeParentEmailLogSheet_(ss.getSheetByName('寄送紀錄')); initializeParentResponseSummarySheet_(ss.getSheetByName('家長回覆摘要')); initializeSystemSettingsSheet_(ss.getSheetByName('系統設定')); createOrUpdateInstructionSheet(ss); updateDashboard(); alert_('目前這份活動專案管理 Sheet 已初始化完成。下一步可建立公文專案啟動表/活動建案表,或使用「活動資料」產生示範行政專案。'); return ss.getUrl(); } catch (error) { handleError_('初始化平台 Sheet 失敗', error); } } /** * 舊名稱相容:主流程請使用 initializePlatformSpreadsheet。 */ function setupCurrentSpreadsheetAsActivityHub() { return initializePlatformSpreadsheet(); } function createActivityFromActivityData() { return generateFullAdminProjectPackage(); } /** * 從「活動資料」建立一筆示範活動並產生資料包。 */ function generateFullAdminProjectPackage() { try { const ss = getPlatformSpreadsheetOrThrow_(); const activity = readActivityDataSheet_(ss); activity.activityId = generateActivityId_(); const result = generatePackageForActivity_(activity, { source: '活動資料', packageType: '示範活動資料包' }); appendActivityListRow_(ss, activity, result); appendFileListRows_(ss, result); appendTrackingRows_(ss, activity, result); updateDashboard(); alert_([ '示範行政專案資料包已產生:', result.packageFolderUrl, '', '請檢查專案主資料夾根目錄中的三個核心文件:', '01_專案記錄 Docs、02_待辦追蹤表 Sheet、03_成果檢核表 Sheet。', result.calendarStatus, result.gmailStatus ].join('\n')); return result; } catch (error) { handleError_('依活動資料產生示範活動失敗', error); } } /** * 建立公文專案啟動表/活動建案表,回應寫入 Google Form 自動產生的回應分頁。 */ function createActivityIntakeForm() { try { const ss = getPlatformSpreadsheetOrThrow_(); const props = PropertiesService.getScriptProperties(); const existingFormId = props.getProperty('INTAKE_FORM_ID'); if (existingFormId) { try { const existingForm = FormApp.openById(existingFormId); existingForm.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); props.setProperty('INTAKE_FORM_PUBLISHED_URL', existingForm.getPublishedUrl()); props.setProperty('INTAKE_FORM_EDIT_URL', existingForm.getEditUrl()); writeFormLinksToHomeAndInstructions_(ss, existingForm); ensureDefaultIntakeTestProjectSafely_(ss); updatePlatformDashboard(); alert_('已沿用既有公文專案啟動表/活動建案表。\n填答連結:' + existingForm.getPublishedUrl() + '\n編輯連結:' + existingForm.getEditUrl()); return { formUrl: existingForm.getPublishedUrl(), editUrl: existingForm.getEditUrl() }; } catch (error) { Logger.log('既有表單無法開啟,將建立新表單:' + error.message); } } const existingSheetIds = ss.getSheets().map(function(sheet) { return sheet.getSheetId(); }); const form = FormApp.create(INTAKE_FORM_TITLE); form.setDescription('請填寫一筆公文或活動建案資料。系統會建立行政專案資料包、標準資料夾、三個核心文件、期限提醒與承辦通知草稿。正式內容仍須承辦人確認。'); form.addListItem().setTitle('專案類型').setRequired(true) .setChoiceValues(['公文啟動專案', '校外教學', '校內活動', '親子活動', '競賽活動', '教師研習', '大型典禮', '成果發表', '其他']); form.addTextItem().setTitle('活動/專案名稱').setRequired(true); form.addTextItem().setTitle('公文文號'); form.addTextItem().setTitle('公文來源'); form.addTextItem().setTitle('公文連結'); form.addTextItem().setTitle('附件連結'); form.addTextItem().setTitle('承辦處室'); form.addTextItem().setTitle('承辦人'); form.addTextItem().setTitle('承辦人 Email'); form.addTextItem().setTitle('協辦人'); form.addDateItem().setTitle('活動日期'); form.addDateItem().setTitle('截止日期'); form.addParagraphTextItem().setTitle('重要期限'); form.addTextItem().setTitle('對象年級/班級'); form.addTextItem().setTitle('預估人數'); form.addTextItem().setTitle('活動地點'); form.addTextItem().setTitle('經費來源'); addYesNoItem_(form, '是否需要表單回覆'); addYesNoItem_(form, '是否需要成果報告'); addYesNoItem_(form, '是否需要核銷'); addYesNoItem_(form, '是否需要 NotebookLM 資料包'); form.addListItem().setTitle('交通方式') .setChoiceValues(['步行', '遊覽車', '大眾運輸', '家長接送', '校內活動無交通', '其他或待確認']); addYesNoItem_(form, '是否收費'); addYesNoItem_(form, '是否需要保險'); addYesNoItem_(form, '是否需要家長同意書'); form.addParagraphTextItem().setTitle('備註'); form.addParagraphTextItem().setTitle('目前最擔心的問題'); form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); Utilities.sleep(1000); const responseSheet = resolveFormResponseSheet_(ss, existingSheetIds); initializeFormResponseSheet_(responseSheet); const platformFolder = getPlatformRootFolder_(); moveFileToFolder_(form.getId(), platformFolder); props.setProperty('INTAKE_FORM_ID', form.getId()); props.setProperty('INTAKE_FORM_PUBLISHED_URL', form.getPublishedUrl()); props.setProperty('INTAKE_FORM_EDIT_URL', form.getEditUrl()); props.setProperty('FORM_RESPONSE_SHEET_NAME', responseSheet.getName()); writeFormLinksToHomeAndInstructions_(ss, form); ensureDefaultIntakeTestProjectSafely_(ss, responseSheet); updateDashboard(); alert_('公文專案啟動表/活動建案表已建立,並已在回應分頁預設加入 1 筆可測試的專案資料。\n填答連結:' + form.getPublishedUrl() + '\n編輯連結:' + form.getEditUrl()); return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() }; } catch (error) { handleError_('建立活動建案表單失敗', error); } } function recreateActivityIntakeForm() { try { const ui = SpreadsheetApp.getUi(); const answer = ui.alert( '重新建立活動建案表單', '這會建立一份新的公文專案啟動表/活動建案表並改用新表單連結;舊表單不會刪除。是否繼續?', ui.ButtonSet.YES_NO ); if (answer !== ui.Button.YES) { alert_('已取消重新建立表單。'); return null; } const props = PropertiesService.getScriptProperties(); props.deleteProperty('INTAKE_FORM_ID'); props.deleteProperty('INTAKE_FORM_PUBLISHED_URL'); props.deleteProperty('INTAKE_FORM_EDIT_URL'); return createActivityIntakeForm(); } catch (error) { handleError_('重新建立活動建案表單失敗', error); } } function ensureDefaultIntakeTestProject_(ss, responseSheet) { const sheet = responseSheet || getFormResponseSheet_(ss); const headerMap = initializeFormResponseSheet_(sheet); const sampleName = '新北GAS專案管理研習'; for (let row = 2; row <= sheet.getLastRow(); row++) { const activityName = readFirstAvailableCell_(sheet, row, headerMap, ['活動/專案名稱', '活動 / 專案名稱', '活動/專案名稱', '活動名稱', '專案名稱']); if (activityName === sampleName) { return false; } } const sample = buildDefaultIntakeTestProject_(); const rowValues = new Array(sheet.getLastColumn()).fill(''); Object.keys(sample).forEach(function(header) { const column = headerMap[header] || headerMap[normalizeHeader_(header)]; if (column) rowValues[column - 1] = sample[header]; }); sheet.getRange(sheet.getLastRow() + 1, 1, 1, rowValues.length).setValues([rowValues]); return true; } function ensureDefaultIntakeTestProjectSafely_(ss, responseSheet) { try { return ensureDefaultIntakeTestProject_(ss, responseSheet); } catch (error) { Logger.log('預設測試專案資料建立失敗:' + error.message); return false; } } function buildDefaultIntakeTestProject_() { const activityDate = new Date(); activityDate.setDate(activityDate.getDate() + 21); const deadlineDate = new Date(); deadlineDate.setDate(deadlineDate.getDate() + 14); return { '時間戳記': nowText_(), '專案類型': '教師研習', '活動/專案名稱': '新北GAS專案管理研習', '公文文號': '新北教研字第TEST001號', '公文來源': '新北市政府教育局', '公文連結': 'https://example.com/document', '附件連結': 'https://example.com/attachment', '承辦處室': '教務處', '承辦人': '彭智標', '承辦人 Email': Session.getActiveUser().getEmail() || '', '協辦人': '資訊組、研習承辦同仁', '活動日期': activityDate, '截止日期': deadlineDate, '重要期限': '報名與名冊確認請於活動前一週完成;成果資料於活動後一週內整理。', '對象年級/班級': '校內教師與行政同仁', '預估人數': '30', '活動地點': '電腦教室', '經費來源': '校內研習經費', '是否需要表單回覆': '是', '是否需要成果報告': '是', '是否需要核銷': '否', '是否需要 NotebookLM 資料包': '是', '交通方式': '校內活動無交通', '是否收費': '否', '是否需要保險': '否', '是否需要家長同意書': '否', '備註': '這是系統預設測試資料,可直接用來測試批次產生行政專案資料包。', '目前最擔心的問題': '研習資料是否完整、講義與成果資料是否能順利整理。' }; } /** * 批次處理真實表單回應分頁中尚未產包的活動,每次最多 10 筆。 */ function batchGenerateProjectPackagesFromFormResponses() { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getFormResponseSheet_(ss); const headerMap = initializeFormResponseSheet_(sheet); const lastRow = sheet.getLastRow(); const dataRows = Math.max(lastRow - 1, 0); let generated = 0; let skipped = 0; let failed = 0; if (dataRows === 0) { alert_( '批次處理完成。' + '\n讀取分頁:' + sheet.getName() + '\n資料列數:0' + '\n已產生:0 筆' + '\n略過:0 筆' + '\n失敗:0 筆' + '\n\n目前已找到表單回應分頁,但沒有可處理的資料列。' + '\n請先送出一筆活動建案表,或在第 2 列手動填入測試資料後再執行。' ); return; } for (let row = 2; row <= lastRow; row++) { if (generated >= BATCH_LIMIT) break; const status = String(readCellByHeader_(sheet, row, headerMap, '處理狀態') || '').trim(); const packageUrl = String(readCellByHeader_(sheet, row, headerMap, '資料包資料夾連結') || '').trim(); if (status === '已產生' || packageUrl) { const reason = status === '已產生' ? '已產生或處理狀態為「已產生」' : '已有資料包資料夾連結'; if (packageUrl && status !== '已產生') { writeCellByHeader_(sheet, row, headerMap, '處理狀態', '已產生'); } writeSkipReason_(sheet, row, headerMap, reason); Logger.log({ row: row, activityName: '', status: status, packageUrl: packageUrl, action: 'skipped', reason: reason }); skipped++; continue; } try { const activity = readActivityFromResponseRow_(sheet, row, headerMap); if (!activity.activityName) { const reason = '活動/專案名稱空白'; writeCellByHeader_(sheet, row, headerMap, '處理狀態', '略過'); writeSkipReason_(sheet, row, headerMap, reason); Logger.log({ row: row, activityName: '', status: status, packageUrl: packageUrl, action: 'skipped', reason: reason }); skipped++; continue; } if (!activity.activityType) { const reason = '必要欄位不足:專案類型或活動類型空白'; writeCellByHeader_(sheet, row, headerMap, '處理狀態', '略過'); writeSkipReason_(sheet, row, headerMap, reason); Logger.log({ row: row, activityName: activity.activityName || '', status: status, packageUrl: packageUrl, action: 'skipped', reason: reason }); skipped++; continue; } activity.activityId = generateActivityId_(); const result = generatePackageForActivity_(activity, { source: '表單回應第 ' + row + ' 列', packageType: '活動資料包' }); writeBackPackageLinks_(sheet, row, headerMap, activity, result); appendActivityListRow_(ss, activity, result); appendFileListRows_(ss, result); appendTrackingRows_(ss, activity, result); Logger.log({ row: row, activityName: activity.activityName || '', status: status, packageUrl: result.packageFolderUrl, action: 'generated' }); generated++; } catch (error) { failed++; writeCellByHeader_(sheet, row, headerMap, '處理狀態', '失敗'); writeCellByHeader_(sheet, row, headerMap, '錯誤訊息', error.message); Logger.log({ row: row, activityName: '', status: status, packageUrl: packageUrl, action: 'failed', reason: error.message }); } } updateDashboard(); let message = '批次處理完成。' + '\n讀取分頁:' + sheet.getName() + '\n資料列數:' + dataRows + '\n已產生:' + generated + ' 筆' + '\n略過:' + skipped + ' 筆' + '\n失敗:' + failed + ' 筆'; if (dataRows > 0 && generated === 0 && skipped === 0 && failed === 0) { message += '\n\n系統找到資料列,但沒有任何資料被處理。請檢查活動/專案名稱、處理狀態與資料包連結欄位。'; } alert_(message); } /** * 匯出目前「活動資料」為 NotebookLM 專案資料包。 */ function exportNotebookLMProjectPackage() { return exportSelectedActivityNotebookLMPackage(); } function exportSelectedActivityNotebookLMPackage() { try { const ss = getPlatformSpreadsheetOrThrow_(); const activity = readActivityDataSheet_(ss); activity.activityId = activity.activityId || generateActivityId_(); const folder = createNotebookExportFolder_(activity); const notebookDoc = createNotebookPackageDoc_(folder, activity, {}); const result = { activityId: activity.activityId, activityName: activity.activityName, packageFolderUrl: folder.getUrl(), notebookPackageUrl: notebookDoc.getUrl(), source: '活動資料', generatedAt: nowText_() }; appendFileListRows_(ss, result); appendNotebookRows_(ss, activity, result); updateDashboard(); alert_('NotebookLM 專案資料包已匯出:\n' + notebookDoc.getUrl()); return notebookDoc.getUrl(); } catch (error) { handleError_('匯出 NotebookLM 專案資料包失敗', error); } } function aiGenerateProjectRecordDraft() { runAiMenuAction_('AI 專案記錄草稿', function(context) { const result = generateProjectRecordDraftWithAi_(context.activity, context); alert_('AI 專案記錄草稿已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n請開啟專案記錄 Docs 檢查內容。'); return result; }); } function aiFillTaskTrackingSheet() { runAiMenuAction_('AI 待辦追蹤建議', function(context) { const result = fillTaskTrackingSheetWithAi_(context.activity, context); alert_('AI 待辦追蹤建議已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n請開啟待辦追蹤表 Sheet 檢查 AI_待辦建議 分頁。'); return result; }); } function aiGenerateOutcomeChecklist() { runAiMenuAction_('AI 成果檢核建議', function(context) { const result = generateOutcomeChecklistWithAi_(context.activity, context); alert_('AI 成果檢核建議已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n請開啟成果檢核表 Sheet 檢查 AI_成果檢核建議 分頁。'); return result; }); } function aiGenerateAnnouncementDraft() { runAiMenuAction_('AI 校內公告草稿', function(context) { const result = generateAnnouncementDraftWithAi_(context.activity, context); alert_('AI 校內公告草稿已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n文件:' + result); return result; }); } function aiGenerateFormQuestions() { runAiMenuAction_('AI 表單題目建議', function(context) { const result = generateFormQuestionsWithAi_(context.activity, context); alert_('AI 表單題目建議已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n文件:' + result); return result; }); } function aiGenerateOutcomeReportDraft() { runAiMenuAction_('AI 成果報告初稿', function(context) { const result = generateOutcomeReportDraftWithAi_(context.activity, context); alert_('AI 成果報告初稿已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n文件:' + result); return result; }); } function aiGenerateNotebookLmSummary() { runAiMenuAction_('AI NotebookLM 匯入摘要', function(context) { const result = generateNotebookLmSummaryWithAi_(context.activity, context); alert_('AI NotebookLM 匯入摘要已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n文件:' + result); return result; }); } function aiCheckRisksAndGaps() { runAiMenuAction_('AI 風險與缺漏檢查', function(context) { const result = checkRisksAndGapsWithAi_(context.activity, context); alert_('AI 風險與缺漏檢查已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n請檢查風險表或文件。'); return result; }); } function generateNotebookLmSuggestedQuestions() { try { const context = getSelectedActivityContext_(); if (!context || !context.activity) { alert_('請先在活動清單或表單回應分頁選取一筆活動資料。'); return; } requireProjectPackageContext_(context); const title = 'NotebookLM 建議提問'; const prompt = [ '請根據以下活動資料,產生 12 個適合 NotebookLM 使用的提問。', '請分成:專案理解、待辦追蹤、風險檢查、成果報告、下次改進。', '', activityToText_(context.activity) ].join('\n'); const text = shouldUseGeminiApi_() ? callGeminiWithPromptFallback_(prompt) : buildTemplateFallback_(prompt, context.activity); const url = createAiDocInProjectSubfolder_(context, '99_系統產生文件', 'AI_NotebookLM建議提問 - ' + context.activity.activityName, title, text); alert_('NotebookLM 建議提問已產生。' + '\n活動/專案名稱:' + context.activity.activityName + '\n文件:' + url); return url; } catch (error) { handleError_('產生 NotebookLM 建議提問失敗', error); } } function runAiMenuAction_(label, callback) { if (!requireAiMode_()) return; try { const context = getSelectedActivityContext_(); if (!context || !context.activity) { alert_('請先在活動清單或表單回應分頁選取一筆活動資料。'); return; } requireProjectPackageContext_(context); return callback(context); } catch (error) { alert_(label + '時發生錯誤:' + error.message); Logger.log(label + ' 失敗:' + error.message); } } function generateProjectRecordDraftWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('專案記錄草稿', activity, context, [ '請產生可放入專案記錄 Docs 的草稿。', '請包含:專案摘要、行政依據、重要日期、責任分工、需追蹤事項、成果資料需求、人工確認事項。' ]); const text = callGeminiWithPromptFallback_(prompt); return appendAiSectionToDocByUrl_(context.projectRecordDocUrl, 'AI 產生之專案記錄草稿', text); } function fillTaskTrackingSheetWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('待辦追蹤表補齊建議', activity, context, [ '請補齊待辦追蹤建議,分成待辦總表、WBS 工作分解、里程碑、責任分工。', '請用條列或表格文字表示,避免宣稱已完成正式行政程序。' ]); const text = callGeminiWithPromptFallback_(prompt); return appendAiTextSheetByUrl_(context.taskTrackingSheetUrl, 'AI_待辦建議', 'AI 待辦建議區', text); } function generateOutcomeChecklistWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('成果檢核項目建議', activity, context, [ '請補齊成果檢核建議,分成成果檢核總表、成果素材清單、NotebookLM 知識庫資料、Gemini 草稿任務。', '請特別標示哪些資料不可含個資、哪些需要承辦人確認。' ]); const text = callGeminiWithPromptFallback_(prompt); return appendAiTextSheetByUrl_(context.outcomeChecklistSheetUrl, 'AI_成果檢核建議', 'AI 成果檢核建議區', text); } function generateAnnouncementDraftWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('校內公告草稿', activity, context, [ '請產生校內公告草稿。', '請包含:公告主旨、活動資訊、承辦處室、重要期限、配合事項、提醒與人工確認事項。' ]); const text = callGeminiWithPromptFallback_(prompt); return createAiDocInProjectSubfolder_(context, '99_系統產生文件', 'AI_校內公告草稿 - ' + activity.activityName, 'AI 校內公告草稿', text); } function generateFormQuestionsWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('表單題目建議', activity, context, [ '請依活動資料產生 Google Form 題目建議。', '請分成基本資料、參與意願、同意事項、交通或保險資訊、回覆確認、備註。', '請標示涉及個資或敏感資料的題目需要校內規範確認。' ]); const text = callGeminiWithPromptFallback_(prompt); return createAiDocInProjectSubfolder_(context, '03_表單與回覆資料', 'AI_表單題目建議 - ' + activity.activityName, 'AI 表單題目建議', text); } function generateOutcomeReportDraftWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('成果報告初稿', activity, context, [ '請產生成果報告初稿架構與可填寫段落。', '請包含:活動目的、辦理過程、參與情形、成果摘要、照片說明建議、檢討與改進、附件清單。', '請標示仍需承辦人補資料與確認的段落。' ]); const text = callGeminiWithPromptFallback_(prompt); return createAiDocInProjectSubfolder_(context, '07_成果資料與成果報告', 'AI_成果報告初稿 - ' + activity.activityName, 'AI 成果報告初稿', text); } function generateNotebookLmSummaryWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('NotebookLM 匯入摘要', activity, context, [ '請產生 NotebookLM 匯入摘要。', '請包含:專案摘要、建議匯入資料、可詢問 NotebookLM 的問題、成果報告可用素材、需要人工確認事項。' ]); const text = callGeminiWithPromptFallback_(prompt); return createAiDocInProjectSubfolder_(context, '99_系統產生文件', 'AI_NotebookLM匯入摘要 - ' + activity.activityName, 'AI NotebookLM 匯入摘要', text); } function checkRisksAndGapsWithAi_(activity, context) { const prompt = buildAiMenuPrompt_('風險與缺漏檢查', activity, context, [ '請依活動資料、待辦追蹤表與成果檢核表,產生風險與缺漏檢查建議。', '請分成行政程序、資料完整性、期限提醒、交通安全、個資、經費核銷、成果資料、交接風險。' ]); const text = callGeminiWithPromptFallback_(prompt); if (context.riskSheetUrl) { return appendAiTextSheetByUrl_(context.riskSheetUrl, 'AI_風險與缺漏建議', 'AI 風險與缺漏建議區', text); } return createAiDocInProjectSubfolder_(context, '99_系統產生文件', 'AI_風險與缺漏檢查 - ' + activity.activityName, 'AI 風險與缺漏檢查', text); } function createParentContactForm() { try { const context = getParentModuleContext_(); if (!context) return; const activity = context.activity; const ss = getPlatformSpreadsheetOrThrow_(); initializeParentContactSheet_(getOrCreateSheet_(ss, '家長聯絡資料')); const form = FormApp.create('家長聯絡表單 - ' + projectDisplayName_(activity)); form.setDescription([ '活動名稱:' + valueOrPending_(activity.activityName), '活動日期:' + valueOrPending_(activity.activityDate), '承辦單位/承辦人:' + valueOrPending_(activity.department || activity.owner), '填寫期限:' + valueOrPending_(activity.deadlineDate), '', '個資使用提醒:聯絡資料僅供本次活動通知、保險、聯繫與行政確認使用。正式使用、保存與分享仍依校內規範辦理。' ].join('\n')); form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); const activityIdItem = form.addTextItem().setTitle('活動ID').setHelpText('系統欄位,請勿修改。').setRequired(true); const activityNameItem = form.addTextItem().setTitle('活動/專案名稱').setHelpText('系統欄位,請勿修改。').setRequired(true); form.addTextItem().setTitle('學生姓名').setRequired(true); form.addTextItem().setTitle('班級').setRequired(true); form.addTextItem().setTitle('座號').setRequired(false); form.addTextItem().setTitle('家長姓名').setRequired(true); form.addTextItem().setTitle('家長 Email').setRequired(false); form.addTextItem().setTitle('家長電話').setRequired(false); form.addMultipleChoiceItem().setTitle('是否參加本次活動').setChoiceValues(['參加', '不參加', '需再確認']).setRequired(true); form.addMultipleChoiceItem().setTitle('是否同意接收本次活動 Email 通知').setChoiceValues(['同意', '不同意']).setRequired(true); form.addCheckboxItem().setTitle('特殊需求').setChoiceValues(['無', '暈車', '飲食需求', '醫療需求', '交通接送', '其他']).setRequired(false); form.addParagraphTextItem().setTitle('家長補充說明').setRequired(false); const prefilledUrl = form.createResponse() .withItemResponse(activityIdItem.createResponse(activity.activityId || '')) .withItemResponse(activityNameItem.createResponse(activity.activityName || '')) .toPrefilledUrl(); const formFolder = getProjectSubfolderFromContext_(context, '03_表單與回覆資料'); moveFileToFolder_(form.getId(), formFolder); const noteDoc = createParentModuleNoteDoc_(formFolder, activity, '家長聯絡表單說明', [ '家長聯絡表單:' + prefilledUrl, '編輯連結:' + form.getEditUrl(), '', '請承辦人確認表單欄位、填寫期限、個資告知文字與回覆資料使用方式後再發出。', '表單回覆或人工整理後,請以「家長聯絡資料」工作表作為寄送前確認名單。' ].join('\n')); updateActivityListParentField_(activity, '家長聯絡表單連結', prefilledUrl); appendParentFileListRow_(activity, '家長聯絡表單', '家長聯絡表單 - ' + activity.activityName, prefilledUrl, '家長聯絡與通知模組'); appendParentFileListRow_(activity, '家長聯絡表單說明', noteDoc.getName(), noteDoc.getUrl(), '放置於 03_表單與回覆資料'); alert_('家長聯絡表單已建立。' + '\n表單:' + prefilledUrl); return prefilledUrl; } catch (error) { handleError_('建立家長聯絡表單失敗', error); } } function generateParentNoticeDraft() { return generateParentNoticeDraftByType_('初次通知'); } function generatePreActivityReminderDraft() { return generateParentNoticeDraftByType_('活動前提醒'); } function generateNonResponseReminderDraft() { return generateParentNoticeDraftByType_('未回覆提醒'); } function generateParentNoticeDraftByType_(noticeType) { try { const context = getParentModuleContext_(); if (!context) return; const activity = context.activity; const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '家長通知草稿'); initializeParentNoticeDraftSheet_(sheet); const finalType = getDraftTypeForNewVersion_(sheet, activity, noticeType); const prompt = buildParentNoticePrompt_(activity, finalType, context); const aiResult = generateAiTextOrPrompt_( '家長通知草稿:' + finalType, prompt, buildParentNoticeFallback_(activity, finalType, context), { temperature: 0.25, maxOutputTokens: 4096 } ); const parsed = parseParentNoticeDraft_(aiResult.text, activity, finalType, context); const now = nowText_(); const row = [ activity.activityId, activity.activityName, finalType, parsed.subject, parsed.emailBody, parsed.lineText, parsed.paperText, aiResult.mode, aiResult.mode === AI_MODES.AI_MODE && aiResult.ok ? '是' : '否', '待確認', '', '', aiResult.error || '', now ]; sheet.getRange(sheet.getLastRow() + 1, 1, 1, PARENT_NOTICE_DRAFT_HEADERS.length).setValues([row]); const docUrl = createAiDocInProjectSubfolder_( context, '04_通知與聯繫資料', 'AI_' + finalType + ' - ' + activity.activityName, 'AI_' + finalType, [ '通知類型:' + finalType, 'Email 主旨:' + parsed.subject, '', 'Email 版:', parsed.emailBody, '', 'LINE 簡短版:', parsed.lineText, '', '紙本通知版:', parsed.paperText, '', '本通知為系統協助產生之草稿,活動日期、時間、地點、費用、交通、安全、保險、個資與校內程序,仍須由承辦人確認後才可寄送。' ].join('\n') ); updateActivityListParentField_(activity, '家長通知草稿連結', docUrl); appendParentFileListRow_(activity, 'AI_家長通知草稿 Docs', 'AI_' + finalType + ' - ' + activity.activityName, docUrl, '通知類型:' + finalType); alert_(finalType + '草稿已建立。請到「家長通知草稿」工作表確認內容,正式寄送前需將狀態改為「已確認」。'); return docUrl; } catch (error) { handleError_('建立家長通知草稿失敗', error); } } function buildParentMailingList() { try { const context = getParentModuleContext_(); if (!context) return; const ss = getPlatformSpreadsheetOrThrow_(); syncParentContactResponses_(context.activity); const contacts = getParentContactRows_(context.activity.activityId); const notice = getLatestParentNoticeDraft_(context.activity, null, false); const noticeType = notice ? notice.noticeType : '初次通知'; const sheet = getOrCreateSheet_(ss, '寄送名單'); initializeParentMailingListSheet_(sheet); const existing = buildExistingMailingKeys_(sheet, context.activity.activityId, noticeType); let added = 0; contacts.forEach(function(contact) { const key = buildParentMailingKey_(context.activity.activityId, noticeType, contact.email, contact.studentName); if (existing[key]) return; const validation = validateParentRecipient_(contact); sheet.appendRow([ context.activity.activityId, context.activity.activityName, noticeType, contact.studentName, contact.className, contact.seatNo, contact.email, validation.send, validation.status, '', validation.error, validation.note ]); added++; }); updateActivityListParentField_(context.activity, '寄送名單狀態', '已建立:' + added + ' 筆新增,通知類型:' + noticeType); alert_('寄送名單已建立。新增 ' + added + ' 筆;請檢查「寄送名單」工作表後再寄送測試信或正式寄送。'); return added; } catch (error) { handleError_('建立寄送名單失敗', error); } } function previewParentNoticeEmail() { try { const context = getParentModuleContext_(); if (!context) return; const notice = getLatestParentNoticeDraft_(context.activity, null, false); if (!notice) throw new Error('尚未建立家長通知草稿。'); const stats = getParentMailingStats_(context.activity.activityId, notice.noticeType); const warning = notice.confirmationStatus !== '已確認' ? '\n\n目前通知草稿尚未由承辦人確認,不能批次寄送。' : ''; alert_([ '家長通知信預覽', '活動名稱:' + context.activity.activityName, '通知類型:' + notice.noticeType, 'Email 主旨:' + notice.subject, '', 'Email 內容前 500 字:', String(notice.body || '').slice(0, 500), '', '寄送名單人數:' + stats.total, '可寄送人數:' + stats.sendable, '略過人數:' + stats.skipped, '缺少 Email 人數:' + stats.missingEmail, '未同意 Email 通知人數:' + stats.noConsent, warning ].join('\n')); } catch (error) { handleError_('預覽家長通知信失敗', error); } } function sendParentTestEmail() { try { const context = getParentModuleContext_(); if (!context) return; const notice = getLatestParentNoticeDraft_(context.activity, null, false); if (!notice) throw new Error('尚未建立家長通知草稿。'); const recipient = Session.getActiveUser().getEmail(); if (!recipient) throw new Error('無法取得目前使用者 Email,請改用可取得帳號的 Workspace 帳戶執行。'); const subject = '【測試信】' + notice.subject; const body = ['這是系統測試信,尚未寄送給家長。', '', notice.body].join('\n'); MailApp.sendEmail(recipient, subject, body); appendParentEmailLog_(context.activity, '測試信', recipient, '', subject, '測試成功', '', '寄送測試信給自己'); alert_('測試信已寄出:' + recipient); } catch (error) { try { const context = getSelectedActivityContext_(); if (context && context.activity) appendParentEmailLog_(context.activity, '測試信', Session.getActiveUser().getEmail() || '', '', '測試信', '測試失敗', error.message, ''); } catch (logError) { Logger.log(logError.message); } handleError_('寄送測試信失敗', error); } } function sendParentEmailsAfterConfirmation() { try { const context = getParentModuleContext_(); if (!context) return; const notice = getLatestParentNoticeDraft_(context.activity, null, true); if (!notice) throw new Error('找不到「已確認」的家長通知草稿,不能批次寄送。'); const mailingRows = getPendingParentMailingRows_(context.activity.activityId, notice.noticeType); if (!mailingRows.length) throw new Error('寄送名單中沒有「是否寄送 = 是」且「寄送狀態 = 待寄送」的資料。'); const safety = checkParentEmailSafety_(context.activity, notice, mailingRows); if (safety.blockers.length) { throw new Error('寄送已被阻擋:\n' + safety.blockers.join('\n')); } const sender = Session.getActiveUser().getEmail() || '目前帳戶'; const promptText = [ '即將寄送家長通知信。', '', '活動名稱:' + context.activity.activityName, '通知類型:' + notice.noticeType, '預計寄送:' + mailingRows.length + ' 封', '寄送帳號:' + sender, '', '請確認:', '1. 活動日期、時間、地點正確。', '2. 費用與繳交方式正確。', '3. 收件人名單已確認。', '4. 通知內容已由承辦人確認。', '5. 未包含不應寄出的個資或內部資料。', safety.warnings.length ? '\n提醒:\n' + safety.warnings.join('\n') : '', '', '請輸入「確認寄送」後才會批次寄送。' ].join('\n'); const ui = SpreadsheetApp.getUi(); const response = ui.prompt('承辦人確認後批次寄送', promptText, ui.ButtonSet.OK_CANCEL); if (response.getSelectedButton() !== ui.Button.OK || response.getResponseText() !== '確認寄送') { alert_('已中止寄送。'); return; } let success = 0; let failed = 0; mailingRows.forEach(function(item) { try { MailApp.sendEmail(item.email, notice.subject, notice.body); item.sheet.getRange(item.row, item.statusColumn).setValue('已寄送'); item.sheet.getRange(item.row, item.sentAtColumn).setValue(nowText_()); item.sheet.getRange(item.row, item.errorColumn).setValue(''); appendParentEmailLog_(context.activity, notice.noticeType, item.email, item.studentName, notice.subject, '已寄送', '', ''); success++; } catch (error) { item.sheet.getRange(item.row, item.statusColumn).setValue('失敗'); item.sheet.getRange(item.row, item.errorColumn).setValue(error.message); appendParentEmailLog_(context.activity, notice.noticeType, item.email, item.studentName, notice.subject, '失敗', error.message, ''); failed++; } }); updateActivityListParentField_(context.activity, '最近寄送時間', nowText_()); alert_('家長通知寄送完成。' + '\n成功:' + success + '\n失敗:' + failed + '\n略過:' + safety.skipped + '\n請查看「寄送紀錄」。'); } catch (error) { handleError_('承辦人確認後批次寄送失敗', error); } } function openParentEmailLog() { try { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '寄送紀錄'); initializeParentEmailLogSheet_(sheet); ss.setActiveSheet(sheet); alert_('已開啟「寄送紀錄」工作表。'); } catch (error) { handleError_('查看寄送紀錄失敗', error); } } function summarizeParentResponsesWithAi() { try { const context = getParentModuleContext_(); if (!context) return; syncParentContactResponses_(context.activity); const contacts = getParentContactRows_(context.activity.activityId); const stats = buildParentResponseStats_(contacts); const prompt = buildParentResponseSummaryPrompt_(context.activity, stats, contacts); const aiResult = generateAiTextOrPrompt_( '家長回覆與特殊需求摘要', prompt, buildParentResponseSummaryFallback_(context.activity, stats), { temperature: 0.2, maxOutputTokens: 4096 } ); const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '家長回覆摘要'); initializeParentResponseSummarySheet_(sheet); sheet.appendRow([ context.activity.activityId, context.activity.activityName, nowText_(), stats.total, stats.join, stats.notJoin, stats.noResponse, stats.specialNeedsText, stats.teacherFollowUp, stats.ownerFollowUp, formatAiResultForOutput_(aiResult), '待確認', aiResult.error || '' ]); const docUrl = createAiDocInProjectSubfolder_(context, '04_通知與聯繫資料', 'AI_家長回覆摘要 - ' + context.activity.activityName, 'AI 家長回覆摘要', formatAiResultForOutput_(aiResult)); updateActivityListParentField_(context.activity, '家長回覆摘要連結', docUrl); appendParentFileListRow_(context.activity, 'AI_家長回覆摘要 Docs', 'AI_家長回覆摘要 - ' + context.activity.activityName, docUrl, '家長聯絡與通知模組'); alert_('家長回覆摘要已建立。請檢查是否需要去識別化或補充導師確認事項。'); return docUrl; } catch (error) { handleError_('AI 摘要家長回覆與特殊需求失敗', error); } } function getParentModuleContext_() { const context = getSelectedActivityContext_(); if (!context || !context.activity) { alert_('請先在活動清單或表單回應分頁選取一筆活動資料,並確認已產生行政專案資料包。'); return null; } requireProjectPackageContext_(context); return context; } function getProjectSubfolderFromContext_(context, subfolderName) { const projectFolder = DriveApp.getFolderById(extractIdFromUrl_(context.packageFolderUrl || context.projectFolderUrl)); return getOrCreateSubFolder_(projectFolder, subfolderName); } function createParentModuleNoteDoc_(folder, activity, title, content) { const doc = DocumentApp.create(title + ' - ' + projectDisplayName_(activity)); const body = doc.getBody(); appendTitle_(body, title); appendSection_(body, '活動資料', activityToText_(activity)); appendSection_(body, '家長聯絡與通知模組', content); appendSection_(body, '人工確認提醒', '正式寄送前務必確認日期、時間、地點、費用、交通、安全與收件名單。系統僅協助草擬、整理與寄送紀錄,正式通知責任仍由承辦人確認。'); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc; } function updateActivityListParentField_(activity, header, value) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '活動清單'); initializeActivityListSheet_(sheet); const map = buildHeaderMap_(sheet); const row = findActivityListRow_(sheet, map, activity); if (row) { writeCellByHeader_(sheet, row, map, header, value); writeCellByHeader_(sheet, row, map, '最後更新時間', nowText_()); } } function findActivityListRow_(sheet, headerMap, activity) { const lastRow = sheet.getLastRow(); for (let row = 2; row <= lastRow; row++) { const id = readCellByHeader_(sheet, row, headerMap, '活動ID'); const name = readCellByHeader_(sheet, row, headerMap, '活動/專案名稱'); if (activity.activityId && id === activity.activityId) return row; if (!activity.activityId && name === activity.activityName) return row; } return 0; } function appendParentFileListRow_(activity, fileType, fileName, url, note) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '文件清單'); initializeFileListSheet_(sheet); sheet.appendRow([ activity.activityId, activity.activityName, fileType, fileName, url, nowText_(), note || '家長聯絡與通知模組' ]); } function buildParentNoticePrompt_(activity, noticeType, context) { return [ '你是熟悉國民中小學活動行政的承辦助理。請產生家長通知草稿。', '通知類型:' + noticeType, '請使用繁體中文,簡短清楚,不要過度行銷,不要在給家長的信件中提到 AI 或內部工作流。', '請固定用以下標記輸出:', 'Email 主旨:', 'Email 內容:', 'LINE 簡短版:', '紙本通知版:', '', '內容必須包含活動名稱、日期、地點、對象、費用或待確認、表單連結、填寫期限、注意事項、承辦人聯絡方式。', '未回覆提醒不得列出其他學生姓名或其他家庭資訊。', '請加入一句承辦人確認提醒,但不要把內部流程寫給家長。', '', '活動資料:', activityToText_(activity), '', '家長聯絡表單連結:' + getActivityListParentField_(activity, '家長聯絡表單連結') ].join('\n'); } function buildParentNoticeFallback_(activity, noticeType, context) { const formUrl = getActivityListParentField_(activity, '家長聯絡表單連結') || '(請貼上家長聯絡表單連結)'; const subject = noticeType + ':' + activity.activityName; const body = [ '家長您好:', '', '本次「' + valueOrPending_(activity.activityName) + '」預計於 ' + valueOrPending_(activity.activityDate) + ' 辦理,請協助確認孩子參加意願,並於 ' + valueOrPending_(activity.deadlineDate) + ' 前完成表單填寫。', '', '活動資訊:', '* 日期:' + valueOrPending_(activity.activityDate), '* 時間:待確認', '* 地點:' + valueOrPending_(activity.location), '* 對象:' + valueOrPending_(activity.participants), '* 費用:' + valueOrPending_(activity.hasFee || activity.budgetSource), '* 注意事項:請依正式通知與導師提醒辦理。', '', '表單連結:', formUrl, '', '若有特殊需求或需要協助事項,請於表單中說明,方便學校事前安排。', '', '感謝您的協助。', valueOrPending_(activity.department || activity.owner), '', '本通知為系統協助產生之草稿,活動日期、時間、地點、費用、交通、安全、保險、個資與校內程序,仍須由承辦人確認後才可寄送。' ].join('\n'); return [ 'Email 主旨:' + subject, 'Email 內容:', body, 'LINE 簡短版:', '提醒家長協助填寫「' + activity.activityName + '」活動回覆表,日期:' + valueOrPending_(activity.activityDate) + ',期限:' + valueOrPending_(activity.deadlineDate) + ',表單:' + formUrl, '紙本通知版:', body, '', buildPromptTemplateText_('家長通知草稿:' + noticeType, buildParentNoticePrompt_(activity, noticeType, context)) ].join('\n'); } function parseParentNoticeDraft_(text, activity, noticeType, context) { const content = String(text || ''); const subject = extractMarkedBlock_(content, 'Email 主旨', ['Email 內容', 'LINE 簡短版', '紙本通知版']) || noticeType + ':' + activity.activityName; const emailBody = extractMarkedBlock_(content, 'Email 內容', ['LINE 簡短版', '紙本通知版']) || buildParentNoticeFallback_(activity, noticeType, context); const lineText = extractMarkedBlock_(content, 'LINE 簡短版', ['紙本通知版']) || ('提醒家長完成「' + activity.activityName + '」活動回覆。'); const paperText = extractMarkedBlock_(content, '紙本通知版', []) || emailBody; return { subject: subject.replace(/\n/g, ' ').trim(), emailBody: emailBody.trim(), lineText: lineText.trim(), paperText: paperText.trim() }; } function extractMarkedBlock_(text, label, nextLabels) { const startPattern = new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[::]', 'i'); const start = text.search(startPattern); if (start === -1) return ''; const after = text.slice(start).replace(startPattern, ''); let end = after.length; nextLabels.forEach(function(nextLabel) { const nextPattern = new RegExp('\\n\\s*' + nextLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[::]', 'i'); const next = after.search(nextPattern); if (next !== -1 && next < end) end = next; }); return after.slice(0, end).trim(); } function getDraftTypeForNewVersion_(sheet, activity, noticeType) { const map = buildHeaderMap_(sheet); let confirmedCount = 0; for (let row = 2; row <= sheet.getLastRow(); row++) { if (readCellByHeader_(sheet, row, map, '活動ID') !== activity.activityId) continue; const type = readCellByHeader_(sheet, row, map, '通知類型'); const status = readCellByHeader_(sheet, row, map, '承辦人確認狀態'); if (String(type).indexOf(noticeType) === 0 && status === '已確認') confirmedCount++; } return confirmedCount ? noticeType + '_v' + (confirmedCount + 1) : noticeType; } function getLatestParentNoticeDraft_(activity, noticeType, requireConfirmed) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '家長通知草稿'); initializeParentNoticeDraftSheet_(sheet); const map = buildHeaderMap_(sheet); for (let row = sheet.getLastRow(); row >= 2; row--) { if (readCellByHeader_(sheet, row, map, '活動ID') !== activity.activityId) continue; const type = readCellByHeader_(sheet, row, map, '通知類型'); if (noticeType && type !== noticeType) continue; const status = readCellByHeader_(sheet, row, map, '承辦人確認狀態'); if (requireConfirmed && status !== '已確認') continue; return { row: row, activityId: activity.activityId, noticeType: type, subject: readCellByHeader_(sheet, row, map, 'Email 主旨'), body: readCellByHeader_(sheet, row, map, 'Email 內容'), confirmationStatus: status, error: readCellByHeader_(sheet, row, map, '錯誤訊息') }; } return null; } function getActivityListParentField_(activity, header) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '活動清單'); initializeActivityListSheet_(sheet); const map = buildHeaderMap_(sheet); const row = findActivityListRow_(sheet, map, activity); return row ? readCellByHeader_(sheet, row, map, header) : ''; } function getParentContactRows_(activityId) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '家長聯絡資料'); initializeParentContactSheet_(sheet); const map = buildHeaderMap_(sheet); const rows = []; for (let row = 2; row <= sheet.getLastRow(); row++) { if (readCellByHeader_(sheet, row, map, '活動ID') !== activityId) continue; rows.push({ row: row, studentName: readCellByHeader_(sheet, row, map, '學生姓名'), className: readCellByHeader_(sheet, row, map, '班級'), seatNo: readCellByHeader_(sheet, row, map, '座號'), parentName: readCellByHeader_(sheet, row, map, '家長姓名'), email: String(readCellByHeader_(sheet, row, map, '家長 Email') || '').trim(), phone: readCellByHeader_(sheet, row, map, '家長電話'), consent: readCellByHeader_(sheet, row, map, '是否同意 Email 通知'), participation: readCellByHeader_(sheet, row, map, '是否參加'), specialNeeds: readCellByHeader_(sheet, row, map, '特殊需求'), note: readCellByHeader_(sheet, row, map, '家長備註') }); } return rows; } function syncParentContactResponses_(activity) { const ss = getPlatformSpreadsheetOrThrow_(); const target = getOrCreateSheet_(ss, '家長聯絡資料'); initializeParentContactSheet_(target); const targetMap = buildHeaderMap_(target); const existing = {}; for (let row = 2; row <= target.getLastRow(); row++) { if (readCellByHeader_(target, row, targetMap, '活動ID') !== activity.activityId) continue; const key = [ readCellByHeader_(target, row, targetMap, '學生姓名'), String(readCellByHeader_(target, row, targetMap, '家長 Email') || '').toLowerCase() ].join('|'); existing[key] = true; } let imported = 0; ss.getSheets().forEach(function(sheet) { if (sheet.getName() === '家長聯絡資料') return; if (sheet.getLastRow() < 2 || sheet.getLastColumn() < 5) return; const map = buildHeaderMap_(sheet); if (!hasHeader_(map, '學生姓名') || !hasHeader_(map, '家長 Email') || !hasHeader_(map, '是否參加本次活動')) return; for (let row = 2; row <= sheet.getLastRow(); row++) { const responseActivityId = readFirstAvailableCell_(sheet, row, map, ['活動ID']); const responseActivityName = readFirstAvailableCell_(sheet, row, map, ['活動/專案名稱', '活動名稱']); if (responseActivityId && responseActivityId !== activity.activityId) continue; if (!responseActivityId && responseActivityName && responseActivityName !== activity.activityName) continue; if (!responseActivityId && !responseActivityName) continue; const studentName = readCellByHeader_(sheet, row, map, '學生姓名'); const email = String(readCellByHeader_(sheet, row, map, '家長 Email') || '').trim(); if (!studentName && !email) continue; const key = [studentName, email.toLowerCase()].join('|'); if (existing[key]) continue; target.appendRow([ activity.activityId, activity.activityName, studentName, readCellByHeader_(sheet, row, map, '班級'), readCellByHeader_(sheet, row, map, '座號'), readCellByHeader_(sheet, row, map, '家長姓名'), email, readCellByHeader_(sheet, row, map, '家長電話'), normalizeParentEmailConsent_(readCellByHeader_(sheet, row, map, '是否同意接收本次活動 Email 通知')), normalizeParentParticipation_(readCellByHeader_(sheet, row, map, '是否參加本次活動')), readCellByHeader_(sheet, row, map, '特殊需求'), readCellByHeader_(sheet, row, map, '家長補充說明'), '是', '否', '否', nowText_() ]); existing[key] = true; imported++; } }); return imported; } function hasHeader_(headerMap, header) { return !!(headerMap[header] || headerMap[normalizeHeader_(header)]); } function normalizeParentEmailConsent_(value) { const text = String(value || '').trim(); if (text === '同意') return '同意'; if (text === '不同意') return '不同意'; return '待確認'; } function normalizeParentParticipation_(value) { const text = String(value || '').trim(); if (text === '參加' || text === '不參加' || text === '需再確認') return text; return '未回覆'; } function validateParentRecipient_(contact) { if (!contact.email) return { send: '否', status: '略過', error: '', note: '缺少家長 Email' }; if (contact.consent === '不同意') return { send: '否', status: '略過', error: '', note: '未同意 Email 通知' }; if (!isValidEmail_(contact.email)) return { send: '否', status: '略過', error: 'Email 格式錯誤', note: 'Email 格式錯誤' }; return { send: '是', status: '待寄送', error: '', note: '' }; } function isValidEmail_(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || '').trim()); } function buildExistingMailingKeys_(sheet, activityId, noticeType) { const map = buildHeaderMap_(sheet); const keys = {}; for (let row = 2; row <= sheet.getLastRow(); row++) { if (readCellByHeader_(sheet, row, map, '活動ID') !== activityId) continue; if (readCellByHeader_(sheet, row, map, '通知類型') !== noticeType) continue; const email = readCellByHeader_(sheet, row, map, '家長 Email'); const studentName = readCellByHeader_(sheet, row, map, '學生姓名'); keys[buildParentMailingKey_(activityId, noticeType, email, studentName)] = true; } return keys; } function buildParentMailingKey_(activityId, noticeType, email, studentName) { return [activityId, noticeType, String(email || '').toLowerCase(), studentName || ''].join('|'); } function getParentMailingStats_(activityId, noticeType) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '寄送名單'); initializeParentMailingListSheet_(sheet); const map = buildHeaderMap_(sheet); const stats = { total: 0, sendable: 0, skipped: 0, missingEmail: 0, noConsent: 0 }; for (let row = 2; row <= sheet.getLastRow(); row++) { if (readCellByHeader_(sheet, row, map, '活動ID') !== activityId) continue; if (noticeType && readCellByHeader_(sheet, row, map, '通知類型') !== noticeType) continue; stats.total++; const send = readCellByHeader_(sheet, row, map, '是否寄送'); const status = readCellByHeader_(sheet, row, map, '寄送狀態'); const note = String(readCellByHeader_(sheet, row, map, '備註') || ''); if (send === '是' && status === '待寄送') stats.sendable++; if (status === '略過') stats.skipped++; if (note.indexOf('缺少家長 Email') !== -1) stats.missingEmail++; if (note.indexOf('未同意 Email 通知') !== -1) stats.noConsent++; } return stats; } function getPendingParentMailingRows_(activityId, noticeType) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '寄送名單'); initializeParentMailingListSheet_(sheet); const map = buildHeaderMap_(sheet); const rows = []; for (let row = 2; row <= sheet.getLastRow(); row++) { if (readCellByHeader_(sheet, row, map, '活動ID') !== activityId) continue; if (readCellByHeader_(sheet, row, map, '通知類型') !== noticeType) continue; if (readCellByHeader_(sheet, row, map, '是否寄送') !== '是') continue; if (readCellByHeader_(sheet, row, map, '寄送狀態') !== '待寄送') continue; rows.push({ sheet: sheet, row: row, studentName: readCellByHeader_(sheet, row, map, '學生姓名'), email: readCellByHeader_(sheet, row, map, '家長 Email'), statusColumn: map['寄送狀態'], sentAtColumn: map['寄送時間'], errorColumn: map['錯誤訊息'] }); } return rows; } function checkParentEmailSafety_(activity, notice, mailingList) { const blockers = []; const warnings = []; let skipped = 0; if (!notice || notice.confirmationStatus !== '已確認') blockers.push('通知草稿尚未由承辦人確認。'); if (!notice.subject) blockers.push('Email 主旨空白。'); if (!notice.body) blockers.push('Email 內容空白。'); mailingList.forEach(function(item) { if (!item.email || !isValidEmail_(item.email)) { blockers.push('寄送名單含空白或不合法 Email。'); skipped++; } }); const body = String(notice.body || ''); if (!activity.activityDate || body.indexOf('待確認') !== -1) warnings.push('通知內容可能仍有待確認日期、時間或地點。'); if ((body.indexOf('費用') !== -1 || body.indexOf('繳費') !== -1) && !activity.hasFee && !activity.budgetSource) warnings.push('通知內容提到費用,但活動資料未填費用或經費來源。'); if (/身分證|診斷|病歷|地址|個別學生|全班名單/.test(body)) blockers.push('通知內容可能包含不應公開的學生資料或敏感內容。'); if (/電話|醫療|特殊需求/.test(body)) warnings.push('通知內容提到聯絡或特殊需求資訊,請確認沒有揭露個別學生狀況。'); return { blockers: uniqueStrings_(blockers), warnings: uniqueStrings_(warnings), skipped: skipped }; } function appendParentEmailLog_(activity, noticeType, email, studentName, subject, result, errorMessage, note) { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = getOrCreateSheet_(ss, '寄送紀錄'); initializeParentEmailLogSheet_(sheet); sheet.appendRow([ nowText_(), activity.activityId, activity.activityName, noticeType, email, studentName || '', subject, result, errorMessage || '', Session.getActiveUser().getEmail() || '', note || '' ]); } function buildParentResponseStats_(contacts) { const stats = { total: contacts.length, join: 0, notJoin: 0, noResponse: 0, specialNeeds: {}, specialNeedsText: '', teacherFollowUp: '', ownerFollowUp: '' }; contacts.forEach(function(contact) { if (contact.participation === '參加') stats.join++; else if (contact.participation === '不參加') stats.notJoin++; else stats.noResponse++; const special = String(contact.specialNeeds || '').trim(); if (special && special !== '無') { special.split(/[,,、\n]/).forEach(function(item) { const key = String(item || '').trim(); if (key && key !== '無') stats.specialNeeds[key] = (stats.specialNeeds[key] || 0) + 1; }); } }); stats.specialNeedsText = Object.keys(stats.specialNeeds).map(function(key) { return key + ':' + stats.specialNeeds[key] + ' 人'; }).join(';') || '無明顯特殊需求'; stats.teacherFollowUp = stats.noResponse ? '有未回覆名單,建議請導師協助確認。' : '暫無未回覆追蹤需求。'; stats.ownerFollowUp = Object.keys(stats.specialNeeds).length ? '有特殊需求,建議承辦人確認交通、保險、飲食或醫療安排。' : '暫無特殊需求追蹤。'; return stats; } function buildParentResponseSummaryPrompt_(activity, stats, contacts) { const deidentifiedRows = contacts.map(function(contact) { return [ '班級:' + valueOrPending_(contact.className), '是否參加:' + valueOrPending_(contact.participation), '特殊需求:' + valueOrPending_(contact.specialNeeds), '家長備註:' + valueOrPending_(contact.note) ].join(';'); }).join('\n'); return [ '請根據以下去識別化家長回覆資料,產生承辦人摘要。', '請包含回覆總數、參加/不參加/未回覆、特殊需求分類、需要導師確認、需要承辦人確認、保險或交通注意事項、通知內容是否需要補充。', '不要列出學生姓名、家長姓名、電話或 Email。', '', '活動資料:', activityToText_(activity), '', '統計:', JSON.stringify(stats), '', '去識別化回覆:', deidentifiedRows ].join('\n'); } function buildParentResponseSummaryFallback_(activity, stats) { return [ '以下為家長回覆統計摘要與提示詞模板。', '', '回覆總數:' + stats.total, '參加人數:' + stats.join, '不參加人數:' + stats.notJoin, '未回覆人數:' + stats.noResponse, '特殊需求摘要:' + stats.specialNeedsText, '需要導師確認:' + stats.teacherFollowUp, '需要承辦人確認:' + stats.ownerFollowUp, '', buildPromptTemplateText_('家長回覆與特殊需求摘要', '請依去識別化家長回覆資料,整理特殊需求、未回覆追蹤、交通保險注意事項與承辦人確認事項。') ].join('\n'); } function uniqueStrings_(values) { const seen = {}; return values.filter(function(value) { if (seen[value]) return false; seen[value] = true; return true; }); } /** * 更新平台儀表板。 */ function updateDashboard() { return updatePlatformDashboard(); } function updatePlatformDashboard() { try { const ss = getPlatformSpreadsheetOrThrow_(); const dashboard = getOrCreateSheet_(ss, '平台首頁'); const activityList = getOrCreateSheet_(ss, '活動清單'); initializeActivityListSheet_(activityList); const responseSheet = findFormResponseSheetOrNull_(ss); if (responseSheet) { initializeFormResponseSheet_(responseSheet); } const stats = calculatePlatformStats_(activityList, responseSheet); const props = PropertiesService.getScriptProperties(); const formPublishedUrl = props.getProperty('INTAKE_FORM_PUBLISHED_URL') || ''; const formEditUrl = props.getProperty('INTAKE_FORM_EDIT_URL') || ''; const platformFolderUrl = props.getProperty('PLATFORM_ROOT_FOLDER_URL') || getSystemSettingValue_('平台根資料夾連結'); const projectPackagesUrl = getFolderUrlById_(props.getProperty('PROJECT_PACKAGES_FOLDER_ID') || getSystemSettingValue_('活動專案資料包資料夾 ID')); const notebookExportUrl = getFolderUrlById_(props.getProperty('NOTEBOOKLM_EXPORT_FOLDER_ID') || getSystemSettingValue_('NotebookLM匯出資料夾 ID')); const readmeUrl = props.getProperty('README_DOC_URL') || getSystemSettingValue_('README 文件連結'); dashboard.getRange(1, 1, 1, 2).setValues([['項目', '內容']]); const aiMode = getAiMode_(); const keyStatus = getGeminiKeyStatus_(); const modeDescription = aiMode === AI_MODES.AI_MODE && keyStatus === '已設定' ? '目前模式:AI_MODE。Gemini API Key 已設定,可使用 API 產生草稿。正式內容仍須由承辦人確認。' : '目前模式:提示詞模板模式。未啟用 Gemini API,系統會產生提示詞與半自動模板,基礎資料包仍可正常建立。'; dashboard.getRange(2, 1, 22, 2).setValues([ ['平台定位', '活動專案管理'], ['目前模式', 'Google Sheet 中樞模式'], ['AI 模式', aiMode], ['Gemini API Key 狀態', keyStatus], ['AI 模式說明', modeDescription], ['平台根資料夾', platformFolderUrl], ['活動專案管理 Sheet', ss.getUrl()], ['活動專案資料包資料夾', projectPackagesUrl], ['NotebookLM 匯出資料夾', notebookExportUrl], ['README 文件', readmeUrl], ['表單狀態', formPublishedUrl ? '已建立' : '尚未建立'], ['表單填答連結', formPublishedUrl], ['表單編輯連結', formEditUrl], ['活動清單筆數', stats.activities], ['已收到表單筆數', stats.responses], ['已產生資料包筆數', stats.generated], ['待處理筆數', stats.pending], ['最近一次產生時間', stats.latestGeneratedAt || ''], ['基礎課最低成功標準', '建立專案主資料夾、標準子資料夾與三個核心文件。'], ['進階活動專案管理中樞路徑', '建立公文專案啟動表/活動建案表,收多筆專案後批次產包。'], ['AI_MODE 輔助功能', '啟用後可使用:專案記錄草稿、待辦追蹤建議、成果檢核建議、校內公告草稿、表單題目、成果報告初稿、NotebookLM 匯入摘要、風險與缺漏檢查。使用前請先檢查 API Key、產生資料包,並選取活動列。'], ['下一步建議', buildNextStepSuggestion_(formPublishedUrl, stats)] ]); formatHeaderRow_(dashboard, 2); dashboard.autoResizeColumns(1, 2); } catch (error) { handleError_('更新平台儀表板失敗', error); } } /** * 檢查 Gemini API Key,並依測試結果切換 AI 模式。 */ function checkGeminiApiKey() { const key = getGeminiApiKey_(); const checkedAt = nowText_(); if (!key) { setAiModeAndKeyStatus_(AI_MODES.TEMPLATE_WITH_PROMPTS, '未設定', checkedAt, ''); updateAiModeSurfaces_(); buildActivityAdminMenu_(); const message = '尚未設定 Gemini API Key,系統將使用提示詞模板模式。基礎資料包仍可正常建立。'; alert_(message); Logger.log(message); return message; } try { testGeminiApiKey_(key); setAiModeAndKeyStatus_(AI_MODES.AI_MODE, '已設定', checkedAt, ''); updateAiModeSurfaces_(); buildActivityAdminMenu_(); const message = 'Gemini API Key 測試成功,AI 模式已切換為 AI_MODE。AI_MODE 已啟用,系統會嘗試自動產生草稿內容。'; alert_(message); Logger.log(message); return message; } catch (error) { setAiModeAndKeyStatus_(AI_MODES.TEMPLATE_WITH_PROMPTS, '測試失敗', checkedAt, error.message); updateAiModeSurfaces_(); buildActivityAdminMenu_(); const message = 'Gemini API Key 測試失敗,系統將維持提示詞模板模式。基礎資料包仍可正常建立。'; alert_(message + '\n\n錯誤訊息:' + error.message); Logger.log(message + ' ' + error.message); return message; } } /** * 顯示平台定位與操作流程。 */ function showAboutWorkflow() { const message = [ '本工具定位:', '這是活動專案管理用的進階中樞,用來集中資料、期限、文件、提醒與交接資訊。', '學校行政最累的地方,常常不是不會做,而是資料太散、期限太多、成果太晚才開始整理。', '', '操作流程:', '1. 一鍵建立我的平台資料夾。', '2. 建立公文專案啟動表/活動建案表。', '3. 送出或新增一筆專案資料。', '4. 產生行政專案資料包。', '5. 檢查標準資料夾。', '6. 檢查三個核心文件:01_專案記錄 Docs、02_待辦追蹤表 Sheet、03_成果檢核表 Sheet。', '7. 檢查 Calendar 提醒。', '8. 檢查 Gmail 通知。', '9. 檢查 NotebookLM / Gemini 輔助資料。', '10. 由承辦人確認正式內容。', '', '三個核心文件:', '01_專案記錄 Docs、02_待辦追蹤表 Sheet、03_成果檢核表 Sheet 會放在專案主資料夾根目錄。', '', 'AI 模式:', 'NotebookLM 是專案知識庫;Gemini 是草稿協助。預設使用 TEMPLATE_WITH_PROMPTS,不需要 API Key。', '', '重要提醒:', HUMAN_CONFIRMATION_NOTICE ].join('\n'); alert_(message); } /** * 非正式主流程:只用於從目前平台中的活動資料建立展示資料包。 */ function createDemoPackageOnly() { return generateFullAdminProjectPackage(); } /** * 舊函式相容。主流程請使用「活動專案管理」選單。 */ function installAdvancedActivityProjectWorkflow() { alert_('此函式已改版。現在會執行「一鍵建立我的平台資料夾」,讓目前這份已綁定腳本的 Sheet 成為活動專案管理中樞。'); return installMyActivityAdminPlatform(); } function updateDashboard_() { return updatePlatformDashboard(); } function exportNotebookLMPackage_() { return exportSelectedActivityNotebookLMPackage(); } function createPlatformSubfolders_(platformRoot) { return { GUIDE: getOrCreateSubFolder_(platformRoot, PLATFORM_SUBFOLDER_NAMES.GUIDE), HUB: getOrCreateSubFolder_(platformRoot, PLATFORM_SUBFOLDER_NAMES.HUB), PACKAGES: getOrCreateSubFolder_(platformRoot, PLATFORM_SUBFOLDER_NAMES.PACKAGES), NOTEBOOK: getOrCreateSubFolder_(platformRoot, PLATFORM_SUBFOLDER_NAMES.NOTEBOOK), OUTCOMES: getOrCreateSubFolder_(platformRoot, PLATFORM_SUBFOLDER_NAMES.OUTCOMES), SAMPLES: getOrCreateSubFolder_(platformRoot, PLATFORM_SUBFOLDER_NAMES.SAMPLES) }; } function createPlatformReadmeDoc_(guideFolder, ss, platformRoot, folders) { const doc = DocumentApp.create('README_活動型行政專案管理平台操作說明'); const body = doc.getBody(); appendTitle_(body, '活動型行政專案管理平台操作說明'); appendSection_(body, '本平台用途', '本平台定位為「活動專案管理」的進階活動型行政專案管理平台。它協助資料集中、資料夾建立、三個核心文件產生、待辦追蹤、成果檢核、期限提醒、承辦通知、家長聯絡與通知,以及 NotebookLM / Gemini 後續整理。'); appendSection_(body, '基礎流程', [ '1. 收到公文或活動需求。', '2. 填寫公文專案啟動表/活動建案表。', '3. 產生行政專案資料包。', '4. 檢查專案主資料夾與標準子資料夾。', '5. 檢查 01_專案記錄 Docs、02_待辦追蹤表 Sheet、03_成果檢核表 Sheet。', '6. 檢查 Calendar 提醒與 Gmail 承辦通知。' ].join('\n')); appendSection_(body, '進階流程', [ '1. 建立公文專案啟動表/活動建案表。', '2. 收多筆公文或活動專案。', '3. 批次產生活動專案資料包。', '4. 從活動清單、文件清單、NotebookLM 匯出資料追蹤。' ].join('\n')); appendSection_(body, 'AI 模式', [ 'TEMPLATE_WITH_PROMPTS:保底模式,不需要 API Key。系統建立資料夾、追蹤文件、三個核心文件與提示詞模板。', 'AI_MODE:進階模式。只有 Gemini API Key 測試成功後才啟用,系統可呼叫 Gemini API 協助產生草稿。', 'Gemini API 呼叫失敗時,系統會回到 TEMPLATE_WITH_PROMPTS,不中斷資料包建立。' ].join('\n')); appendSection_(body, 'NotebookLM 與 Gemini', [ 'NotebookLM:將公文、附件、計畫書、會議紀錄、照片說明、成果資料與成果報告整理成可詢問的專案知識庫。', 'Gemini:協助產出公文重點摘要、校內公告、待辦清單、表單題目、會議追蹤表、成果報告初稿、成果簡報大綱與下次辦理建議。', 'Gemini 只協助草稿,不做正式決策。' ].join('\n')); appendSection_(body, '家長聯絡與通知模組', [ '家長聯絡與通知模組用於協助承辦人依據活動資料建立家長通知草稿、聯絡表單、寄送名單與寄送紀錄。', '建議流程:建立家長聯絡表單 → 收集或整理家長 Email → 產生家長通知草稿 → 承辦人確認內容 → 建立寄送名單 → 寄送測試信 → 承辦人確認後批次寄送 → 查看寄送紀錄 → 摘要家長回覆與特殊需求。', 'AI 可協助整理與草擬,但正式通知內容、收件名單、寄送時機與個資處理,仍須由承辦人確認。' ].join('\n')); appendSection_(body, '人工確認', HUMAN_CONFIRMATION_NOTICE); appendSection_(body, '平台連結', [ '平台根資料夾:' + platformRoot.getUrl(), '活動專案管理 Sheet:' + ss.getUrl(), '活動專案資料包:' + folders.PACKAGES.getUrl(), 'NotebookLM 匯出:' + folders.NOTEBOOK.getUrl() ].join('\n')); doc.saveAndClose(); moveFileToFolder_(doc.getId(), guideFolder); return doc; } function writePlatformFolderSettings_(ss, platformRoot, folders, readmeDoc) { const settings = getOrCreateSheet_(ss, '系統設定'); const values = [ ['平台名稱', PLATFORM_NAME, '目前平台名稱'], ['建立時間', nowText_(), '平台資料夾建立或更新時間'], ['平台根資料夾 ID', platformRoot.getId(), 'Drive 根資料夾'], ['平台根資料夾連結', platformRoot.getUrl(), 'Drive 根資料夾'], ['平台說明資料夾 ID', folders.GUIDE.getId(), PLATFORM_SUBFOLDER_NAMES.GUIDE], ['AI中樞資料夾 ID', folders.HUB.getId(), PLATFORM_SUBFOLDER_NAMES.HUB], ['活動專案資料包資料夾 ID', folders.PACKAGES.getId(), PLATFORM_SUBFOLDER_NAMES.PACKAGES], ['NotebookLM匯出資料夾 ID', folders.NOTEBOOK.getId(), PLATFORM_SUBFOLDER_NAMES.NOTEBOOK], ['成果整理資料夾 ID', folders.OUTCOMES.getId(), PLATFORM_SUBFOLDER_NAMES.OUTCOMES], ['範例資料資料夾 ID', folders.SAMPLES.getId(), PLATFORM_SUBFOLDER_NAMES.SAMPLES], ['README 文件連結', readmeDoc.getUrl(), '平台操作說明'], ['活動專案管理 Sheet 連結', ss.getUrl(), '目前這份已綁定腳本的 Sheet'] ]; values.forEach(function(row) { upsertKeyValue_(settings, row[0], row[1], row[2]); }); const props = PropertiesService.getScriptProperties(); props.setProperty('PLATFORM_ROOT_FOLDER_ID', platformRoot.getId()); props.setProperty('PLATFORM_ROOT_FOLDER_URL', platformRoot.getUrl()); props.setProperty('PROJECT_PACKAGES_FOLDER_ID', folders.PACKAGES.getId()); props.setProperty('NOTEBOOKLM_EXPORT_FOLDER_ID', folders.NOTEBOOK.getId()); props.setProperty('README_DOC_URL', readmeDoc.getUrl()); } function initializePlatformSheets_(spreadsheet) { PLATFORM_SHEETS.forEach(function(name) { getOrCreateSheet_(spreadsheet, name); }); initializeHomeSheet_(spreadsheet.getSheetByName('平台首頁')); initializeActivityListSheet_(spreadsheet.getSheetByName('活動清單')); initializeActivityDataSheet_(spreadsheet.getSheetByName('活動資料')); initializeFileListSheet_(spreadsheet.getSheetByName('文件清單')); initializeWbsTrackingSheet_(spreadsheet.getSheetByName('WBS任務追蹤')); initializeNotificationSheet_(spreadsheet.getSheetByName('通知管理')); initializeRiskSheet_(spreadsheet.getSheetByName('風險檢查')); initializeNotebookExportSheet_(spreadsheet.getSheetByName('NotebookLM匯出資料')); initializeParentContactSheet_(spreadsheet.getSheetByName('家長聯絡資料')); initializeParentNoticeDraftSheet_(spreadsheet.getSheetByName('家長通知草稿')); initializeParentMailingListSheet_(spreadsheet.getSheetByName('寄送名單')); initializeParentEmailLogSheet_(spreadsheet.getSheetByName('寄送紀錄')); initializeParentResponseSummarySheet_(spreadsheet.getSheetByName('家長回覆摘要')); initializeSystemSettingsSheet_(spreadsheet.getSheetByName('系統設定')); createOrUpdateInstructionSheet(spreadsheet); } function initializeHomeSheet_(sheet) { if (!sheet.getRange(1, 1).getValue()) { sheet.getRange(1, 1).setValue('活動型行政專案管理平台'); sheet.getRange(1, 1).setFontSize(16).setFontWeight('bold').setBackground('#e8f0ff'); sheet.getRange(3, 1, 11, 2).setValues([ ['平台用途', '活動專案管理:資料集中、資料夾建立、三個核心文件、待辦追蹤、成果檢核、期限提醒、承辦通知與工具輔助整理。'], ['操作 1', '一鍵建立我的平台資料夾'], ['操作 2', '建立公文專案啟動表/活動建案表'], ['操作 3', '送出或新增一筆專案資料'], ['操作 4', '產生行政專案資料包'], ['操作 5', '檢查標準資料夾'], ['操作 6', '檢查三個核心文件:01_專案記錄 Docs、02_待辦追蹤表 Sheet、03_成果檢核表 Sheet'], ['操作 7', '檢查 Calendar、Gmail、NotebookLM / Gemini 輔助資料'], ['操作 8', '由承辦人確認正式內容'], ['家長聯絡與通知', '資料包建立後,可建立家長聯絡表單、家長通知草稿、寄送名單、測試信、正式寄送與寄送紀錄。'], ['提醒', '沒有 Gemini API Key 也可以建立三個核心文件與基本通知範本。Calendar 或 Gmail 失敗時需人工補建或通知。'] ]); } sheet.autoResizeColumns(1, 2); } function initializeActivityListSheet_(sheet) { ensureHeader_(sheet, ACTIVITY_LIST_HEADERS); formatHeaderRow_(sheet, ACTIVITY_LIST_HEADERS.length); sheet.setFrozenRows(1); } function initializeActivityDataSheet_(sheet) { ensureHeader_(sheet, ['欄位名稱', '內容', '承辦人確認', '備註']); const existing = getFirstColumnValues_(sheet); ACTIVITY_FIELDS.forEach(function(pair) { const rowIndex = existing.indexOf(pair[0]); if (rowIndex === -1) { sheet.appendRow([pair[0], pair[1], '', '']); } else if (!sheet.getRange(rowIndex + 1, 2).getValue()) { sheet.getRange(rowIndex + 1, 2).setValue(pair[1]); } }); sheet.getRange('F1:H1').merge(); sheet.getRange('F1').setValue('填完活動資料後,請點上方選單:活動專案管理 → 依「活動資料」新增一筆行政專案'); sheet.getRange('F1').setBackground('#fff7ed').setFontColor('#9a3412').setFontWeight('bold').setWrap(true); formatHeaderRow_(sheet, 4); sheet.setFrozenRows(1); sheet.setColumnWidth(1, 180); sheet.setColumnWidth(2, 280); sheet.setColumnWidth(6, 220); sheet.setColumnWidth(7, 360); } function initializeFormResponseSheet_(sheet) { const headers = ['時間戳記'].concat(FORM_FIELDS).concat(RESPONSE_OUTPUT_HEADERS); ensureHeader_(sheet, headers); formatHeaderRow_(sheet, sheet.getLastColumn()); sheet.setFrozenRows(1); return buildHeaderMap_(sheet); } function initializeFileListSheet_(sheet) { ensureHeader_(sheet, FILE_LIST_HEADERS); formatHeaderRow_(sheet, FILE_LIST_HEADERS.length); sheet.setFrozenRows(1); } function initializeWbsTrackingSheet_(sheet) { ensureHeader_(sheet, WBS_HEADERS); formatHeaderRow_(sheet, WBS_HEADERS.length); sheet.setFrozenRows(1); } function initializeNotificationSheet_(sheet) { ensureHeader_(sheet, NOTIFICATION_HEADERS); formatHeaderRow_(sheet, NOTIFICATION_HEADERS.length); sheet.setFrozenRows(1); } function initializeRiskSheet_(sheet) { ensureHeader_(sheet, RISK_HEADERS); formatHeaderRow_(sheet, RISK_HEADERS.length); sheet.setFrozenRows(1); } function initializeNotebookExportSheet_(sheet) { ensureHeader_(sheet, NOTEBOOK_HEADERS); formatHeaderRow_(sheet, NOTEBOOK_HEADERS.length); sheet.setFrozenRows(1); } function initializeParentContactSheet_(sheet) { ensureHeader_(sheet, PARENT_CONTACT_HEADERS); formatHeaderRow_(sheet, PARENT_CONTACT_HEADERS.length); sheet.setFrozenRows(1); setValidationList_(sheet, 2, 9, Math.max(sheet.getMaxRows() - 1, 1), PARENT_EMAIL_CONSENT_VALUES); setValidationList_(sheet, 2, 10, Math.max(sheet.getMaxRows() - 1, 1), PARENT_PARTICIPATION_VALUES); setValidationList_(sheet, 2, 13, Math.max(sheet.getMaxRows() - 1, 1), YES_NO_REVIEW_VALUES); setValidationList_(sheet, 2, 14, Math.max(sheet.getMaxRows() - 1, 1), YES_NO_REVIEW_VALUES); setValidationList_(sheet, 2, 15, Math.max(sheet.getMaxRows() - 1, 1), YES_NO_REVIEW_VALUES); sheet.autoResizeColumns(1, PARENT_CONTACT_HEADERS.length); } function initializeParentNoticeDraftSheet_(sheet) { ensureHeader_(sheet, PARENT_NOTICE_DRAFT_HEADERS); formatHeaderRow_(sheet, PARENT_NOTICE_DRAFT_HEADERS.length); sheet.setFrozenRows(1); setValidationList_(sheet, 2, 3, Math.max(sheet.getMaxRows() - 1, 1), PARENT_NOTICE_TYPES); setValidationList_(sheet, 2, 10, Math.max(sheet.getMaxRows() - 1, 1), PARENT_NOTICE_CONFIRMATION_VALUES); sheet.setColumnWidth(4, 280); sheet.setColumnWidth(5, 620); sheet.setColumnWidth(6, 420); sheet.setColumnWidth(7, 520); sheet.getRange(1, 1, Math.max(sheet.getLastRow(), 1), PARENT_NOTICE_DRAFT_HEADERS.length).setWrap(true); } function initializeParentMailingListSheet_(sheet) { ensureHeader_(sheet, PARENT_MAILING_LIST_HEADERS); formatHeaderRow_(sheet, PARENT_MAILING_LIST_HEADERS.length); sheet.setFrozenRows(1); setValidationList_(sheet, 2, 3, Math.max(sheet.getMaxRows() - 1, 1), PARENT_NOTICE_TYPES); setValidationList_(sheet, 2, 8, Math.max(sheet.getMaxRows() - 1, 1), PARENT_SEND_VALUES); setValidationList_(sheet, 2, 9, Math.max(sheet.getMaxRows() - 1, 1), PARENT_SEND_STATUS_VALUES); sheet.autoResizeColumns(1, PARENT_MAILING_LIST_HEADERS.length); } function initializeParentEmailLogSheet_(sheet) { ensureHeader_(sheet, PARENT_EMAIL_LOG_HEADERS); formatHeaderRow_(sheet, PARENT_EMAIL_LOG_HEADERS.length); sheet.setFrozenRows(1); sheet.autoResizeColumns(1, PARENT_EMAIL_LOG_HEADERS.length); } function initializeParentResponseSummarySheet_(sheet) { ensureHeader_(sheet, PARENT_RESPONSE_SUMMARY_HEADERS); formatHeaderRow_(sheet, PARENT_RESPONSE_SUMMARY_HEADERS.length); sheet.setFrozenRows(1); sheet.setColumnWidth(8, 360); sheet.setColumnWidth(11, 620); sheet.getRange(1, 1, Math.max(sheet.getLastRow(), 1), PARENT_RESPONSE_SUMMARY_HEADERS.length).setWrap(true); } function initializeSystemSettingsSheet_(sheet) { ensureHeader_(sheet, ['設定項目', '目前值', '說明']); const currentAiMode = getSetting_('AI 模式') || getSetting_('AI_MODE') || CONFIG.AI_MODE; const keyStatus = getSetting_('Gemini API Key 狀態') || (hasGeminiApiKey_() ? '待測試' : '未設定'); upsertKeyValue_(sheet, 'AI 模式', normalizeAiMode_(currentAiMode), 'TEMPLATE_WITH_PROMPTS / AI_MODE'); upsertKeyValue_(sheet, 'AI_MODE', normalizeAiMode_(currentAiMode), '相容舊欄位:TEMPLATE_WITH_PROMPTS / AI_MODE'); upsertKeyValue_(sheet, 'MODEL_NAME', CONFIG.MODEL_NAME, 'Gemini API 模型名稱'); upsertKeyValue_(sheet, 'TIMEZONE', CONFIG.TIMEZONE, '預設時區'); upsertKeyValue_(sheet, '範本使用方式', '複製已綁定 Apps Script 的活動專案管理 Sheet 範本後,從自訂選單啟動。', '本程式不建立另一份空白 Sheet'); upsertKeyValue_(sheet, 'Gemini API Key 狀態', keyStatus, '已設定 / 未設定 / 測試失敗 / 待測試'); upsertKeyValue_(sheet, 'GEMINI_API_KEY 狀態', keyStatus, '相容舊欄位,不顯示完整 API Key'); upsertKeyValue_(sheet, 'Gemini API Key 測試時間', getSetting_('Gemini API Key 測試時間') || '', '最近一次執行檢查 Gemini API Key 的時間'); upsertKeyValue_(sheet, 'Gemini API Key 錯誤訊息', getSetting_('Gemini API Key 錯誤訊息') || '', '測試失敗時記錄錯誤內容'); upsertKeyValue_(sheet, 'Gemini API Key 顯示', maskApiKey_(getGeminiApiKey_()), '僅遮罩顯示,不寫入完整 API Key'); upsertKeyValue_(sheet, '平台模式', '活動專案管理', '活動型行政專案管理平台'); formatHeaderRow_(sheet, 3); sheet.setFrozenRows(1); } function createOrUpdateInstructionSheet(spreadsheet) { const ss = spreadsheet || SpreadsheetApp.getActiveSpreadsheet(); if (!ss) throw new Error('找不到可更新操作說明的 Spreadsheet。'); const sheet = getOrCreateSheet_(ss, '操作說明'); sheet.getRange('A1').setValue('第二堂課進階版:活動型行政專案管理平台'); sheet.getRange('A1').setFontWeight('bold').setFontSize(16).setBackground('#e8f0ff'); sheet.getRange('A3:B12').setValues([ ['流程 1', '一鍵建立我的平台資料夾'], ['流程 2', '建立公文專案啟動表/活動建案表'], ['流程 3', '送出或新增一筆專案資料'], ['流程 4', '產生行政專案資料包'], ['流程 5', '檢查標準資料夾'], ['流程 6', '檢查三個核心文件:01_專案記錄 Docs、02_待辦追蹤表 Sheet、03_成果檢核表 Sheet'], ['流程 7', '檢查 Calendar 提醒'], ['流程 8', '檢查 Gmail 通知'], ['流程 9', '檢查 NotebookLM / Gemini 輔助資料'], ['流程 10', '由承辦人確認正式內容'] ]); sheet.getRange('A14:B27').setValues([ ['系統定位', '活動專案管理;工具協助整理專案資料與提示缺漏,Apps Script 展開行政流程。'], ['基礎必備產物', '專案主資料夾、標準子資料夾、三個核心文件、Calendar 期限提醒、Gmail 承辦通知。'], ['AI 模式:TEMPLATE_WITH_PROMPTS', '適合大多數學員。不需要 API Key。系統會建立資料夾、追蹤文件與提示詞模板。'], ['AI 模式:AI_MODE', '適合講師展示或進階使用。需要 Gemini API Key 測試成功後才會啟用。系統可以呼叫 Gemini API 協助產生草稿。'], ['AI_MODE 可用功能', 'AI 產生專案記錄草稿、AI 補齊待辦追蹤表、AI 產生成果檢核項目、AI 產生校內公告草稿、AI 產生表單題目、AI 產生成果報告初稿、AI 產生 NotebookLM 匯入摘要、AI 檢查風險與缺漏。這些 AI 功能選單只會在 AI_MODE 啟用後顯示。'], ['AI_MODE 使用前提', '已填入 Gemini API Key、已執行檢查 Gemini API Key、系統設定顯示 AI_MODE、已產生行政專案資料包、已在活動清單或表單回應分頁選取一筆活動。'], ['沒有 Gemini API Key 也可以操作', '提示詞模板模式下仍可建立基礎資料包、三個核心文件與提示詞。'], ['家長聯絡與通知模組', '用於協助承辦人依據活動資料建立家長通知草稿、聯絡表單、寄送名單與寄送紀錄。AI 可協助整理與草擬,但正式通知內容、收件名單、寄送時機與個資處理,仍須由承辦人確認。'], ['家長通知操作順序', '先產生活動專案資料包 → 建立家長聯絡表單 → 收集家長回覆或匯入家長 Email → AI 或模板產生家長通知草稿 → 承辦人確認內容 → 建立寄送名單 → 寄送測試信給自己 → 承辦人確認後批次寄送 → 查看寄送紀錄 → 摘要家長回覆與特殊需求。'], ['正式寄送提醒', '正式寄送前務必確認日期、時間、地點、費用、交通、安全與收件名單。系統僅協助草擬、整理與寄送紀錄,正式通知責任仍由承辦人確認。'], ['需要 Gemini 時才設定 API Key', '只有「AI 模式 = AI_MODE」且「Gemini API Key 狀態 = 已設定」時才會呼叫 Gemini。'], ['選單不出現的常見原因', '這份程式沒有綁定在目前 Sheet,或重新整理後尚未授權。'], ['AI 模式提醒', '即使啟用 AI_MODE,正式公告、經費核銷、公文回覆、成果送出、交通安全、個資處理與校內程序仍須由承辦人確認。'], ['人工確認', HUMAN_CONFIRMATION_NOTICE] ]); const props = PropertiesService.getScriptProperties(); sheet.getRange('A29:B31').setValues([ ['公文專案啟動表/活動建案表填答連結', props.getProperty('INTAKE_FORM_PUBLISHED_URL') || ''], ['公文專案啟動表/活動建案表編輯連結', props.getProperty('INTAKE_FORM_EDIT_URL') || ''], ['最後更新時間', nowText_()] ]); sheet.autoResizeColumns(1, 2); } function generatePackageForActivity_(activity, options) { options = options || {}; const folder = createActivityPackageFolder_(activity, options.packageType || '活動資料包'); const subfolders = createStandardProjectSubfolders_(folder); const context = { subfolders: subfolders, source: options.source || '', packageType: options.packageType || '活動資料包' }; const ai = generateAiDraftsSafely_(activity); const projectRecordDoc = createProjectRecordDoc_(activity, folder, context); const taskTrackingSheet = createTaskTrackingSheet_(activity, folder, context); const outcomeChecklistSheet = createOutcomeChecklistSheet_(activity, folder, context); const systemFolder = subfolders['99_系統產生文件'] || folder; const workFolder = subfolders['02_工作分工與會議紀錄'] || systemFolder; const notificationFolder = subfolders['04_通知與聯繫資料'] || systemFolder; const outcomeFolder = subfolders['07_成果資料與成果報告'] || systemFolder; const summaryDoc = createSummaryDoc_(systemFolder, activity, ai.summary); const wbsSheet = createWbsSheet_(workFolder, activity, ai.wbs); const parentNoticeDoc = createParentNoticeDoc_(notificationFolder, activity, ai.parentNotice); const teacherReminderDoc = createTeacherReminderDoc_(notificationFolder, activity, ai.teacherReminder); const riskSheet = createRiskSheet_(workFolder, activity, ai.risk); const notificationSheet = createNotificationSheet_(notificationFolder, activity); const workflowReviewDoc = createWorkflowReviewDoc_(systemFolder, activity, ai.workflowReview); const notebookDoc = createNotebookPackageDoc_(outcomeFolder, activity, { projectRecordDoc: projectRecordDoc, taskTrackingSheet: taskTrackingSheet, outcomeChecklistSheet: outcomeChecklistSheet, summaryDoc: summaryDoc, wbsSheet: wbsSheet, parentNoticeDoc: parentNoticeDoc, teacherReminderDoc: teacherReminderDoc, riskSheet: riskSheet, notificationSheet: notificationSheet, workflowReviewDoc: workflowReviewDoc }); const handoffDoc = createHandoffDoc_(systemFolder, activity, ai.handoff); const partialResult = { activityId: activity.activityId, activityName: activity.activityName, projectFolderUrl: folder.getUrl(), packageFolderUrl: folder.getUrl(), projectRecordDocUrl: projectRecordDoc.getUrl(), taskTrackingSheetUrl: taskTrackingSheet.getUrl(), outcomeChecklistSheetUrl: outcomeChecklistSheet.getUrl(), notebookPackageUrl: notebookDoc.getUrl() }; const calendarStatus = createCalendarRemindersSafely_(activity, partialResult); const gmailStatus = sendOwnerNotificationSafely_(activity, partialResult); return { activityId: activity.activityId, activityName: activity.activityName, packageType: context.packageType, source: context.source, projectFolderUrl: folder.getUrl(), packageFolderUrl: folder.getUrl(), projectRecordDocUrl: projectRecordDoc.getUrl(), taskTrackingSheetUrl: taskTrackingSheet.getUrl(), outcomeChecklistSheetUrl: outcomeChecklistSheet.getUrl(), standardFolderStatus: '已建立 ' + PROJECT_SUBFOLDER_NAMES.length + ' 個標準子資料夾', summaryDocUrl: summaryDoc.getUrl(), wbsSheetUrl: wbsSheet.getUrl(), parentNoticeDocUrl: parentNoticeDoc.getUrl(), teacherReminderDocUrl: teacherReminderDoc.getUrl(), riskSheetUrl: riskSheet.getUrl(), notificationSheetUrl: notificationSheet.getUrl(), workflowReviewDocUrl: workflowReviewDoc.getUrl(), notebookPackageUrl: notebookDoc.getUrl(), handoffSummaryUrl: handoffDoc.getUrl(), calendarStatus: calendarStatus, gmailStatus: gmailStatus, generatedAt: nowText_() }; } function appendActivityListRow_(ss, activity, result) { const sheet = ss.getSheetByName('活動清單'); initializeActivityListSheet_(sheet); sheet.appendRow([ activity.activityId, activity.activityName, activity.activityType, activity.documentNo, activity.documentSource, activity.activityDate, activity.deadlineDate, activity.importantDeadlines, activity.location, activity.participants, activity.estimatedNumber, activity.department, activity.owner, activity.ownerEmail, '文件已產生', result.packageFolderUrl, result.projectRecordDocUrl, result.taskTrackingSheetUrl, result.outcomeChecklistSheetUrl, result.standardFolderStatus, result.calendarStatus, result.gmailStatus, result.notebookPackageUrl, '', '', '', '', '', result.summaryDocUrl, result.wbsSheetUrl, result.parentNoticeDocUrl, result.teacherReminderDocUrl, result.riskSheetUrl, result.notificationSheetUrl, result.handoffSummaryUrl, result.generatedAt, result.generatedAt, result.source || '' ]); } function appendFileListRows_(ss, result) { const sheet = ss.getSheetByName('文件清單'); initializeFileListSheet_(sheet); const rows = [ ['專案資料夾', '專案資料夾', result.packageFolderUrl, '主資料夾'], ['專案記錄 Docs', '01_專案記錄 Docs', result.projectRecordDocUrl, '核心文件'], ['待辦追蹤表 Sheet', '02_待辦追蹤表 Sheet', result.taskTrackingSheetUrl, '核心文件'], ['成果檢核表 Sheet', '03_成果檢核表 Sheet', result.outcomeChecklistSheetUrl, '核心文件'], ['活動建案摘要', '活動建案摘要', result.summaryDocUrl, '進階輔助產物'], ['WBS任務清單', 'WBS任務清單', result.wbsSheetUrl, '進階輔助產物'], ['家長通知單', '家長通知單', result.parentNoticeDocUrl, '進階輔助產物'], ['導師行前提醒', '導師行前提醒', result.teacherReminderDocUrl, '進階輔助產物'], ['風險檢查表', '風險檢查表', result.riskSheetUrl, '進階輔助產物'], ['通知管理表', '通知管理表', result.notificationSheetUrl, '進階輔助產物'], ['工作流驗收建議', '工作流驗收建議', result.workflowReviewDocUrl, '進階輔助產物'], ['NotebookLM資料包', 'NotebookLM資料包', result.notebookPackageUrl, '進階輔助產物'], ['承辦人交接摘要', '承辦人交接摘要', result.handoffSummaryUrl, '進階輔助產物'] ].filter(function(row) { return row[2]; }).map(function(row) { return [result.activityId, result.activityName, row[0], row[1], row[2], result.generatedAt, row[3] + ';' + (result.source || result.packageType || '')]; }); if (rows.length) { sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, FILE_LIST_HEADERS.length).setValues(rows); } } function appendTrackingRows_(ss, activity, result) { appendWbsRows_(ss.getSheetByName('WBS任務追蹤'), activity); appendNotificationRows_(ss.getSheetByName('通知管理'), activity); appendRiskRows_(ss.getSheetByName('風險檢查'), activity); appendNotebookRows_(ss, activity, result); } function appendWbsRows_(sheet, activity) { initializeWbsTrackingSheet_(sheet); const rows = WBS_STAGES.map(function(stage) { return [ activity.activityId, stage, stage + '工作包', '依「' + activity.activityName + '」活動資料修正任務', activity.owner || '待指派', '依活動日期倒推', '請參考資料包文件', '待確認', '已建立模板', '' ]; }); sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, WBS_HEADERS.length).setValues(rows); } function appendNotificationRows_(sheet, activity) { initializeNotificationSheet_(sheet); const rows = [ ['家長', '家長通知單與同意書', '活動前 2-4 週', '紙本/班級群組/校網', '是', activity.owner || '', '待確認', '正式發出前需核定'], ['導師', '行前提醒與名冊確認', '活動前一週', '校內群組/會議', '是', activity.owner || '', '待確認', '確認點名與回報方式'], ['行政處室', '交通、保險、經費與安全事項', '活動前 2-4 週', '會議/公文/Email', '是', activity.owner || '', '待確認', '依校內流程'], ['學生', '攜帶物品與集合資訊', '活動前一天', '導師轉知', '否', '導師', '待確認', '避免公布個資'], ['主管', '核准與風險確認', '通知發出前', '簽核/會議', '是', activity.owner || '', '待確認', '需確認正式版本'] ].map(function(row) { return [activity.activityId].concat(row); }); sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, NOTIFICATION_HEADERS.length).setValues(rows); } function appendRiskRows_(sheet, activity) { initializeRiskSheet_(sheet); const rows = RISK_CATEGORIES.map(function(category) { return [activity.activityId, category, '請依 ' + activity.activityName + ' 補上具體風險', '', '', '', '待確認', activity.owner || '', '待確認', '可參考資料包中的風險檢查表']; }); sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, RISK_HEADERS.length).setValues(rows); } function appendNotebookRows_(ss, activity, result) { const sheet = ss.getSheetByName('NotebookLM匯出資料'); initializeNotebookExportSheet_(sheet); const rows = [ ['活動基本資料', activityToText_(activity), '是', '需去識別化後使用'], ['資料包資料夾', result.packageFolderUrl || '', '是', '可作為來源索引'], ['專案記錄 Docs', result.projectRecordDocUrl || '', '是', '專案脈絡與決策紀錄'], ['待辦追蹤表 Sheet', result.taskTrackingSheetUrl || '', '是', '任務、里程碑與責任分工'], ['成果檢核表 Sheet', result.outcomeChecklistSheetUrl || '', '是', '成果報告、核銷與結案檢核'], ['家長通知單', result.parentNoticeDocUrl || '', '是', '正式版本需承辦人確認'], ['導師行前提醒', result.teacherReminderDocUrl || '', '是', '正式版本需承辦人確認'], ['風險檢查表', result.riskSheetUrl || '', '是', '風險判斷需人工確認'], ['Gemini 草稿任務', GEMINI_DRAFT_TYPES.join('、'), '是', 'Gemini 協助草稿,不做正式決策'], ['敏感資料提醒', '學生姓名、健康、電話、收費明細不要匯入 NotebookLM', '否', '保留在 Drive 權限控管'] ].map(function(row) { return [activity.activityId].concat(row); }); sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, NOTEBOOK_HEADERS.length).setValues(rows); } function writeBackPackageLinks_(sheet, row, headerMap, activity, result) { writeCellByHeader_(sheet, row, headerMap, '活動ID', activity.activityId); writeCellByHeader_(sheet, row, headerMap, '處理狀態', '已產生'); writeCellByHeader_(sheet, row, headerMap, '資料包資料夾連結', result.packageFolderUrl); writeCellByHeader_(sheet, row, headerMap, '專案記錄連結', result.projectRecordDocUrl); writeCellByHeader_(sheet, row, headerMap, '待辦追蹤表連結', result.taskTrackingSheetUrl); writeCellByHeader_(sheet, row, headerMap, '成果檢核表連結', result.outcomeChecklistSheetUrl); writeCellByHeader_(sheet, row, headerMap, '活動建案摘要連結', result.summaryDocUrl); writeCellByHeader_(sheet, row, headerMap, 'WBS任務清單連結', result.wbsSheetUrl); writeCellByHeader_(sheet, row, headerMap, '家長通知單連結', result.parentNoticeDocUrl); writeCellByHeader_(sheet, row, headerMap, '導師行前提醒連結', result.teacherReminderDocUrl); writeCellByHeader_(sheet, row, headerMap, '風險檢查表連結', result.riskSheetUrl); writeCellByHeader_(sheet, row, headerMap, '通知管理表連結', result.notificationSheetUrl); writeCellByHeader_(sheet, row, headerMap, 'NotebookLM資料包連結', result.notebookPackageUrl); writeCellByHeader_(sheet, row, headerMap, '承辦人交接摘要連結', result.handoffSummaryUrl); writeCellByHeader_(sheet, row, headerMap, 'Calendar 狀態', result.calendarStatus); writeCellByHeader_(sheet, row, headerMap, 'Gmail 狀態', result.gmailStatus); writeCellByHeader_(sheet, row, headerMap, '產生時間', result.generatedAt); writeCellByHeader_(sheet, row, headerMap, '錯誤訊息', ''); } function generateActivityId_() { const now = new Date(); const timestamp = Utilities.formatDate(now, CONFIG.TIMEZONE, 'yyyyMMdd-HHmmss'); const props = PropertiesService.getScriptProperties(); const serialKey = 'ACTIVITY_ID_SERIAL_' + Utilities.formatDate(now, CONFIG.TIMEZONE, 'yyyyMMdd'); const next = Number(props.getProperty(serialKey) || '0') + 1; props.setProperty(serialKey, String(next)); const serial = ('000' + next).slice(-3); return 'ACT-' + timestamp + '-' + serial; } function generateAiDraftsSafely_(activity) { const drafts = {}; if (!shouldUseGeminiApi_()) { return drafts; } const activityText = activityToText_(activity); try { drafts.parentNotice = callGemini_(PROMPTS.parentNotice.replace('(貼上活動建案資料)', activityText)); } catch (e) { handleGeminiDraftError_(e); } try { drafts.teacherReminder = callGemini_(PROMPTS.teacherReminder.replace('(貼上活動建案資料)', activityText)); } catch (e) { handleGeminiDraftError_(e); } try { drafts.risk = callGemini_(PROMPTS.riskCheck.replace('(貼上活動建案資料)', activityText)); } catch (e) { handleGeminiDraftError_(e); } try { drafts.wbs = callGemini_(PROMPTS.wbs.replace('(貼上活動建案資料)', activityText)); } catch (e) { handleGeminiDraftError_(e); } try { drafts.handoff = callGemini_(PROMPTS.handoff.replace('(貼上活動建案資料)', activityText)); } catch (e) { handleGeminiDraftError_(e); } try { drafts.workflowReview = callGemini_(PROMPTS.workflowReview.replace('(貼上表單欄位、資料夾架構、文件清單、提醒節點)', buildWorkflowReviewContext_(activity))); } catch (e) { handleGeminiDraftError_(e); } return drafts; } function handleGeminiDraftError_(error) { Logger.log('Gemini API 草稿產生失敗,改用提示詞模板:' + error.message); setSetting_('Gemini API Key 錯誤訊息', error.message, '產生草稿時的最近一次錯誤;AI_MODE 不會因單次產生失敗自動關閉'); } function createStandardProjectSubfolders_(projectFolder) { const map = {}; PROJECT_SUBFOLDER_NAMES.forEach(function(name) { map[name] = getOrCreateSubFolder_(projectFolder, name); }); return map; } function createProjectRecordDoc_(project, projectFolder, context) { const projectName = projectDisplayName_(project); const doc = DocumentApp.create('01_專案記錄 Docs - ' + projectName); const body = doc.getBody(); appendTitle_(body, '01_專案記錄 Docs - ' + projectName); appendSection_(body, '系統定位', '本文件屬於「活動專案管理」核心文件。工具協助整理專案資料與提示缺漏,Apps Script 協助建立行政流程;正式內容仍由承辦人確認。'); appendDocTable_(body, '一、專案基本資料', [ ['專案名稱', projectName], ['專案類型', project.activityType], ['公文文號', project.documentNo], ['公文來源', project.documentSource], ['公文連結', project.documentUrl], ['附件連結', project.attachmentUrl], ['活動日期', project.activityDate], ['截止日期', project.deadlineDate], ['重要期限', project.importantDeadlines], ['活動地點', project.location], ['對象年級/班級', project.participants], ['預估人數', project.estimatedNumber], ['承辦處室', project.department], ['承辦人', project.owner], ['協辦人', project.coOwners], ['建案日期', nowText_()] ]); appendSection_(body, '二、專案目標', '本專案要完成的行政目標:\n預期成果:\n對學生、教師、家長或學校行政的意義:'); appendSection_(body, '三、公文重點摘要', '公文要求事項:\n重要期限:\n需要回覆或送出的資料:\n相關法規或注意事項:\n承辦人確認:'); appendDocTable_(body, '四、重要期程', [ ['期程項目', '日期', '說明', '是否建立 Calendar 提醒', '負責人', '備註'], ['公文收文日', '', '收到公文或活動需求', '待確認', project.owner || '', ''], ['專案啟動日', nowText_(), '建立行政專案資料包', '待確認', project.owner || '', ''], ['家長通知日', '', '通知或公告草稿需人工確認', '待確認', project.owner || '', ''], ['表單或同意書回收截止日', project.deadlineDate || '', '回收表單、同意書或名冊', '待確認', project.owner || '', ''], ['保險名冊確認日', '', '活動前確認保險與名冊', '待確認', project.owner || '', ''], ['行前通知日', '', '導師與學生行前提醒', '待確認', project.owner || '', ''], ['活動日期', project.activityDate || '', '活動執行日', '待確認', project.owner || '', ''], ['成果資料回收日', '', '照片、紀錄、回饋與成果素材', '待確認', project.owner || '', ''], ['核銷或結案日期', project.deadlineDate || '', '經費核銷、成果送出或公文回覆', '待確認', project.owner || '', ''] ]); appendDocTable_(body, '五、執行紀錄', [['日期', '紀錄事項', '負責人', '後續處理', '備註'], ['', '', '', '', '']]); appendDocTable_(body, '六、重要決策紀錄', [['決策日期', '決策內容', '依據資料', '決策者/確認者', '備註'], ['', '', '', '', '']]); appendDocTable_(body, '七、成果整理紀錄', [['成果項目', '資料來源', '存放位置', '是否完成', '備註'], ['', '', '', '', '']]); appendSection_(body, '八、結案摘要', '實際完成情形:\n遇到的問題:\n下次可沿用事項:\n下次應調整事項:\n下次辦理建議:'); const projectRecordPrompt = buildProjectRecordAiPrompt_(project, context); const projectRecordAi = generateAiTextOrPrompt_( '專案記錄草稿', projectRecordPrompt, buildPromptTemplateText_('專案記錄草稿', projectRecordPrompt), { temperature: 0.25, maxOutputTokens: 4096 } ); appendSection_( body, buildAiResultSectionTitle_(projectRecordAi, 'AI 產生之專案記錄草稿', 'AI 提示詞模板'), formatAiResultForOutput_(projectRecordAi) ); appendSection_(body, '九、承辦人確認區', HUMAN_CONFIRMATION_NOTICE); if (context && context.subfolders) { appendDocTable_(body, '標準資料夾索引', PROJECT_SUBFOLDER_NAMES.map(function(name) { return [name, context.subfolders[name] ? context.subfolders[name].getUrl() : '']; })); } doc.saveAndClose(); moveFileToFolder_(doc.getId(), projectFolder); return doc; } function createTaskTrackingSheet_(project, projectFolder, context) { const projectName = projectDisplayName_(project); const ss = SpreadsheetApp.create('02_待辦追蹤表 Sheet - ' + projectName); const sheet = ss.getSheets()[0]; sheet.setName('待辦總表'); const headers = ['任務編號', '階段', '任務名稱', '任務說明', '負責人', '協辦人', '開始日期', '截止日期', '狀態', '優先程度', '對應資料夾', '對應文件', '是否已提醒', '備註']; sheet.getRange(1, 1, 1, headers.length).setValues([headers]); const defaultTasks = [ ['T01', '啟動', '確認公文要求事項', '整理公文重點、期限與需送出資料', project.owner, project.coOwners, '', project.deadlineDate, '未開始', '高', folderUrlFromContext_(context, '00_原始公文與附件'), '01_專案記錄 Docs', '待確認', ''], ['T02', '啟動', '建立專案資料夾', '確認標準子資料夾是否已建立', project.owner, project.coOwners, '', '', '已完成', '高', projectFolder.getUrl(), '', '是', '系統建立'], ['T03', '啟動', '上傳原始公文與附件', '將公文、附件與相關來源放入固定位置', project.owner, project.coOwners, '', '', '未開始', '高', folderUrlFromContext_(context, '00_原始公文與附件'), '', '待確認', ''], ['T04', '規劃', '確認活動日期與地點', '日期、地點、集合與返校資訊需人工確認', project.owner, project.coOwners, '', project.activityDate, '未開始', '高', folderUrlFromContext_(context, '01_計畫書與核定資料'), '01_專案記錄 Docs', '待確認', ''], ['T05', '通知', '草擬校內公告', '由 Gemini 或承辦人產生草稿,正式公告需確認', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '04_通知與聯繫資料'), '', '待確認', ''], ['T06', '通知', '草擬家長通知單', '確認日期、費用、交通、保險與同意書資訊', project.owner, project.coOwners, '', '', '未開始', '高', folderUrlFromContext_(context, '04_通知與聯繫資料'), '家長通知單', '待確認', ''], ['T07', '表單', '建立表單題目', '設計回覆欄位並避免收集不必要個資', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '03_表單與回覆資料'), '', '待確認', ''], ['T08', '表單', '發出家長通知單或表單', '發出前需主管或承辦人確認正式版本', project.owner, project.coOwners, '', project.deadlineDate, '未開始', '高', folderUrlFromContext_(context, '04_通知與聯繫資料'), '', '待確認', ''], ['T09', '表單', '回收家長同意書或表單資料', '追蹤未回覆名單與補催', project.owner, project.coOwners, '', project.deadlineDate, '未開始', '高', folderUrlFromContext_(context, '03_表單與回覆資料'), '', '待確認', ''], ['T10', '名冊', '彙整參加學生名冊', '名冊需存放於權限控管位置', project.owner, project.coOwners, '', '', '未開始', '高', folderUrlFromContext_(context, '03_表單與回覆資料'), '', '待確認', ''], ['T11', '安全', '確認保險名冊', '核對姓名、班級與保險資料', project.owner, project.coOwners, '', '', '未開始', '高', folderUrlFromContext_(context, '05_經費與採購核銷資料'), '', '待確認', ''], ['T12', '交通', '確認交通車資訊', '確認車次、司機、路線、集合點與緊急聯絡', project.owner, project.coOwners, '', '', '未開始', '高', folderUrlFromContext_(context, '02_工作分工與會議紀錄'), '', '待確認', ''], ['T13', '通知', '建立導師行前提醒', '分活動前一週、前一天、當天與返校後', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '04_通知與聯繫資料'), '導師行前提醒', '待確認', ''], ['T14', '執行', '建立學生分組或車次資料', '需注意個資與權限', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '03_表單與回覆資料'), '', '待確認', ''], ['T15', '執行', '活動當日安全確認', '點名、回報、臨時狀況與安全檢核', project.owner, project.coOwners, '', project.activityDate, '未開始', '高', folderUrlFromContext_(context, '02_工作分工與會議紀錄'), '', '待確認', ''], ['T16', '成果', '收集活動照片', '照片需確認授權與是否含個資', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '06_活動照片與照片說明'), '', '待確認', ''], ['T17', '成果', '撰寫照片說明', '補齊時間、地點、活動內容與學習重點', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '06_活動照片與照片說明'), '', '待確認', ''], ['T18', '成果', '整理學生回饋', '去識別化後可供成果報告與 NotebookLM 使用', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '07_成果資料與成果報告'), '', '待確認', ''], ['T19', '成果', '彙整成果資料', '整理照片、說明、回饋、會議紀錄與成果素材', project.owner, project.coOwners, '', '', '未開始', '高', folderUrlFromContext_(context, '07_成果資料與成果報告'), '03_成果檢核表 Sheet', '待確認', ''], ['T20', '核銷', '檢查經費與核銷資料', '收據、採購、核銷資料依校內規範確認', project.owner, project.coOwners, '', project.deadlineDate, '未開始', '高', folderUrlFromContext_(context, '05_經費與採購核銷資料'), '', '待確認', ''], ['T21', '成果', '產生成果報告初稿', 'Gemini 可協助初稿,正式送出需人工確認', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '07_成果資料與成果報告'), '', '待確認', ''], ['T22', '成果', '產生成果簡報大綱', '供第三堂課成果整理與簡報展示銜接', project.owner, project.coOwners, '', '', '未開始', '低', folderUrlFromContext_(context, '08_簡報與成果展示'), '', '待確認', ''], ['T23', '結案', '完成結案紀錄', '整理完成情形、問題與可交接資料', project.owner, project.coOwners, '', project.deadlineDate, '未開始', '高', folderUrlFromContext_(context, '09_檢討與下次改進'), '01_專案記錄 Docs', '待確認', ''], ['T24', '結案', '整理下次辦理建議', '讓下次承辦人可沿用資料與改進事項', project.owner, project.coOwners, '', '', '未開始', '中', folderUrlFromContext_(context, '09_檢討與下次改進'), '', '待確認', ''] ]; const taskPrompt = buildTaskTrackingAiPrompt_(project, context, headers); const taskAi = generateAiTextOrPrompt_( '待辦總表 TSV', taskPrompt, buildPromptTemplateText_('待辦總表 TSV', taskPrompt), { temperature: 0.2, maxOutputTokens: 4096 } ); let tasks = defaultTasks; let taskParseError = ''; if (taskAi.mode === AI_MODES.AI_MODE && taskAi.ok) { try { const aiTasks = parseTsv_(taskAi.text, headers); tasks = aiTasks; } catch (error) { taskParseError = 'Gemini 回傳內容無法解析為待辦總表 TSV,已保留預設待辦列。' + error.message; Logger.log(taskParseError); } } sheet.getRange(2, 1, tasks.length, headers.length).setValues(tasks); setValidationList_(sheet, 2, 9, tasks.length, TASK_STATUS_VALUES); setValidationList_(sheet, 2, 10, tasks.length, TASK_PRIORITY_VALUES); setValidationList_(sheet, 2, 13, tasks.length, YES_NO_REVIEW_VALUES); formatProjectSheet_(sheet, headers.length); sheet.getRange(1, 1).setNote(buildAiSheetNotice_(taskAi, taskParseError)); writeAiGenerationStatusSheet_(ss, 'AI產生狀態', taskAi, taskAi.text, taskParseError); writeSimpleSheet_(ss.insertSheet('WBS 工作分解'), ['WBS 編號', '工作包', '子任務', '產出文件', '對應資料夾', '負責角色', '完成標準'], [ ['1', '啟動與收件', '確認公文與需求', '專案記錄 Docs', folderUrlFromContext_(context, '00_原始公文與附件'), project.owner || '承辦人', '需求、期限、責任已確認'], ['2', '規劃與通知', '公告、通知、表單、名冊', '待辦追蹤表 Sheet', folderUrlFromContext_(context, '04_通知與聯繫資料'), project.owner || '承辦人', '正式發出前完成人工確認'], ['3', '執行與安全', '活動當日流程、安全與回報', '導師行前提醒', folderUrlFromContext_(context, '02_工作分工與會議紀錄'), project.owner || '承辦人', '點名、分工、回報機制清楚'], ['4', '成果與結案', '成果報告、簡報、核銷、檢討', '成果檢核表 Sheet', folderUrlFromContext_(context, '07_成果資料與成果報告'), project.owner || '承辦人', '成果與交接資料足以送出或沿用'] ]); writeSimpleSheet_(ss.insertSheet('里程碑'), ['里程碑名稱', '日期', '說明', '是否已建立 Calendar 提醒', '負責人', '備註'], buildMilestoneRows_(project)); writeSimpleSheet_(ss.insertSheet('責任分工'), ['工作項目', '承辦人', '協辦人', '導師', '行政處室', '家長/學生', '備註'], [ ['公文與需求確認', project.owner || '', project.coOwners || '', '', project.department || '', '', ''], ['通知與表單回收', project.owner || '', project.coOwners || '', '協助轉知與追蹤', '', '依通知回覆', ''], ['活動當日安全與回報', project.owner || '', project.coOwners || '', '點名與安全管理', project.department || '', '配合提醒', ''], ['成果、核銷與結案', project.owner || '', project.coOwners || '', '提供照片或紀錄', project.department || '', '', ''] ]); writeSimpleSheet_(ss.insertSheet('會議與追蹤紀錄'), ['日期', '會議或討論事項', '決議', '待追蹤事項', '負責人', '下次追蹤日期', '備註'], [['', '', '', '', project.owner || '', '', '']]); moveFileToFolder_(ss.getId(), projectFolder); return ss; } function createOutcomeChecklistSheet_(project, projectFolder, context) { const projectName = projectDisplayName_(project); const ss = SpreadsheetApp.create('03_成果檢核表 Sheet - ' + projectName); const sheet = ss.getSheets()[0]; sheet.setName('成果檢核總表'); const headers = ['檢核類別', '檢核項目', '是否完成', '負責人', '截止日期', '佐證文件連結', '存放資料夾', '是否可提供 NotebookLM', '是否可提供 Gemini', '是否含個資', '備註']; sheet.getRange(1, 1).setValue('成果檢核表用於確認活動資料是否足以銜接成果報告、核銷、第三堂課文件整理、NotebookLM 專案知識庫與 Gemini 草稿產出。'); sheet.getRange(2, 1, 1, headers.length).setValues([headers]); const defaultRows = buildOutcomeChecklistRows_(project, context); const outcomePrompt = buildOutcomeChecklistAiPrompt_(project, context, headers); const outcomeAi = generateAiTextOrPrompt_( '成果檢核總表 TSV', outcomePrompt, buildPromptTemplateText_('成果檢核總表 TSV', outcomePrompt), { temperature: 0.2, maxOutputTokens: 4096 } ); let rows = defaultRows; let outcomeParseError = ''; if (outcomeAi.mode === AI_MODES.AI_MODE && outcomeAi.ok) { try { const aiRows = parseTsv_(outcomeAi.text, headers); rows = aiRows; } catch (error) { outcomeParseError = 'Gemini 回傳內容無法解析為成果檢核總表 TSV,已保留預設檢核列。' + error.message; Logger.log(outcomeParseError); } } sheet.getRange(3, 1, rows.length, headers.length).setValues(rows); setValidationList_(sheet, 3, 3, rows.length, OUTCOME_STATUS_VALUES); setValidationList_(sheet, 3, 8, rows.length, YES_NO_REVIEW_VALUES); setValidationList_(sheet, 3, 9, rows.length, YES_NO_REVIEW_VALUES); setValidationList_(sheet, 3, 10, rows.length, YES_NO_REVIEW_VALUES); sheet.getRange(1, 1).setValue(sheet.getRange(1, 1).getValue() + '\n' + buildAiSheetNotice_(outcomeAi, outcomeParseError)); sheet.getRange(1, 1, 1, headers.length).merge().setBackground('#fff7ed').setFontWeight('bold').setWrap(true); formatHeaderRowAt_(sheet, 2, headers.length); sheet.setFrozenRows(2); sheet.autoResizeColumns(1, headers.length); writeAiGenerationStatusSheet_(ss, 'AI產生狀態', outcomeAi, outcomeAi.text, outcomeParseError); writeSimpleSheet_(ss.insertSheet('成果素材清單'), ['素材類型', '素材名稱', '來源', '存放位置', '是否可公開', '是否含個資', '是否可提供 NotebookLM', '是否可提供 Gemini', '備註'], [ ['照片', '', '活動照片', folderUrlFromContext_(context, '06_活動照片與照片說明'), '待確認', '待確認', '待確認', '待確認', ''], ['照片說明', '', '承辦人或導師', folderUrlFromContext_(context, '06_活動照片與照片說明'), '待確認', '否', '是', '是', ''], ['學生回饋', '', '表單或紙本', folderUrlFromContext_(context, '07_成果資料與成果報告'), '否', '是', '去識別化後可', '去識別化後可', ''], ['經費核銷', '', '會計或承辦資料', folderUrlFromContext_(context, '05_經費與採購核銷資料'), '否', '待確認', '否', '否', '依校內規定處理'] ]); writeSimpleSheet_(ss.insertSheet('NotebookLM 知識庫資料'), ['資料名稱', '資料類型', '來源資料夾', '是否已整理', '是否已上傳 NotebookLM', '可詢問的問題', '備註'], [ ['公文與附件', '原始資料', folderUrlFromContext_(context, '00_原始公文與附件'), '未完成', '未完成', '這份公文要求我們完成哪些事項?', '先去識別化'], ['計畫書與核定資料', '計畫資料', folderUrlFromContext_(context, '01_計畫書與核定資料'), '未完成', '未完成', '活動目標與核定條件是什麼?', ''], ['會議與追蹤紀錄', '過程紀錄', folderUrlFromContext_(context, '02_工作分工與會議紀錄'), '未完成', '未完成', '目前有哪些待追蹤事項?', ''], ['照片說明與成果資料', '成果資料', folderUrlFromContext_(context, '07_成果資料與成果報告'), '未完成', '未完成', '成果報告可怎麼整理?', ''], ['結案檢討', '組織記憶', folderUrlFromContext_(context, '09_檢討與下次改進'), '未完成', '未完成', '下次辦理有哪些建議?', ''] ]); writeSimpleSheet_(ss.insertSheet('Gemini 草稿任務'), [ '草稿類型', '輸入資料', '預期輸出', 'AI 模式', '是否已由 Gemini API 產生', 'AI 產生結果摘要', '是否已人工確認', '錯誤訊息', '備註' ], GEMINI_DRAFT_TYPES.map(function(type) { return [ type, '專案記錄、待辦追蹤表、成果檢核表與已整理素材', type + '草稿', outcomeAi.mode, outcomeAi.mode === AI_MODES.AI_MODE && outcomeAi.ok && !outcomeParseError ? '是' : '否', outcomeAi.mode === AI_MODES.AI_MODE && outcomeAi.ok && !outcomeParseError ? 'AI_MODE 已產生初稿,請承辦人確認。' : '目前使用提示詞模板或預設檢核項目。', '未完成', outcomeAi.error || outcomeParseError || '', 'Gemini 僅協助草稿,不做正式決策' ]; })); writeSimpleSheet_(ss.insertSheet('結案檢討'), ['面向', '本次做得好的地方', '本次遇到的問題', '下次建議', '可沿用模板或資料', '備註'], [ ['流程', '', '', '', '專案記錄 Docs、待辦追蹤表 Sheet', ''], ['資料整理', '', '', '', '成果檢核表 Sheet、NotebookLM 資料包', ''], ['通知與溝通', '', '', '', '家長通知單、導師行前提醒', ''], ['風險與安全', '', '', '', '風險檢查表', ''], ['成果與核銷', '', '', '', '成果資料夾、核銷資料夾', ''] ]); moveFileToFolder_(ss.getId(), projectFolder); return ss; } function createCalendarRemindersSafely_(project, result) { try { const calendar = CONFIG.CALENDAR_ID ? CalendarApp.getCalendarById(CONFIG.CALENDAR_ID) : CalendarApp.getDefaultCalendar(); if (!calendar) return CALENDAR_FAILURE_NOTICE; const reminders = buildReminderRows_(project).filter(function(row) { return row.date; }); reminders.forEach(function(row) { calendar.createAllDayEvent(row.title + ' - ' + projectDisplayName_(project), row.date, { description: [ row.description, '專案資料夾:' + (result.projectFolderUrl || result.packageFolderUrl || ''), '專案記錄:' + (result.projectRecordDocUrl || ''), HUMAN_CONFIRMATION_NOTICE ].join('\n') }); }); return reminders.length ? '已建立 ' + reminders.length + ' 筆 Calendar 提醒' : '未找到可建立提醒的日期,請人工確認或補建。'; } catch (error) { Logger.log(CALENDAR_FAILURE_NOTICE + ' ' + error.message); return CALENDAR_FAILURE_NOTICE; } } function sendOwnerNotificationSafely_(project, result) { try { if (!project.ownerEmail) return GMAIL_FAILURE_NOTICE; const subject = '【行政專案已建立】' + projectDisplayName_(project); const body = [ '專案名稱:' + projectDisplayName_(project), '專案資料夾:' + (result.projectFolderUrl || result.packageFolderUrl || ''), '01_專案記錄 Docs:' + (result.projectRecordDocUrl || ''), '02_待辦追蹤表 Sheet:' + (result.taskTrackingSheetUrl || ''), '03_成果檢核表 Sheet:' + (result.outcomeChecklistSheetUrl || ''), '', '重要期限:' + valueOrPending_(project.importantDeadlines || project.deadlineDate), '下一步建議:請先檢查專案基本資料、公文要求、待辦總表、成果檢核總表與 Calendar 提醒是否正確。', '', HUMAN_CONFIRMATION_NOTICE ].join('\n'); MailApp.sendEmail(project.ownerEmail, subject, body); return '已寄出 Gmail 通知給承辦人'; } catch (error) { Logger.log(GMAIL_FAILURE_NOTICE + ' ' + error.message); return GMAIL_FAILURE_NOTICE; } } function createActivityPackageFolder_(activity, type) { const root = getProjectPackagesFolder_(); const name = sanitizeFileName_(activity.activityId + '_' + projectDisplayName_(activity)); return root.createFolder(name); } function createNotebookExportFolder_(activity) { const root = getNotebookExportRootFolder_(); const name = sanitizeFileName_(activity.activityId + '_NotebookLM匯出_' + activity.activityName); return root.createFolder(name); } function createSummaryDoc_(folder, activity, aiText) { const doc = DocumentApp.create(activity.activityId + '_活動建案摘要'); const body = doc.getBody(); appendTitle_(body, '活動建案摘要'); appendSection_(body, '活動資料', activityToText_(activity)); appendSection_(body, '用途', '整理活動建案資料,供後續 WBS、通知、風險檢查與 NotebookLM 資料包使用。'); appendSection_(body, 'AI 草稿或提示詞', aiText || buildTemplateFallback_('請將下列活動資料交給 Gemini,請它檢查資訊缺漏、優先確認事項與行政風險。', activity)); appendChecklist_(body, ['活動日期、地點、費用是否正確', '交通、保險、同意書是否已確認', '是否避免放入不必要個資']); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc; } function createWbsSheet_(folder, activity, aiText) { const ss = SpreadsheetApp.create(activity.activityId + '_WBS任務清單'); const sheet = ss.getSheets()[0]; sheet.setName('WBS任務清單'); sheet.getRange(1, 1, 1, 9).setValues([['階段', '工作包名稱', '具體任務', '負責角色', '建議完成時間', '需要文件或表單', '是否需要提醒', '狀態', '備註']]); WBS_STAGES.forEach(function(stage) { sheet.appendRow([stage, stage + '工作包', '請依活動資料修正', activity.owner || '待指派', '依活動日期倒推', '待確認', '待確認', '模板', activity.activityId]); }); const promptSheet = ss.insertSheet('AI提示詞與草稿'); promptSheet.getRange(1, 1).setValue(PROMPTS.wbs.replace('(貼上活動建案資料)', activityToText_(activity))); promptSheet.getRange(3, 1).setValue(aiText || '尚未呼叫 Gemini。請複製上方提示詞產生 WBS 草稿。'); promptSheet.setColumnWidth(1, 900); promptSheet.getRange('A:A').setWrap(true); formatHeaderRow_(sheet, 9); moveFileToFolder_(ss.getId(), folder); return ss; } function createParentNoticeDoc_(folder, activity, aiText) { const doc = DocumentApp.create(activity.activityId + '_家長通知單'); const body = doc.getBody(); appendTitle_(body, '家長通知單'); appendSection_(body, '活動資料', activityToText_(activity)); appendSection_(body, 'AI 提示詞', PROMPTS.parentNotice.replace('(貼上活動建案資料)', activityToText_(activity))); appendSection_(body, '範本文字或 AI 草稿', aiText || sampleParentNotice_(activity)); appendChecklist_(body, ['日期地點是否正確', '費用是否確認', '交通與保險是否完成', '是否經主管確認', '是否避免不必要個資']); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc; } function createTeacherReminderDoc_(folder, activity, aiText) { const doc = DocumentApp.create(activity.activityId + '_導師行前提醒'); const body = doc.getBody(); appendTitle_(body, '導師行前提醒'); appendSection_(body, '活動資料', activityToText_(activity)); appendSection_(body, 'AI 提示詞', PROMPTS.teacherReminder.replace('(貼上活動建案資料)', activityToText_(activity))); appendSection_(body, '範本文字或 AI 草稿', aiText || sampleTeacherReminder_(activity)); appendChecklist_(body, ['名冊與點名方式是否確認', '交通與安全提醒是否清楚', '回報方式是否清楚', '是否避免公開學生個資']); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc; } function createRiskSheet_(folder, activity, aiText) { const ss = SpreadsheetApp.create(activity.activityId + '_風險檢查表'); const sheet = ss.getSheets()[0]; sheet.setName('風險登錄'); sheet.getRange(1, 1, 1, 9).setValues([['風險類別', '風險內容', '可能造成的問題', '預防方式', '建議新增欄位', '是否需要提醒', '負責人', '狀態', '備註']]); RISK_CATEGORIES.forEach(function(category) { sheet.appendRow([category, '請由 AI 或承辦人補上', '', '', '', '待確認', activity.owner || '', '模板', activity.activityId]); }); const promptSheet = ss.insertSheet('AI提示詞與草稿'); promptSheet.getRange(1, 1).setValue(PROMPTS.riskCheck.replace('(貼上活動建案資料)', activityToText_(activity))); promptSheet.getRange(3, 1).setValue(aiText || '尚未呼叫 Gemini。請複製上方提示詞產生風險檢查草稿。'); promptSheet.setColumnWidth(1, 900); promptSheet.getRange('A:A').setWrap(true); formatHeaderRow_(sheet, 9); moveFileToFolder_(ss.getId(), folder); return ss; } function createNotificationSheet_(folder, activity) { const ss = SpreadsheetApp.create(activity.activityId + '_通知管理表'); const sheet = ss.getSheets()[0]; sheet.setName('通知管理'); sheet.getRange(1, 1, 1, 8).setValues([['通知對象', '通知內容', '建議時間', '通知管道', '是否需回覆', '負責人', '狀態', '備註']]); sheet.getRange(2, 1, 5, 8).setValues([ ['家長', '家長通知單與同意書', '活動前 2-4 週', '紙本/班級群組/校網', '是', activity.owner || '', '待確認', '正式發出前需核定'], ['導師', '行前提醒與名冊確認', '活動前一週', '校內群組/會議', '是', activity.owner || '', '待確認', '確認點名與回報方式'], ['行政處室', '交通、保險、經費與安全事項', '活動前 2-4 週', '會議/公文/Email', '是', activity.owner || '', '待確認', '依校內流程'], ['學生', '攜帶物品與集合資訊', '活動前一天', '導師轉知', '否', '導師', '待確認', '避免公布個資'], ['主管', '核准與風險確認', '通知發出前', '簽核/會議', '是', activity.owner || '', '待確認', '需確認正式版本'] ]); formatHeaderRow_(sheet, 8); moveFileToFolder_(ss.getId(), folder); return ss; } function createNotebookPackageDoc_(folder, activity, files) { const doc = DocumentApp.create(activity.activityId + '_NotebookLM資料包'); const body = doc.getBody(); appendTitle_(body, 'NotebookLM 專案資料包'); appendSection_(body, '使用定位', 'NotebookLM 用來將公文、附件、計畫書、會議紀錄、照片說明、成果資料與成果報告整理成可以詢問的專案知識庫。這份文件是提供 NotebookLM 或其他 AI 工具讀取的乾淨摘要。請先去識別化,不放學生姓名、健康細節、電話或不必要個資。'); appendSection_(body, '活動資料摘要', activityToText_(activity)); body.appendParagraph('資料包檔案連結').setHeading(DocumentApp.ParagraphHeading.HEADING2); Object.keys(files || {}).forEach(function(key) { const file = files[key]; if (file && file.getUrl) { body.appendParagraph(file.getName() + ':' + file.getUrl()); } }); const notebookPrompt = buildNotebookLmPackageAiPrompt_(activity, files); const notebookAi = generateAiTextOrPrompt_( 'NotebookLM 專案脈絡摘要', notebookPrompt, buildPromptTemplateText_('NotebookLM 專案脈絡摘要', notebookPrompt), { temperature: 0.25, maxOutputTokens: 4096 } ); appendSection_( body, buildAiResultSectionTitle_(notebookAi, 'AI 產生之 NotebookLM 專案脈絡摘要', 'NotebookLM 提示詞模板'), formatAiResultForOutput_(notebookAi) ); appendSection_(body, 'Gemini 草稿任務', GEMINI_DRAFT_TYPES.map(function(type) { return '□ ' + type + ':Gemini 可協助草稿,正式內容仍須承辦人確認。'; }).join('\n')); appendSection_(body, '建議查詢問題', '1. 目前最需要先確認哪些行政事項?\n2. 有哪些安全、交通、保險或家長溝通風險?\n3. 承辦人下一週應優先做哪 5 件事?\n4. 成果報告還缺哪些佐證資料?\n5. 下次辦理有哪些可沿用與需調整事項?'); appendSection_(body, '人工確認提醒', HUMAN_CONFIRMATION_NOTICE); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc; } function createWorkflowReviewDoc_(folder, activity, aiText) { const doc = DocumentApp.create(activity.activityId + '_工作流驗收建議'); const body = doc.getBody(); appendTitle_(body, '工作流驗收建議'); appendSection_(body, '文件用途', '檢查本活動資料包是否對應 WBS、里程碑、風險登錄、責任分工、交付成果與單一事實來源。'); appendSection_(body, 'AI 工作流驗收提示詞', PROMPTS.workflowReview.replace('(貼上表單欄位、資料夾架構、文件清單、提醒節點)', buildWorkflowReviewContext_(activity))); appendSection_(body, 'AI 檢查結果或人工檢查區', aiText || '尚未呼叫 Gemini API。請複製上方提示詞到 Gemini 或 ChatGPT,產生檢查結果後貼回此區。'); appendChecklist_(body, [ '專案主資料夾已建立', '標準子資料夾已建立', '01_專案記錄 Docs 已建立', '02_待辦追蹤表 Sheet 已建立', '03_成果檢核表 Sheet 已建立', '文件清單已記錄三個核心文件連結', '活動清單可回查資料包', 'Calendar 提醒如失敗需人工補建', 'Gmail 通知如失敗需人工通知', 'Gemini API 未設定時仍不阻斷基礎資料包', 'NotebookLM 資料包未啟用時仍不阻斷基礎資料包', '正式公告、個資、交通、安全、保險、核銷與成果送出已交由承辦人確認' ]); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc; } function createHandoffDoc_(folder, activity, aiText) { const doc = DocumentApp.create(activity.activityId + '_承辦人交接摘要'); const body = doc.getBody(); appendTitle_(body, '承辦人交接摘要'); appendSection_(body, '活動資料', activityToText_(activity)); appendSection_(body, 'AI 提示詞', PROMPTS.handoff.replace('(貼上活動建案資料)', activityToText_(activity))); appendSection_(body, '交接摘要草稿', aiText || '目前已建立資料包。請承辦人確認日期、地點、費用、交通、保險、同意書、主管核定與成果整理責任。'); appendChecklist_(body, ['待確認事項是否清楚', '資料包連結是否完整', 'AI 草稿是否經人工確認']); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc; } function calculatePlatformStats_(activityList, responseSheet) { const activityCount = Math.max(activityList.getLastRow() - 1, 0); if (!responseSheet) { return { activities: activityCount, responses: 0, generated: 0, pending: 0, latestGeneratedAt: '' }; } initializeFormResponseSheet_(responseSheet); const map = buildHeaderMap_(responseSheet); const lastRow = responseSheet.getLastRow(); let responses = 0; let generated = 0; let pending = 0; let latestGeneratedAt = ''; for (let row = 2; row <= lastRow; row++) { const name = readFirstAvailableCell_(responseSheet, row, map, [ '活動/專案名稱', '活動 / 專案名稱', '活動/專案名稱', '活動名稱', '專案名稱' ]); if (!name) continue; responses++; const status = readCellByHeader_(responseSheet, row, map, '處理狀態'); const generatedAt = readCellByHeader_(responseSheet, row, map, '產生時間'); if (status === '已產生') { generated++; latestGeneratedAt = generatedAt || latestGeneratedAt; } else { pending++; } } return { activities: activityCount, responses: responses, generated: generated, pending: pending, latestGeneratedAt: latestGeneratedAt }; } function buildNextStepSuggestion_(formUrl, stats) { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss || !ss.getSheetByName('平台首頁')) return '請先初始化平台 Sheet。'; if (!formUrl) return '請建立活動建案表單。'; if (!stats.responses) return '請填寫一筆表單測試資料。'; if (stats.pending) return '請批次產生活動資料包。'; if (stats.generated) return '請開啟資料包檢查各文件內容,並討論如何導入實際行政工作流。'; return '請依平台操作流程進行。'; } function readActivityDataSheet_(ss) { const sheet = ss.getSheetByName('活動資料'); if (!sheet) throw new Error('找不到「活動資料」。請先初始化平台 Sheet。'); const values = sheet.getDataRange().getValues(); const map = {}; for (let i = 1; i < values.length; i++) { if (values[i][0]) map[String(values[i][0]).trim()] = values[i][1]; } return normalizeActivity_(map); } function readActivityFromResponseRow_(sheet, row, headerMap) { const map = {}; FORM_FIELDS.forEach(function(header) { map[header] = readCellByHeader_(sheet, row, headerMap, header); }); map['活動/專案名稱'] = readFirstAvailableCell_(sheet, row, headerMap, [ '活動/專案名稱', '活動 / 專案名稱', '活動/專案名稱', '活動名稱', '專案名稱' ]); map['專案類型'] = readFirstAvailableCell_(sheet, row, headerMap, [ '專案類型', '活動類型' ]); map['承辦人 Email'] = readFirstAvailableCell_(sheet, row, headerMap, [ '承辦人 Email', '承辦人Email', 'Email', '電子郵件' ]); map['截止日期'] = readFirstAvailableCell_(sheet, row, headerMap, [ '截止日期', '公文期限', '重要期限' ]); map['承辦人'] = readFirstAvailableCell_(sheet, row, headerMap, [ '承辦人', '承辦人或組別' ]); map['對象年級/班級'] = readFirstAvailableCell_(sheet, row, headerMap, [ '對象年級/班級', '參與對象' ]); return normalizeActivity_(map); } function normalizeActivity_(map) { const projectName = map['活動/專案名稱'] || map['活動名稱'] || ''; const projectType = map['專案類型'] || map['活動類型'] || ''; const owner = map['承辦人'] || map['承辦人或組別'] || ''; return { activityId: map['活動ID'] || '', activityName: String(projectName).replace('(示範案例,可改成實際專案名稱)', '').trim(), activityType: projectType, documentNo: map['公文文號'] || '', documentSource: map['公文來源'] || '', documentUrl: map['公文連結'] || '', attachmentUrl: map['附件連結'] || '', department: map['承辦處室'] || '', owner: owner, ownerEmail: map['承辦人 Email'] || map['承辦人Email'] || '', coOwners: map['協辦人'] || '', activityDate: formatDateValue_(map['活動日期']), deadlineDate: formatDateValue_(map['截止日期'] || map['公文期限']), importantDeadlines: map['重要期限'] || '', location: map['活動地點'] || '', participants: map['對象年級/班級'] || map['參與對象'] || '', estimatedNumber: map['預估人數'] || '', budgetSource: map['經費來源'] || '', needsFormResponse: map['是否需要表單回覆'] || '', needsOutcomeReport: map['是否需要成果報告'] || '', needsReimbursement: map['是否需要核銷'] || '', needsNotebookPackage: map['是否需要 NotebookLM 資料包'] || '', transportation: map['交通方式'] || '', hasFee: map['是否收費'] || '', needsInsurance: map['是否需要保險'] || '', needsConsent: map['是否需要家長同意書'] || '', notes: map['備註'] || '', concern: map['目前最擔心的問題'] || '' }; } function activityToText_(activity) { return [ '活動ID:' + valueOrPending_(activity.activityId), '活動/專案名稱:' + valueOrPending_(activity.activityName), '專案類型:' + valueOrPending_(activity.activityType), '公文文號:' + valueOrPending_(activity.documentNo), '公文來源:' + valueOrPending_(activity.documentSource), '公文連結:' + valueOrPending_(activity.documentUrl), '附件連結:' + valueOrPending_(activity.attachmentUrl), '承辦處室:' + valueOrPending_(activity.department), '承辦人:' + valueOrPending_(activity.owner), '承辦人 Email:' + valueOrPending_(activity.ownerEmail), '協辦人:' + valueOrPending_(activity.coOwners), '活動日期:' + valueOrPending_(activity.activityDate), '截止日期:' + valueOrPending_(activity.deadlineDate), '重要期限:' + valueOrPending_(activity.importantDeadlines), '活動地點:' + valueOrPending_(activity.location), '對象年級/班級:' + valueOrPending_(activity.participants), '預估人數:' + valueOrPending_(activity.estimatedNumber), '經費來源:' + valueOrPending_(activity.budgetSource), '是否需要表單回覆:' + valueOrPending_(activity.needsFormResponse), '是否需要成果報告:' + valueOrPending_(activity.needsOutcomeReport), '是否需要核銷:' + valueOrPending_(activity.needsReimbursement), '是否需要 NotebookLM 資料包:' + valueOrPending_(activity.needsNotebookPackage), '交通方式:' + valueOrPending_(activity.transportation), '是否收費:' + valueOrPending_(activity.hasFee), '是否需要保險:' + valueOrPending_(activity.needsInsurance), '是否需要家長同意書:' + valueOrPending_(activity.needsConsent), '備註:' + valueOrPending_(activity.notes), '目前最擔心的問題:' + valueOrPending_(activity.concern) ].join('\n'); } function callGemini_(prompt) { return callGeminiApi_(prompt); } function callGeminiApi_(prompt, options) { options = options || {}; const apiKey = getGeminiApiKey_(); if (!apiKey) throw new Error('尚未設定 GEMINI_API_KEY'); const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + encodeURIComponent(getGeminiModel_()) + ':generateContent?key=' + encodeURIComponent(apiKey); const payload = { contents: [{ role: 'user', parts: [{ text: String(prompt || '') }] }], generationConfig: { temperature: typeof options.temperature === 'number' ? options.temperature : 0.35, topP: typeof options.topP === 'number' ? options.topP : 0.9, topK: typeof options.topK === 'number' ? options.topK : 40, maxOutputTokens: options.maxOutputTokens || 4096 } }; const response = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true }); const code = response.getResponseCode(); const text = response.getContentText(); let data; try { data = JSON.parse(text); } catch (error) { throw new Error('Gemini API 回應不是有效 JSON:' + text); } if (code < 200 || code >= 300) { const message = data && data.error && data.error.message ? data.error.message : text; throw new Error('Gemini API HTTP ' + code + ':' + message); } if (!data.candidates || !data.candidates.length) { throw new Error('Gemini API 未回傳有效內容:' + text); } const candidate = data.candidates[0]; const parts = candidate.content && candidate.content.parts ? candidate.content.parts : []; const output = parts.map(function(part) { return part.text || ''; }).join('\n').trim(); if (!output) { throw new Error('Gemini API 回傳內容為空白:' + text); } return output; } function generateAiTextOrPrompt_(taskName, prompt, fallbackPromptText, options) { const fallbackText = fallbackPromptText || buildPromptTemplateText_(taskName, prompt); if (!shouldUseGeminiApi_()) { return { mode: AI_MODES.TEMPLATE_WITH_PROMPTS, ok: true, text: fallbackText, error: '' }; } try { return { mode: AI_MODES.AI_MODE, ok: true, text: callGeminiApi_(prompt, options), error: '' }; } catch (error) { Logger.log(taskName + ' Gemini API 呼叫失敗,改用提示詞模板:' + error.message); setSetting_('Gemini API Key 錯誤訊息', error.message, '產生草稿時的最近一次錯誤;系統保留 AI_MODE,單次失敗改用提示詞模板'); return { mode: AI_MODES.TEMPLATE_WITH_PROMPTS, ok: false, text: [ 'AI_MODE 呼叫 Gemini API 失敗,以下改提供提示詞模板。', '任務:' + taskName, '錯誤訊息:' + error.message, '', fallbackText ].join('\n'), error: error.message }; } } function buildPromptTemplateText_(taskName, prompt) { return [ '提示詞模板:' + taskName, '請將以下提示詞貼到 Gemini 或其他 AI 工具,取得草稿後再由承辦人確認。', '', prompt ].join('\n'); } function buildAiResultSectionTitle_(aiResult, aiTitle, templateTitle) { if (!aiResult.ok && aiResult.error) return aiTitle + '(API 失敗,已改用提示詞模板)'; if (aiResult.mode === AI_MODES.AI_MODE && aiResult.ok) return aiTitle; return templateTitle || 'AI 提示詞模板'; } function formatAiResultForOutput_(aiResult) { const label = aiResult.mode === AI_MODES.AI_MODE && aiResult.ok ? '【AI_MODE:Gemini API 已產生內容】' : !aiResult.ok && aiResult.error ? '【AI_MODE:Gemini API 呼叫失敗,以下為提示詞模板】' : '【TEMPLATE_WITH_PROMPTS:提示詞模板】'; return [label, '', aiResult.text].join('\n'); } function getGeminiApiKey_() { const propsKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY') || ''; if (String(propsKey).trim()) { return String(propsKey).trim(); } const sheetKey = getSetting_('Gemini API Key') || getSetting_('GEMINI_API_KEY'); return String(sheetKey || '').trim(); } function hasGeminiApiKey_() { return !!getGeminiApiKey_(); } function getAiMode_() { const mode = getSetting_('AI 模式') || getSetting_('AI_MODE'); return normalizeAiMode_(mode || AI_MODES.TEMPLATE_WITH_PROMPTS); } function normalizeAiMode_(mode) { const value = String(mode || '').trim(); if (value === AI_MODES.AI_MODE || value === 'GEMINI_API') return AI_MODES.AI_MODE; return AI_MODES.TEMPLATE_WITH_PROMPTS; } function getGeminiKeyStatus_() { return getSetting_('Gemini API Key 狀態') || getSetting_('GEMINI_API_KEY 狀態') || (hasGeminiApiKey_() ? '待測試' : '未設定'); } function shouldUseGeminiApi_() { return getAiMode_() === AI_MODES.AI_MODE && getGeminiKeyStatus_() === '已設定'; } function isAiModeReady_() { return shouldUseGeminiApi_(); } function requireAiMode_() { if (!isAiModeReady_()) { alert_( '目前尚未啟用 AI_MODE。' + '\n請先到系統設定填入 Gemini API Key,並執行「檢查 Gemini API Key」。' + '\n\n目前仍可使用提示詞模板模式建立基礎資料包。' ); return false; } return true; } function debugAiMode() { const aiMode = getAiMode_(); const keyStatus = getSetting_('Gemini API Key 狀態') || getGeminiKeyStatus_(); const model = getGeminiModel_(); alert_( 'AI 模式檢查' + '\nAI 模式:' + aiMode + '\nGemini API Key 狀態:' + keyStatus + '\nGemini 模型:' + model + '\nAI 功能可用:' + (isAiModeReady_() ? '是' : '否') ); } function getGeminiModel_() { return getSetting_('Gemini 模型') || getSetting_('MODEL_NAME') || CONFIG.MODEL_NAME || 'gemini-1.5-flash'; } function getSetting_(key) { return getSystemSettingValue_(key); } function setSetting_(key, value, note) { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) return; const sheet = getOrCreateSheet_(ss, '系統設定'); upsertKeyValue_(sheet, key, value, note || ''); } function setAiModeAndKeyStatus_(aiMode, keyStatus, checkedAt, errorMessage) { const normalizedMode = normalizeAiMode_(aiMode); setSetting_('AI 模式', normalizedMode, 'TEMPLATE_WITH_PROMPTS / AI_MODE'); setSetting_('AI_MODE', normalizedMode, '相容舊欄位:TEMPLATE_WITH_PROMPTS / AI_MODE'); setSetting_('Gemini API Key 狀態', keyStatus, '已設定 / 未設定 / 測試失敗'); setSetting_('GEMINI_API_KEY 狀態', keyStatus, '相容舊欄位,不顯示完整 API Key'); setSetting_('Gemini API Key 測試時間', checkedAt || nowText_(), '最近一次執行檢查 Gemini API Key 的時間'); setSetting_('Gemini API Key 錯誤訊息', errorMessage || '', '測試失敗時記錄錯誤內容'); setSetting_('Gemini API Key 顯示', maskApiKey_(getGeminiApiKey_()), '僅遮罩顯示,不寫入完整 API Key'); } function updateAiModeSurfaces_() { try { updatePlatformDashboard(); } catch (error) { Logger.log('更新平台首頁失敗:' + error.message); } try { createOrUpdateInstructionSheet(SpreadsheetApp.getActiveSpreadsheet()); } catch (error) { Logger.log('更新操作說明失敗:' + error.message); } } function testGeminiApiKey_(apiKey) { if (!apiKey) throw new Error('Gemini API Key 未設定'); callGeminiApi_('請只回覆 OK。', { temperature: 0, maxOutputTokens: 32 }); return true; } function maskApiKey_(apiKey) { const value = String(apiKey || '').trim(); if (!value) return '未設定'; if (value.length <= 8) return '已設定'; return value.slice(0, 4) + '…' + value.slice(-4); } function getSelectedActivityContext_() { const ss = getPlatformSpreadsheetOrThrow_(); const sheet = ss.getActiveSheet(); const range = sheet.getActiveRange(); if (!range || range.getRow() < 2) { alert_('請先在活動清單或表單回應分頁選取一筆活動資料列。'); return null; } const row = range.getRow(); const headerMap = buildHeaderMap_(sheet); let activity = null; if (sheet.getName() === '活動清單') { activity = readActivityFromActivityListRow_(sheet, row, headerMap); } else if (looksLikeFormResponseSheet_(sheet)) { activity = readActivityFromResponseRow_(sheet, row, headerMap); } else { alert_('請先在「活動清單」或表單回應分頁選取一筆活動資料。'); return null; } const context = { sheetName: sheet.getName(), row: row, activity: activity, activityId: readFirstAvailableCell_(sheet, row, headerMap, ['活動ID', '活動 ID']), projectFolderUrl: readFirstAvailableCell_(sheet, row, headerMap, ['專案資料夾連結', '資料包資料夾連結', '活動資料夾連結']), packageFolderUrl: readFirstAvailableCell_(sheet, row, headerMap, ['資料包資料夾連結', '專案資料夾連結', '活動資料夾連結']), projectRecordDocUrl: readFirstAvailableCell_(sheet, row, headerMap, ['專案記錄連結', '專案記錄 Docs']), taskTrackingSheetUrl: readFirstAvailableCell_(sheet, row, headerMap, ['待辦追蹤表連結', '待辦追蹤表 Sheet']), outcomeChecklistSheetUrl: readFirstAvailableCell_(sheet, row, headerMap, ['成果檢核表連結', '成果檢核表 Sheet']), riskSheetUrl: readFirstAvailableCell_(sheet, row, headerMap, ['風險檢查表連結']), notebookPackageUrl: readFirstAvailableCell_(sheet, row, headerMap, ['NotebookLM資料包連結', 'NotebookLM 資料包連結']) }; if (!context.packageFolderUrl && context.projectFolderUrl) { context.packageFolderUrl = context.projectFolderUrl; } if (!context.activity.activityId && context.activityId) { context.activity.activityId = context.activityId; } return context; } function readActivityFromActivityListRow_(sheet, row, headerMap) { const map = {}; ACTIVITY_LIST_HEADERS.forEach(function(header) { map[header] = readCellByHeader_(sheet, row, headerMap, header); }); return normalizeActivity_(map); } function requireProjectPackageContext_(context) { if (!context.packageFolderUrl || !context.projectRecordDocUrl || !context.taskTrackingSheetUrl || !context.outcomeChecklistSheetUrl) { throw new Error('請先產生行政專案資料包,再執行 AI 輔助功能。'); } } function buildAiMenuPrompt_(taskName, activity, context, instructions) { return [ '你是協助學校行政人員整理活動專案資料的助理。', '任務:' + taskName, '', '請根據活動資料產出可供承辦人檢查的草稿或建議。', '所有內容都必須保留人工確認提醒,不可宣稱可直接送出或取代正式程序。', '', instructions.join('\n'), '', '活動資料:', activityToText_(activity), '', '既有資料包連結:', '專案資料夾:' + (context.packageFolderUrl || context.projectFolderUrl || ''), '專案記錄 Docs:' + (context.projectRecordDocUrl || ''), '待辦追蹤表 Sheet:' + (context.taskTrackingSheetUrl || ''), '成果檢核表 Sheet:' + (context.outcomeChecklistSheetUrl || '') ].join('\n'); } function buildProjectRecordAiPrompt_(activity, context) { return [ '你是學校行政活動專案管理助理。請根據活動資料產出「專案記錄 Docs」可直接貼入的草稿。', '請使用繁體中文,內容務實、具體,不使用空泛 AI 宣傳語。', '請包含以下段落:', '1. 專案摘要', '2. 行政依據與需確認資料', '3. 重要期程與期限風險', '4. 分工與溝通重點', '5. 成果資料與結案需求', '6. 承辦人必須人工確認事項', '', '不得宣稱內容可直接公告、送出、核銷或取代正式程序。', '', '活動資料:', activityToText_(activity), '', '標準資料夾:', buildFolderIndexText_(context) ].join('\n'); } function buildTaskTrackingAiPrompt_(activity, context, headers) { return [ '你是學校行政活動專案管理助理。請根據活動資料產生「待辦總表」資料列。', '只輸出 TSV,以 tab 分隔欄位,不要 Markdown 表格,不要加說明文字。', '第一列可以是欄名,也可以直接輸出資料列。', '每列必須剛好包含以下 ' + headers.length + ' 個欄位:', headers.join('\t'), '', '請至少產生 12 筆任務,涵蓋啟動、規劃、通知、表單、名冊、安全、執行、成果、核銷、結案。', '任務狀態限用:未開始、進行中、已完成、待確認。', '優先程度限用:高、中、低。', '是否已提醒限用:是、否、待確認。', '不可宣稱正式程序已完成;需要人工確認者請標示待確認。', '', '活動資料:', activityToText_(activity), '', '標準資料夾:', buildFolderIndexText_(context) ].join('\n'); } function buildOutcomeChecklistAiPrompt_(activity, context, headers) { return [ '你是學校行政活動專案管理助理。請根據活動資料產生「成果檢核總表」資料列。', '只輸出 TSV,以 tab 分隔欄位,不要 Markdown 表格,不要加說明文字。', '第一列可以是欄名,也可以直接輸出資料列。', '每列必須剛好包含以下 ' + headers.length + ' 個欄位:', headers.join('\t'), '', '請至少產生 12 筆檢核項目,涵蓋活動前、活動中、活動後、成果報告、核銷、NotebookLM、Gemini 草稿任務與交接。', '是否完成限用:未完成、進行中、已完成、待確認。', '是否可提供 NotebookLM、是否可提供 Gemini、是否含個資限用:是、否、待確認、去識別化後可。', '不得要求收集不必要個資;涉及學生資料、照片、健康或聯絡資訊時請標示需人工確認。', '', '活動資料:', activityToText_(activity), '', '標準資料夾:', buildFolderIndexText_(context) ].join('\n'); } function buildNotebookLmPackageAiPrompt_(activity, files) { const fileLines = Object.keys(files || {}).map(function(key) { const file = files[key]; return file && file.getUrl ? file.getName() + ':' + file.getUrl() : ''; }).filter(String); return [ '你是協助學校行政人員整理 NotebookLM 專案資料包的助理。', '請根據活動資料與已建立文件,產生可放入 NotebookLM 資料包的專案脈絡摘要。', '請使用繁體中文,語氣務實,不要使用空泛 AI 宣傳語。', '請至少包含:', '1. 專案摘要', '2. 建議匯入 NotebookLM 的資料清單', '3. 可詢問 NotebookLM 的問題', '4. 成果報告可用素材整理建議', '5. 需要人工確認的資料', '', '請提醒使用者先去識別化,不放入學生姓名、電話、健康資料或不必要個資。', '', '活動資料:', activityToText_(activity), '', '已建立文件:', fileLines.length ? fileLines.join('\n') : '尚未提供文件連結。' ].join('\n'); } function buildFolderIndexText_(context) { if (!context || !context.subfolders) return '尚未提供資料夾索引。'; return PROJECT_SUBFOLDER_NAMES.map(function(name) { return name + ':' + (context.subfolders[name] ? context.subfolders[name].getUrl() : ''); }).join('\n'); } function parseTsv_(text, expectedHeaders) { const rows = parseTsvRows_(text, expectedHeaders); if (!rows.length) { throw new Error('TSV 解析後沒有任何資料列'); } return rows; } function parseTsvRows_(text, headers) { const expectedColumns = headers.length; const headerKeys = headers.map(normalizeHeader_); const cleaned = String(text || '') .replace(/```(?:tsv|csv|text)?/gi, '') .replace(/```/g, '') .trim(); if (!cleaned) return []; return cleaned.split(/\r?\n/).map(function(line) { return String(line || '').trim(); }).filter(function(line) { return line && !/^[-|\s]+$/.test(line) && line.indexOf('【') !== 0; }).map(function(line) { if (line.indexOf('\t') !== -1) return line.split('\t'); if (line.charAt(0) === '|' || line.indexOf('|') !== -1) { return line.replace(/^\|/, '').replace(/\|$/, '').split('|'); } if (line.indexOf('|') !== -1) return line.split('|'); return []; }).map(function(row) { return row.map(function(cell) { return String(cell || '').trim(); }); }).filter(function(row) { if (!row.length) return false; const normalized = row.map(normalizeHeader_); const sameAsHeader = normalized.slice(0, expectedColumns).join('|') === headerKeys.join('|'); if (sameAsHeader) return false; const usefulCells = row.filter(function(cell) { return cell; }).length; return usefulCells >= Math.min(3, expectedColumns); }).map(function(row) { const normalizedRow = row.slice(0, expectedColumns); while (normalizedRow.length < expectedColumns) normalizedRow.push(''); return normalizedRow; }); } function buildAiSheetNotice_(aiResult, parseError) { if (aiResult.mode === AI_MODES.AI_MODE && aiResult.ok && !parseError) { return 'AI_MODE 已產生初稿,請承辦人確認資料適用性。'; } if (!aiResult.ok && aiResult.error) { return 'AI_MODE 呼叫失敗,已改用提示詞模板。錯誤訊息:' + aiResult.error; } if (parseError) { return 'AI_MODE 已呼叫 Gemini API,但回傳格式無法解析,已改用預設項目。' + parseError; } return '以下為提示詞模板,請複製到 Gemini 或 ChatGPT 後產生草稿。'; } function writeAiGenerationStatusSheet_(ss, baseName, aiResult, rawText, parseError) { const sheet = insertUniqueSheet_(ss, baseName); const rows = [ ['產生模式', aiResult.mode], ['Gemini API 是否成功', aiResult.ok ? '是' : '否'], ['錯誤訊息', aiResult.error || parseError || ''], ['產生時間', nowText_()], ['承辦人確認', HUMAN_CONFIRMATION_NOTICE], ['', ''], ['原始內容或提示詞', rawText || aiResult.text || ''] ]; sheet.getRange(1, 1, rows.length, 2).setValues(rows); sheet.getRange(1, 1, rows.length, 2).setWrap(true); sheet.setColumnWidth(1, 180); sheet.setColumnWidth(2, 900); } function callGeminiWithPromptFallback_(prompt) { const result = generateAiTextOrPrompt_('AI 選單任務', prompt, buildPromptTemplateText_('AI 選單任務', prompt)); return formatAiResultForOutput_(result); } function withHumanConfirmationNotice_(content) { return [ '以下內容僅供草稿或提示詞參考。正式公告、經費核銷、公文回覆、成果送出、交通安全、個資處理與校內程序,仍須由承辦人或權責單位確認。', '', content ].join('\n'); } function appendAiSectionToDocByUrl_(url, sectionTitle, content) { const doc = DocumentApp.openById(extractIdFromUrl_(url)); const body = doc.getBody(); body.appendPageBreak(); appendSection_(body, sectionTitle + '|' + nowText_(), withHumanConfirmationNotice_(content)); doc.saveAndClose(); return doc.getUrl(); } function appendAiTextSheetByUrl_(url, baseSheetName, title, content) { const ss = SpreadsheetApp.openById(extractIdFromUrl_(url)); const sheet = insertUniqueSheet_(ss, baseSheetName); const rows = String(withHumanConfirmationNotice_(content)).split('\n').map(function(line) { return [line]; }); sheet.getRange(1, 1).setValue(title + '|' + nowText_()); sheet.getRange(2, 1, rows.length, 1).setValues(rows); sheet.getRange(1, 1).setFontWeight('bold').setBackground('#fff7ed').setWrap(true); sheet.setColumnWidth(1, 900); sheet.getRange(2, 1, rows.length, 1).setWrap(true); return ss.getUrl(); } function createAiDocInProjectSubfolder_(context, subfolderName, fileName, title, content) { const projectFolder = DriveApp.getFolderById(extractIdFromUrl_(context.packageFolderUrl || context.projectFolderUrl)); const folder = getOrCreateSubFolder_(projectFolder, subfolderName); const doc = DocumentApp.create(sanitizeFileName_(fileName)); const body = doc.getBody(); appendTitle_(body, title); appendSection_(body, 'AI 建議區|' + nowText_(), withHumanConfirmationNotice_(content)); doc.saveAndClose(); moveFileToFolder_(doc.getId(), folder); return doc.getUrl(); } function insertUniqueSheet_(ss, baseName) { return ss.insertSheet(getUniqueSheetName_(ss, baseName)); } function extractIdFromUrl_(url) { const text = String(url || ''); const match = text.match(/[-\w]{25,}/); if (!match) throw new Error('無法從連結解析檔案 ID:' + text); return match[0]; } function getPlatformSpreadsheetOrThrow_() { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) throw new Error('請從已綁定腳本的平台 Sheet 執行。'); if (!ss.getSheetByName('平台首頁')) { throw new Error('尚未初始化平台 Sheet。請先執行「初始化平台 Sheet」。'); } return ss; } function getFormResponseSheet_(ss) { const props = PropertiesService.getScriptProperties(); const savedName = props.getProperty('FORM_RESPONSE_SHEET_NAME'); if (savedName) { const savedSheet = ss.getSheetByName(savedName); if (savedSheet && looksLikeFormResponseSheet_(savedSheet)) { return savedSheet; } } const candidateNames = [ 'Form_Responses', '表單回覆', '表單回覆 1', 'Form Responses 1', '表單回應', '表單回應 1' ]; for (let i = 0; i < candidateNames.length; i++) { const sheet = ss.getSheetByName(candidateNames[i]); if (sheet && looksLikeFormResponseSheet_(sheet)) { props.setProperty('FORM_RESPONSE_SHEET_NAME', sheet.getName()); return sheet; } } const sheets = ss.getSheets(); for (let j = 0; j < sheets.length; j++) { const sheet = sheets[j]; if (looksLikeFormResponseSheet_(sheet)) { props.setProperty('FORM_RESPONSE_SHEET_NAME', sheet.getName()); return sheet; } } throw new Error('找不到表單回應分頁。請確認試算表中是否有 Form_Responses、表單回覆、表單回覆 1、表單回應或 Google Form 產生的回應分頁,且第一列包含「時間戳記」、「專案類型」、「活動/專案名稱」等欄位。'); } function findFormResponseSheetOrNull_(ss) { try { return getFormResponseSheet_(ss); } catch (error) { Logger.log(error.message); return null; } } function getPlatformRootFolder_() { const props = PropertiesService.getScriptProperties(); const existingId = props.getProperty('PLATFORM_ROOT_FOLDER_ID') || getSystemSettingValue_('平台根資料夾 ID'); if (existingId) { try { const folder = DriveApp.getFolderById(existingId); props.setProperty('PLATFORM_ROOT_FOLDER_ID', folder.getId()); props.setProperty('PLATFORM_ROOT_FOLDER_URL', folder.getUrl()); return folder; } catch (error) { Logger.log('既有平台資料夾不存在,重新建立。'); } } const root = CONFIG.ROOT_FOLDER_ID ? DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID) : DriveApp.getRootFolder(); const folder = root.createFolder(PLATFORM_NAME + '_' + Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'yyyyMMdd_HHmm')); props.setProperty('PLATFORM_ROOT_FOLDER_ID', folder.getId()); props.setProperty('PLATFORM_ROOT_FOLDER_URL', folder.getUrl()); const ss = SpreadsheetApp.getActiveSpreadsheet(); if (ss && ss.getSheetByName('系統設定')) { upsertKeyValue_(ss.getSheetByName('系統設定'), '平台根資料夾 ID', folder.getId(), '平台資料包根資料夾'); upsertKeyValue_(ss.getSheetByName('系統設定'), '平台根資料夾連結', folder.getUrl(), '平台資料包根資料夾'); } return folder; } function getProjectPackagesFolder_() { const folder = getConfiguredFolder_('PROJECT_PACKAGES_FOLDER_ID', '活動專案資料包資料夾 ID'); if (folder) return folder; const root = getPlatformRootFolder_(); const packages = getOrCreateSubFolder_(root, PLATFORM_SUBFOLDER_NAMES.PACKAGES); const ss = SpreadsheetApp.getActiveSpreadsheet(); if (ss && ss.getSheetByName('系統設定')) { upsertKeyValue_(ss.getSheetByName('系統設定'), '活動專案資料包資料夾 ID', packages.getId(), PLATFORM_SUBFOLDER_NAMES.PACKAGES); } PropertiesService.getScriptProperties().setProperty('PROJECT_PACKAGES_FOLDER_ID', packages.getId()); return packages; } function getNotebookExportRootFolder_() { const folder = getConfiguredFolder_('NOTEBOOKLM_EXPORT_FOLDER_ID', 'NotebookLM匯出資料夾 ID'); if (folder) return folder; const root = getPlatformRootFolder_(); const notebook = getOrCreateSubFolder_(root, PLATFORM_SUBFOLDER_NAMES.NOTEBOOK); const ss = SpreadsheetApp.getActiveSpreadsheet(); if (ss && ss.getSheetByName('系統設定')) { upsertKeyValue_(ss.getSheetByName('系統設定'), 'NotebookLM匯出資料夾 ID', notebook.getId(), PLATFORM_SUBFOLDER_NAMES.NOTEBOOK); } PropertiesService.getScriptProperties().setProperty('NOTEBOOKLM_EXPORT_FOLDER_ID', notebook.getId()); return notebook; } function getConfiguredFolder_(propertyKey, settingKey) { const props = PropertiesService.getScriptProperties(); const folderId = props.getProperty(propertyKey) || getSystemSettingValue_(settingKey); if (!folderId) return null; try { const folder = DriveApp.getFolderById(folderId); props.setProperty(propertyKey, folder.getId()); return folder; } catch (error) { Logger.log('找不到設定資料夾 ' + settingKey + ':' + error.message); return null; } } function getFolderUrlById_(folderId) { if (!folderId) return ''; try { return DriveApp.getFolderById(folderId).getUrl(); } catch (error) { return ''; } } function getSystemSettingValue_(key) { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) return ''; const sheet = ss.getSheetByName('系統設定'); if (!sheet || sheet.getLastRow() < 1) return ''; const values = sheet.getRange(1, 1, sheet.getLastRow(), Math.min(sheet.getLastColumn(), 2)).getValues(); for (let i = 0; i < values.length; i++) { if (String(values[i][0] || '').trim() === key) { return values[i][1] || ''; } } return ''; } function resolveFormResponseSheet_(ss, existingSheetIds) { let responseSheet = null; ss.getSheets().forEach(function(sheet) { if (existingSheetIds.indexOf(sheet.getSheetId()) === -1) { responseSheet = sheet; } }); if (responseSheet) { PropertiesService.getScriptProperties().setProperty('FORM_RESPONSE_SHEET_NAME', responseSheet.getName()); return responseSheet; } return getFormResponseSheet_(ss); } function writeFormLinksToHomeAndInstructions_(ss, form) { const props = PropertiesService.getScriptProperties(); props.setProperty('INTAKE_FORM_ID', form.getId()); props.setProperty('INTAKE_FORM_PUBLISHED_URL', form.getPublishedUrl()); props.setProperty('INTAKE_FORM_EDIT_URL', form.getEditUrl()); const settings = getOrCreateSheet_(ss, '系統設定'); upsertKeyValue_(settings, '公文專案啟動表/活動建案表 ID', form.getId(), '目前使用的表單'); upsertKeyValue_(settings, '公文專案啟動表/活動建案表填答連結', form.getPublishedUrl(), '給承辦人或學員填寫'); upsertKeyValue_(settings, '公文專案啟動表/活動建案表編輯連結', form.getEditUrl(), '講師或管理者調整欄位'); const home = getOrCreateSheet_(ss, '平台首頁'); home.getRange('D1:E1').setValues([['表單連結', '網址']]); home.getRange('D2:E3').setValues([ ['公文專案啟動表/活動建案表填答連結', form.getPublishedUrl()], ['公文專案啟動表/活動建案表編輯連結', form.getEditUrl()] ]); formatHeaderRow_(home, Math.max(home.getLastColumn(), 5)); const instructions = getOrCreateSheet_(ss, '操作說明'); instructions.getRange('D1:E1').setValues([['固定連結區', '網址']]); instructions.getRange('D2:E4').setValues([ ['公文專案啟動表/活動建案表填答連結', form.getPublishedUrl()], ['公文專案啟動表/活動建案表編輯連結', form.getEditUrl()], ['最後更新時間', nowText_()] ]); instructions.autoResizeColumns(1, 5); } function buildWorkflowReviewContext_(activity) { return [ '活動資料:', activityToText_(activity), '', '平台工作表:', PLATFORM_SHEETS.join('、'), '', '活動清單欄位:', ACTIVITY_LIST_HEADERS.join('、'), '', '資料包基礎必備產物:', '專案主資料夾、標準子資料夾、01_專案記錄 Docs、02_待辦追蹤表 Sheet、03_成果檢核表 Sheet、Calendar 期限提醒、Gmail 承辦通知', '', '進階輔助產物:', '活動建案摘要、WBS任務清單、家長通知單、導師行前提醒、風險檢查表、通知管理表、NotebookLM資料包、承辦人交接摘要、工作流驗收建議、Gemini 草稿任務', '', '提醒節點:', '公文期限提醒、表單或同意書回收截止提醒、保險名冊確認提醒、行前通知提醒、活動日期提醒、成果資料回收提醒、核銷或結案提醒' ].join('\n'); } function appendDocTable_(body, title, rows) { body.appendParagraph(title).setHeading(DocumentApp.ParagraphHeading.HEADING2); const tableRows = rows.map(function(row) { return row.map(function(cell) { return valueOrPending_(cell); }); }); body.appendTable(tableRows); } function writeSimpleSheet_(sheet, headers, rows) { sheet.getRange(1, 1, 1, headers.length).setValues([headers]); if (rows && rows.length) { sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); } formatProjectSheet_(sheet, headers.length); } function formatProjectSheet_(sheet, columns) { formatHeaderRow_(sheet, columns); sheet.setFrozenRows(1); sheet.autoResizeColumns(1, columns); sheet.getRange(1, 1, Math.max(sheet.getLastRow(), 1), columns).setWrap(true); } function formatHeaderRowAt_(sheet, row, columns) { sheet.getRange(row, 1, 1, columns).setFontWeight('bold').setBackground('#e8f0ff').setFontColor('#1e3a8a'); } function setValidationList_(sheet, startRow, column, rowCount, values) { if (!rowCount) return; const rule = SpreadsheetApp.newDataValidation().requireValueInList(values, true).build(); sheet.getRange(startRow, column, rowCount, 1).setDataValidation(rule); } function folderUrlFromContext_(context, folderName) { if (!context || !context.subfolders || !context.subfolders[folderName]) return ''; return context.subfolders[folderName].getUrl(); } function projectDisplayName_(project) { return sanitizeFileName_(String(project.activityName || project.projectName || '未命名專案').replace('(示範案例,可改成實際專案名稱)', '').trim() || '未命名專案'); } function buildMilestoneRows_(project) { return [ ['公文收文日', '', '收到公文或活動需求', '待確認', project.owner || '', ''], ['專案啟動日', nowText_(), '建立行政專案資料包', '待確認', project.owner || '', ''], ['表單或同意書回收截止日', project.deadlineDate || '', '回收表單、同意書或名冊', '待確認', project.owner || '', ''], ['保險名冊確認日', formatDateForSheet_(offsetDate_(parseProjectDate_(project.activityDate), -7)), '活動前確認保險名冊', '待確認', project.owner || '', ''], ['行前通知日', formatDateForSheet_(offsetDate_(parseProjectDate_(project.activityDate), -3)), '導師與學生行前提醒', '待確認', project.owner || '', ''], ['活動日期', project.activityDate || '', '活動執行日', '待確認', project.owner || '', ''], ['成果資料回收日', formatDateForSheet_(offsetDate_(parseProjectDate_(project.activityDate), 7)), '照片、紀錄與成果素材回收', '待確認', project.owner || '', ''], ['核銷或結案日期', project.deadlineDate || formatDateForSheet_(offsetDate_(parseProjectDate_(project.activityDate), 14)), '經費核銷、成果送出或公文回覆', '待確認', project.owner || '', ''] ]; } function buildOutcomeChecklistRows_(project, context) { const owner = project.owner || ''; const before = [ ['活動前/專案前', '原始公文已存放', '00_原始公文與附件'], ['活動前/專案前', '附件已存放', '00_原始公文與附件'], ['活動前/專案前', '計畫書或核定資料已確認', '01_計畫書與核定資料'], ['活動前/專案前', '校內公告已完成', '04_通知與聯繫資料'], ['活動前/專案前', '家長通知單已完成', '04_通知與聯繫資料'], ['活動前/專案前', '表單題目已建立', '03_表單與回覆資料'], ['活動前/專案前', '家長同意書或表單資料已回收', '03_表單與回覆資料'], ['活動前/專案前', '參加學生名冊已確認', '03_表單與回覆資料'], ['活動前/專案前', '保險資料已確認', '05_經費與採購核銷資料'], ['活動前/專案前', '交通安排已確認', '02_工作分工與會議紀錄'], ['活動前/專案前', '安全提醒已完成', '04_通知與聯繫資料'] ]; const during = [ ['活動中/執行中', '活動照片已收集', '06_活動照片與照片說明'], ['活動中/執行中', '照片說明已撰寫', '06_活動照片與照片說明'], ['活動中/執行中', '學生活動紀錄已收集', '07_成果資料與成果報告'], ['活動中/執行中', '會議或執行紀錄已更新', '02_工作分工與會議紀錄'], ['活動中/執行中', '特殊事件已記錄', '02_工作分工與會議紀錄'], ['活動中/執行中', '臨時狀況已回報', '02_工作分工與會議紀錄'] ]; const after = [ ['活動後/結案前', '學生回饋已整理', '07_成果資料與成果報告'], ['活動後/結案前', '教師觀察紀錄已整理', '07_成果資料與成果報告'], ['活動後/結案前', '活動成果摘要已完成', '07_成果資料與成果報告'], ['活動後/結案前', '成果照片已整理', '06_活動照片與照片說明'], ['活動後/結案前', '經費與核銷資料已整理', '05_經費與採購核銷資料'], ['活動後/結案前', '成果報告初稿已完成', '07_成果資料與成果報告'], ['活動後/結案前', '成果簡報大綱已完成', '08_簡報與成果展示'], ['活動後/結案前', '結案檢討已完成', '09_檢討與下次改進'], ['活動後/結案前', '下次辦理建議已整理', '09_檢討與下次改進'], ['活動後/結案前', '可交接資料已整理', '99_系統產生文件'] ]; return before.concat(during, after).map(function(item) { return [item[0], item[1], '未完成', owner, project.deadlineDate || '', '', folderUrlFromContext_(context, item[2]), '待確認', '待確認', '待確認', '']; }); } function buildReminderRows_(project) { const activityDate = parseProjectDate_(project.activityDate); const deadlineDate = parseProjectDate_(project.deadlineDate); return [ { title: '公文期限提醒', date: deadlineDate, description: '確認公文要求、回覆或送出期限。' }, { title: '表單或同意書回收截止提醒', date: deadlineDate, description: '確認表單、同意書、回覆資料與未回覆追蹤。' }, { title: '保險名冊確認提醒', date: offsetDate_(activityDate, -7), description: '確認保險名冊、參加學生與權限控管。' }, { title: '行前通知提醒', date: offsetDate_(activityDate, -3), description: '確認導師、學生與家長行前通知。' }, { title: '活動日期提醒', date: activityDate, description: '活動當日安全、點名與回報。' }, { title: '成果資料回收提醒', date: offsetDate_(activityDate, 7), description: '收集照片、照片說明、回饋與成果素材。' }, { title: '核銷或結案提醒', date: deadlineDate || offsetDate_(activityDate, 14), description: '確認經費核銷、成果報告、公文回覆或結案紀錄。' } ]; } function parseProjectDate_(value) { if (!value) return null; if (Object.prototype.toString.call(value) === '[object Date]' && !isNaN(value.getTime())) return new Date(value.getTime()); const normalized = String(value).trim().replace(/\./g, '/').replace(/-/g, '/'); const date = new Date(normalized); return isNaN(date.getTime()) ? null : date; } function offsetDate_(date, offsetDays) { if (!date) return null; const copy = new Date(date.getTime()); copy.setDate(copy.getDate() + offsetDays); return copy; } function formatDateForSheet_(date) { if (!date) return ''; return Utilities.formatDate(date, CONFIG.TIMEZONE, 'yyyy/MM/dd'); } function addYesNoItem_(form, title) { form.addMultipleChoiceItem().setTitle(title).setChoiceValues(['是', '否', '不確定']); } function ensureHeader_(sheet, headers) { const currentLastColumn = Math.max(sheet.getLastColumn(), 1); const current = sheet.getRange(1, 1, 1, currentLastColumn).getValues()[0].map(String); if (!current.some(function(value) { return value.trim(); })) { sheet.getRange(1, 1, 1, headers.length).setValues([headers]); return; } const normalizedCurrent = current.map(function(value) { return normalizeHeader_(value); }); headers.forEach(function(header) { const normalizedHeader = normalizeHeader_(header); if (normalizedCurrent.indexOf(normalizedHeader) === -1) { const nextColumn = sheet.getLastColumn() + 1; sheet.getRange(1, nextColumn).setValue(header); current.push(header); normalizedCurrent.push(normalizeHeader_(header)); } }); } function upsertKeyValue_(sheet, key, value, note) { const values = getFirstColumnValues_(sheet); const index = values.indexOf(key); if (index === -1) { sheet.appendRow([key, value, note || '']); } else { sheet.getRange(index + 1, 2).setValue(value); if (note !== undefined) sheet.getRange(index + 1, 3).setValue(note); } } function buildHeaderMap_(sheet) { const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; const map = {}; headers.forEach(function(header, index) { if (!header) return; const raw = String(header).trim(); const normalized = normalizeHeader_(raw); map[raw] = index + 1; map[normalized] = index + 1; }); return map; } function readCellByHeader_(sheet, row, headerMap, header) { const column = headerMap[header] || headerMap[normalizeHeader_(header)]; return column ? sheet.getRange(row, column).getValue() : ''; } function writeCellByHeader_(sheet, row, headerMap, header, value) { const column = headerMap[header] || headerMap[normalizeHeader_(header)]; if (column) sheet.getRange(row, column).setValue(value); } function readFirstAvailableCell_(sheet, row, headerMap, headers) { for (let i = 0; i < headers.length; i++) { const value = readCellByHeader_(sheet, row, headerMap, headers[i]); if (value !== '' && value !== null && value !== undefined) { return value; } } return ''; } function normalizeHeader_(value) { return String(value || '') .trim() .replace(/ /g, '') .replace(/[\u200B-\u200D\uFEFF]/g, '') .replace(/\s+/g, '') .replace(///g, '/') .replace(/﹨/g, '/') .replace(/\\/g, '/'); } function looksLikeFormResponseSheet_(sheet) { if (!sheet || sheet.getLastRow() < 1 || sheet.getLastColumn() < 2) return false; const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] .map(function(value) { return normalizeHeader_(value); }); const hasTimestamp = headers.indexOf(normalizeHeader_('時間戳記')) !== -1 || headers.indexOf(normalizeHeader_('Timestamp')) !== -1; const hasProjectType = headers.indexOf(normalizeHeader_('專案類型')) !== -1 || headers.indexOf(normalizeHeader_('活動類型')) !== -1; const hasProjectName = headers.indexOf(normalizeHeader_('活動/專案名稱')) !== -1 || headers.indexOf(normalizeHeader_('活動 / 專案名稱')) !== -1 || headers.indexOf(normalizeHeader_('活動/專案名稱')) !== -1 || headers.indexOf(normalizeHeader_('活動名稱')) !== -1 || headers.indexOf(normalizeHeader_('專案名稱')) !== -1; return hasTimestamp && hasProjectType && hasProjectName; } function hasFormResponseData_(sheet) { return !!(sheet && sheet.getLastRow() >= 2); } function debugFormResponseSheets() { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) { alert_('找不到目前開啟的 Spreadsheet。請從已綁定腳本的平台 Sheet 執行。'); return; } const sheets = ss.getSheets(); const lines = []; sheets.forEach(function(sheet) { const name = sheet.getName(); const lastRow = sheet.getLastRow(); const lastColumn = sheet.getLastColumn(); let headers = []; if (lastRow >= 1 && lastColumn >= 1) { headers = sheet.getRange(1, 1, 1, lastColumn).getValues()[0]; } lines.push( '分頁:' + name + '\nlastRow:' + lastRow + '\nlastColumn:' + lastColumn + '\n是否像表單回應:' + looksLikeFormResponseSheet_(sheet) + '\n是否有資料列:' + hasFormResponseData_(sheet) + '\n標題:' + headers.join('|') + '\n' ); }); alert_(lines.join('\n––––––––\n')); } function writeSkipReason_(sheet, row, headerMap, reason) { writeCellByHeader_(sheet, row, headerMap, '錯誤訊息', reason); writeCellByHeader_(sheet, row, headerMap, '處理備註', reason); } function getFirstColumnValues_(sheet) { const lastRow = Math.max(sheet.getLastRow(), 1); return sheet.getRange(1, 1, lastRow, 1).getValues().map(function(row) { return String(row[0] || '').trim(); }); } function getOrCreateSheet_(ss, name, headers) { let sheet = ss.getSheetByName(name); if (!sheet) { sheet = ss.insertSheet(getUniqueSheetName_(ss, name)); } if (headers && headers.length) { ensureHeader_(sheet, headers); } return sheet; } function getUniqueSheetName_(ss, baseName) { if (!ss.getSheetByName(baseName)) return baseName; let index = 2; let name = baseName + index; while (ss.getSheetByName(name)) { index++; name = baseName + index; } return name; } function getOrCreateSubFolder_(parentFolder, name) { const folders = parentFolder.getFoldersByName(name); return folders.hasNext() ? folders.next() : parentFolder.createFolder(name); } function moveFileToFolder_(fileId, folder) { const file = DriveApp.getFileById(fileId); folder.addFile(file); try { DriveApp.getRootFolder().removeFile(file); } catch (error) { Logger.log('無法從根目錄移除檔案:' + error.message); } } function appendTitle_(body, title) { body.appendParagraph(title).setHeading(DocumentApp.ParagraphHeading.TITLE); } function appendSection_(body, title, content) { body.appendParagraph(title).setHeading(DocumentApp.ParagraphHeading.HEADING2); String(content || '').split('\n').forEach(function(line) { body.appendParagraph(line); }); } function appendChecklist_(body, items) { body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING2); items.forEach(function(item) { body.appendListItem('□ ' + item); }); } function buildTemplateFallback_(instruction, activity) { return [instruction, '', '活動資料:', activityToText_(activity)].join('\n'); } function sampleParentNotice_(activity) { return [ '親愛的家長您好:', '本校預計辦理「' + activity.activityName + '」。活動日期、地點、費用、交通與保險資訊請以正式通知為準。', '若資料不足,請以【待確認】標示,不要自行編造。', '', '承辦人確認事項:日期、地點、費用、交通、保險、同意書回收期限。' ].join('\n'); } function sampleTeacherReminder_(activity) { return [ '活動前一週:請導師協助確認學生名冊、家長同意書與特殊需求。', '活動前一天:請提醒學生攜帶必要物品,確認集合時間與服裝。', '活動當天集合前:請清點人數,回報未到學生與臨時狀況。', '活動進行中:請留意學生安全,依分組名冊管理。', '返校後:請再次清點人數,協助回報活動問題與照片資料。' ].join('\n'); } function formatHeaderRow_(sheet, columns) { sheet.getRange(1, 1, 1, columns).setFontWeight('bold').setBackground('#e8f0ff').setFontColor('#1e3a8a'); } function formatDateValue_(value) { if (!value) return ''; const date = new Date(value); if (isNaN(date.getTime())) return String(value); return Utilities.formatDate(date, CONFIG.TIMEZONE, 'yyyy/MM/dd'); } function valueOrPending_(value) { return String(value || '').trim() || '【待確認】'; } function sanitizeFileName_(value) { return String(value || '未命名活動').replace(/[\\/:*?"<>|#%{}~&]/g, '_').slice(0, 100); } function nowText_() { return Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm:ss'); } function alert_(message) { try { SpreadsheetApp.getUi().alert(message); } catch (error) { Logger.log(message); } } function handleError_(title, error) { const message = title + ':' + error.message; Logger.log(message); alert_(message); throw error; }