一、系統目標
這份講義同時提供「完整一次貼上版」與「分檔學習版」。實作時建議先貼完整一次貼上版;講解程式架構時,再依分檔版說明每個模組的責任。
二、資料流與工作流
功能說明
三、營隊/活動管理中樞功能總覽
這 16 個功能不是獨立按鈕,而是一條完整的活動行政工作流。從建案、報名、場次、簽到、任務追蹤,到最後匯出 NotebookLM 資料包,協助承辦人把活動資料整理成可追蹤、可同步、可檢查、可交接、可匯出的流程。
一、系統初始化與活動建案
01 初始化中控台 建案
這是整套系統的起點,用來建立或重建活動管理中樞所需的基礎工作表、欄位、格式與下拉選單。
- 活動主檔
- 報名資料表
- 場次設定表
- 簽到預備資料表
- 任務總覽
- 行政文件模板
- 期限狀態表
- 系統設定表
第一次建立展示環境時使用,或需要重建完整中控台時使用。正式資料環境中不建議反覆執行。
02 建立/開啟活動新增表單 建案
建立或開啟活動新增表單,讓承辦人透過 Google 表單輸入活動基本資料,作為活動建案入口。
- 活動新增表單
- 活動基本資料欄位
- 表單回應試算表
- 活動資料收件入口
承辦人要新增一個活動,但不想直接編輯試算表時使用。
03 使用表單新增活動時才按:匯入活動回應 建案
將活動新增表單的回應資料匯入活動主檔,讓表單資料正式進入中控台管理流程。
- 建立活動 ID
- 將活動名稱寫入活動主檔
- 整理承辦人、日期、地點等欄位
- 標記活動狀態
- 建立後續資料包所需的基礎資訊
只有當活動是透過第 2 項表單新增時才需要執行。若活動資料是直接手動填入活動主檔,通常不需要執行。
04 從選取列建立活動資料包 建案
根據目前選取的活動資料列,建立該活動專屬的資料夾、文件與管理架構。
- 活動專屬資料夾
- 報名資料子資料夾
- 簽到資料子資料夾
- 行前通知子資料夾
- 成果照片子資料夾
- 活動摘要文件
- 任務清單
- 場次資料
- NotebookLM 用資料來源資料夾
活動正式成立後使用。這是從一筆活動資料生成完整行政資料包的核心功能。
二、報名表單與資料同步
05 建立報名表單並產生 50 筆假資料 報名
建立活動報名表單,並自動產生 50 筆模擬報名資料,方便課程展示與測試。
- Google 報名表
- 報名表連結
- 報名回應試算表
- 50 筆假報名資料
- 報名資料同步欄位
- 活動主檔中的報名表狀態
教學展示、系統測試或需要模擬大量報名資料時使用。正式活動上線前,應確認是否需要關閉假資料功能。
06 同步報名資料與統計 報名
將報名表單收到的資料同步回中控台,並產生可供行政判斷的統計資訊。
- 報名資料表
- 報名名冊
- 報名人數統計
- 場次人數統計
- 學校、年級、身分別統計
- 飲食、交通、特殊需求統計
- 活動主檔中的報名狀態
- 報名進度
報名期間、報名截止後,或承辦人需要掌握最新報名狀況時使用。
三、場次與簽到管理
07 產生/更新場次設定 場次
將一個活動拆解成一個或多個可管理的場次。
- 場次 ID
- 活動 ID
- 場次名稱
- 場次日期
- 開始時間
- 結束時間
- 地點
- 講師
- 場次容量
- 是否需要簽到
- 是否需要簽退
- 場次狀態
活動需要分場次、分梯次、分時段或分課程模組管理時使用。
08 更新場次統計 場次
根據報名資料與場次設定,重新計算各場次的人數、名額與出席狀態。
- 每場報名人數
- 每場剩餘名額
- 每場候補人數
- 每場簽到人數
- 每場缺席人數
- 每場出席率
- 每場是否額滿
- 是否需要加開場次的參考資訊
報名資料更新後、活動前確認各場人數時,或活動後統計出席狀況時使用。
09 產生場次簽到預備資料 簽到
依照場次和報名名單,先整理每一場的應到人員清單,作為後續簽到比對基礎。
- 場次簽到預備表
- 每場應到人員名單
- 姓名
- 單位/學校
- 身分
- 報名序號
- 簽到狀態欄
- 簽到時間欄
- 備註欄
- QR Code 或簽到代碼欄位
建立簽到表單之前使用。這一步的重點是先確認每場應該有哪些人出席。
10 產生場次簽到表單 簽到
根據場次簽到預備資料,建立實際給學員或參與者填寫的簽到表單。
- 每個場次的簽到表單
- 簽到表單連結
- 簽到 QR Code
- 簽到回應表
- 場次資料中的簽到表連結
活動開始前,需要開放簽到或產生簽到 QR Code 時使用。
11 一鍵更新場次簽到狀態 簽到
將簽到表單回應與原本報名名單比對,更新每位參與者的出席狀態。
- 已簽到
- 未簽到
- 重複簽到
- 非報名者簽到
- 場次錯誤
- 時間異常
- 缺席
- 遲到
- 場次統計
- 報名資料表
- 活動主檔
- 任務總覽
活動當天或活動結束後使用,用來快速完成出席判斷與統計。
四、任務、期限與行政文件
12 更新任務總覽 任務
重新整理整個活動的任務進度,讓承辦人知道目前做到哪裡、還缺什麼。
- 任務是否建立
- 任務狀態
- 任務完成率
- 活動目前階段
- 到期狀態
- 缺漏項目
- 負責人
- 最後更新時間
完成重要步驟後、活動前檢查進度時,或需要向他人交接目前狀態時使用。
13 建立行政文件模板 文件
建立活動常用的行政文件模板,並可依活動資料帶入基本內容,降低重複撰寫文件的負擔。
- 活動計畫
- 報名簡章
- 行前通知
- 家長通知單
- 會議紀錄
- 簽到表
- 成果報告
- 經費概算表
- 核銷檢核表
- 感謝狀或證明文件
- 承辦人測試信
- 寄送通知範本
活動資料確定後,需要產生行政文件、通知文件或成果文件時使用。
14 更新期限狀態 任務
檢查所有任務、報名、場次或文件的期限狀態,協助承辦人掌握時間壓力與逾期風險。
- 尚未開始
- 進行中
- 即將到期
- 已逾期
- 已完成
- 需要提醒
- 可結案
活動前後、每日檢查進度,或承辦人需要確認哪些項目已經接近期限時使用。
15 寄送測試信給承辦人 通知
寄送一封測試信給活動承辦人,確認系統寄信功能與信件內容是否正常。
- 測試信件
- 活動名稱
- 活動日期
- 報名表連結
- 簽到表連結
- 任務提醒
- 系統測試訊息
正式寄送行前通知、錄取通知、提醒信之前使用,用來降低正式寄信出錯的風險。
五、AI 知識包與 NotebookLM
16 匯出 NotebookLM 資料包 AI資料包
將活動資料整理成適合 NotebookLM 或 Gemini 使用的知識資料包,讓 AI 可以讀懂整個活動脈絡。
- 活動基本資料
- 報名統計
- 場次統計
- 簽到統計
- 任務進度
- 行政文件
- 活動成果摘要
- 待辦與缺漏清單
- Markdown 或 Google Docs 格式的統整文件
活動資料整理完成後、撰寫成果報告前,或需要交給 NotebookLM 進行摘要、問答、成果整理時使用。
最適合在課堂上串接展示的功能:從選取列建立活動資料包、建立報名表單並產生 50 筆假資料、同步報名資料與統計、產生場次簽到預備資料、產生場次簽到表單、一鍵更新場次簽到狀態、更新任務總覽、更新期限狀態、匯出 NotebookLM 資料包。
這幾個功能串起來,可以讓學員看見活動資料如何從表單、名冊、簽到、統計一路進入 AI 可讀的知識包。
四、課堂操作步驟
- 在 Google Sheet 開啟 Apps Script。
- 打開或建立 Code.gs。
- 複製「完整一次貼上版」全部內容並貼上。
- 儲存後重新整理試算表,確認出現「營隊活動管理」分組選單。
- 研習展示時,從「一、研習展示」執行「初始化展示環境」。
- 可以先執行「一、研習展示/新增示範資料:新北AI研習」,快速產生一筆活動與四筆場次。
- 也可以到「00_中控台」開啟活動新增表單,填寫自己的活動。
- 只有使用活動新增表單時,才執行「二、正式活動建案/匯入活動表單資料」。若已使用新北示範資料,這一步不用執行。
- 到「01_活動清單」選取活動列。
- 正式活動可執行「二、正式活動建案/一鍵建立正式活動骨架」。
- 研習展示可執行「三、報名管理/建立報名表單+50筆假資料(研習用)」。
- 若使用正式表單回應,再執行「三、報名管理/更新報名名冊與統計」。
- 檢查「04_場次簽到」是否已產生學員 × 場次的預備資料。
- 執行「四、場次與簽到/產生場次簽到表單」,每場會建立一張簽到 Google Form。
- 學員填寫簽到表單後,執行「四、場次與簽到/比對簽到回應並更新出席狀態」。
- 執行「五、任務、期限與日曆/更新任務進度與重要行事」,檢查「05_任務總覽」是否呈現承辦人每日要看的任務。
- 活動後到「七、成果與結案」整理成果並匯出 NotebookLM 資料包。
五、完整一次貼上版
學員實作時建議使用本段:在 Apps Script 的 Code.gs 全選貼上即可。下方分檔版保留給講解與後續維護。
檔案 101
營隊活動管理中樞_完整貼上版.gs|完整一次貼上版(建議學員實作使用)
/**
* 營隊/活動管理中樞|完整一次貼上版
*
* 產生方式:
* node build_combined_camp_gas.js
*
* 教學使用方式:
* 1. 在 Google Sheet 開啟「擴充功能」→「Apps Script」。
* 2. 建立或打開 Code.gs。
* 3. 全選貼上本檔全部內容。
* 4. 儲存後重新整理試算表。
* 5. 使用「營隊活動管理」選單開始操作。
*
* 維護方式:
* 正式開發請優先修改分檔版本,再重新執行產生器,不建議直接手改本合併檔。
*/
/**
* ============================================================
* Config.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:集中設定與欄位常數。
*
* 教學重點:
* 1. 所有工作表名稱、欄位、資料夾名稱集中管理。
* 2. 後續模組只引用這裡的設定,不把欄位名稱寫散在各處。
*/
const CAMP_CONFIG = {
SYSTEM_NAME: '營隊活動管理中樞',
ROOT_FOLDER_ID: '',
CALENDAR_ID: '',
TIMEZONE: 'Asia/Taipei',
DATE_SOON_DAYS: 7,
HUMAN_CONFIRMATION_NOTICE: '本系統協助建檔、整理、提醒與產出草稿。正式公告、收費、保險、交通、安全、個資、採購與核銷仍須由承辦人或權責單位確認。'
};
const CAMP_SHEETS = {
DASHBOARD: '00_中控台',
ACTIVITIES: '01_活動清單',
SESSIONS: '02_場次設定',
REGISTRATIONS: '03_報名資料',
ATTENDANCE: '04_場次簽到',
TASK_OVERVIEW: '05_任務總覽',
TASKS: '05_任務追蹤',
DOCUMENTS: '06_文件清單',
NOTIFICATIONS: '07_通知管理',
OUTCOMES: '08_成果檢查',
NOTEBOOK: '09_NotebookLM匯出',
SETTINGS: '10_系統設定'
};
const CAMP_SHEET_ORDER = [
CAMP_SHEETS.DASHBOARD,
CAMP_SHEETS.ACTIVITIES,
CAMP_SHEETS.SESSIONS,
CAMP_SHEETS.REGISTRATIONS,
CAMP_SHEETS.ATTENDANCE,
CAMP_SHEETS.TASK_OVERVIEW,
CAMP_SHEETS.TASKS,
CAMP_SHEETS.DOCUMENTS,
CAMP_SHEETS.NOTIFICATIONS,
CAMP_SHEETS.OUTCOMES,
CAMP_SHEETS.NOTEBOOK,
CAMP_SHEETS.SETTINGS
];
const CAMP_ACTIVITY_HEADERS = [
'活動ID',
'活動名稱',
'活動類型',
'活動日期',
'活動開始日',
'活動結束日',
'每日預設開始時間',
'每日預設結束時間',
'活動天數',
'場次數',
'是否多日活動',
'是否開放部分場次報名',
'是否需要每日簽到',
'是否需要課後回饋',
'是否需要成果繳交',
'報名截止日',
'同意書回收日',
'活動地點',
'參加對象',
'預估人數',
'承辦處室',
'承辦人',
'承辦人Email',
'是否收費',
'費用說明',
'交通方式',
'是否需要保險',
'是否需要家長同意書',
'專案資料夾連結',
'報名表單連結',
'報名表單編輯連結',
'報名回應表連結',
'活動摘要連結',
'家長通知書連結',
'家長同意書連結',
'標案需求表連結',
'處室通知草稿連結',
'導師行前提醒連結',
'成果檢查表連結',
'NotebookLM資料包連結',
'報名人數',
'同意參加',
'不參加',
'特殊需求數',
'目前狀態',
'最近更新時間',
'備註',
'報名表模板類型',
'任務完成率'
];
const CAMP_SESSION_HEADERS = [
'活動ID',
'場次ID',
'場次日期',
'開始時間',
'結束時間',
'場次名稱',
'場次主題',
'場次目標',
'講師',
'助教或協作人員',
'地點或線上連結',
'本場需要設備',
'講義連結',
'簽到表單連結',
'回饋表單連結',
'錄影連結',
'報名人數',
'簽到人數',
'出席率',
'課前任務完成率',
'課後回饋數',
'成果整理狀態',
'風險等級',
'目前狀態',
'備註',
'任務完成率',
'任務完成狀態'
];
const CAMP_REGISTRATION_HEADERS = [
'活動ID',
'活動名稱',
'時間戳記',
'學生姓名',
'班級',
'座號',
'家長姓名',
'家長Email',
'家長電話',
'是否參加',
'是否全程參加',
'選擇參加場次',
'需要研習時數',
'學習期待',
'目前行政痛點',
'是否同意通知',
'飲食需求',
'特殊需求',
'同意書狀態',
'是否可提供 NotebookLM',
'來源分頁',
'備註'
];
const CAMP_ATTENDANCE_HEADERS = [
'活動ID',
'場次ID',
'場次名稱',
'場次日期',
'學生或學員姓名',
'學校或單位',
'Email',
'報名資料Key',
'是否報名本場',
'簽到狀態',
'簽到時間',
'簽退狀態',
'簽退時間',
'可核發時數',
'備註'
];
const CAMP_TASK_HEADERS = [
'活動ID',
'場次ID',
'WBS編碼',
'階段',
'任務名稱',
'具體任務',
'交付物',
'完成判準',
'負責角色',
'負責人',
'建議開始日期',
'建議完成日期',
'實際完成日期',
'前置任務ID',
'是否關鍵任務',
'需要文件或表單',
'狀態',
'優先程度',
'卡關原因',
'備註',
'最後更新時間'
];
const CAMP_TASK_OVERVIEW_HEADERS = [
'任務ID',
'活動ID',
'活動名稱',
'場次ID',
'場次名稱',
'階段',
'任務名稱',
'任務類型',
'負責人',
'截止日',
'到期日',
'狀態',
'優先程度',
'卡關原因/下一步',
'備註',
'重要行事',
'日曆標題',
'日曆日期',
'開始時間',
'結束時間',
'日曆事件ID',
'日曆同步狀態',
'最後同步時間',
'日曆回寫狀態',
'日曆最後讀取時間',
'完成時間',
'完成日',
'最後更新時間'
];
const CAMP_DOCUMENT_HEADERS = [
'活動ID',
'活動名稱',
'文件類型',
'文件名稱',
'用途',
'連結',
'產生方式',
'狀態',
'最後更新時間',
'承辦人確認',
'備註'
];
const CAMP_NOTIFICATION_HEADERS = [
'活動ID',
'通知名稱',
'通知對象',
'通知管道',
'建議通知日期',
'實際通知日期',
'文件連結',
'寄送或通知狀態',
'承辦人確認',
'備註'
];
const CAMP_OUTCOME_HEADERS = [
'活動ID',
'成果項目',
'是否完成',
'檔案或資料夾連結',
'是否含個資',
'是否可提供 NotebookLM',
'負責人',
'備註'
];
const CAMP_NOTEBOOK_HEADERS = [
'活動ID',
'資料區塊',
'內容摘要',
'來源文件或工作表',
'是否納入匯出',
'備註'
];
const CAMP_SETTINGS_HEADERS = ['設定項目', '設定值', '說明'];
const CAMP_SUBFOLDER_NAMES = [
'00_活動基本資料',
'01_報名表單與回覆',
'02_家長通知與同意書',
'03_處室聯繫與採購',
'04_任務追蹤與會議紀錄',
'05_活動照片與成果',
'06_成果檢查與結案',
'07_NotebookLM資料包',
'99_系統產生文件'
];
const CAMP_TASK_STATUS_VALUES = ['未開始', '進行中', '待確認', '已完成', '延後', '取消', '不適用'];
const CAMP_PRIORITY_VALUES = ['高', '中', '低'];
const CAMP_TASK_STATUS_COLORS = {
'未開始': '#f3f4f6',
'進行中': '#dbeafe',
'待確認': '#fef3c7',
'已完成': '#dcfce7',
'延後': '#fee2e2',
'取消': '#f5f5f5',
'不適用': '#ede9fe'
};
const CAMP_PRIORITY_COLORS = {
'高': '#fecaca',
'中': '#fde68a',
'低': '#bbf7d0'
};
const CAMP_OVERDUE_ROW_COLOR = '#fee2e2';
const CAMP_SOON_ROW_COLOR = '#fef9c3';
const CAMP_CONFIRM_VALUES = ['未確認', '待確認', '已確認', '退回修改', '不適用'];
const CAMP_YES_NO_VALUES = ['是', '否', '待確認', '不適用'];
const CAMP_DATE_STATUS_VALUES = ['未到期', '即將到期', '逾期', '已完成', '無日期'];
const CAMP_SESSION_STATUS_VALUES = ['未開始', '準備中', '進行中', '已完成', '待補件', '取消'];
const CAMP_RISK_LEVEL_VALUES = ['低', '中', '高', '極高'];
const CAMP_ATTENDANCE_STATUS_VALUES = ['未簽到', '已簽到', '請假', '缺席', '不適用'];
const CAMP_REGISTRATION_TEMPLATE_TYPES = ['自動判斷', '教師研習', '線上研習', '學生營隊', '校外教學', '親子活動', '競賽活動', '自訂'];
/**
* ============================================================
* AiPrompts.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:提示詞集中管理。
*
* MVP 先採用「提示詞模板」。Gemini API 可於進階階段再接上。
*/
const CAMP_PROMPTS = {
activityDiagnosis: [
'你是一位熟悉台灣國小行政流程的 AI 行政專案顧問。',
'請根據以下活動建案資料,協助判斷這個活動是否適合用活動型行政專案方式管理。',
'請用表格回答:',
'1. 目前資訊已清楚的部分',
'2. 目前資訊不足的部分',
'3. 最優先要確認的 5 件事',
'4. 如果直接執行,可能出現的行政風險',
'5. 建議建立哪些 Google Workspace 文件、表單、資料夾或提醒',
'',
'活動資料:',
'{{ACTIVITY_DATA}}'
].join('\n'),
parentNotice: [
'你是一位熟悉台灣國小行政語氣的學校行政助理。',
'請根據以下活動資料,產出一份給家長的活動通知書草稿。',
'請注意:',
'1. 語氣正式、清楚、親切。',
'2. 日期、費用、集合時間若資料不足,請用【待確認】標示。',
'3. 涉及收費、保險、交通、安全者,請提醒承辦人正式確認。',
'4. 不要自行編造不存在的資料。',
'5. 使用繁體中文。',
'6. 最後加上「承辦人確認事項」。',
'',
'活動資料:',
'{{ACTIVITY_DATA}}'
].join('\n'),
riskCheck: [
'你是一位協助學校行政人員進行活動風險盤點的 AI 助理。',
'請根據活動資料、任務、通知與成果檢查,找出可能風險。',
'請分成時程、人員、學生安全、交通、家長溝通、經費核銷、成果結案、個資與資料安全。',
'每一項請列出風險內容、可能造成的問題、預防方式、是否需要提醒、承辦人需確認事項。',
'',
'活動資料:',
'{{ACTIVITY_DATA}}'
].join('\n'),
notebook: [
'你是一位協助學校行政人員整理 NotebookLM 專案資料包的助理。',
'請根據以下活動資料與工作流摘要,整理成可匯入 NotebookLM 的乾淨資料包。',
'請不要放入學生電話、健康細節、身分資料或不必要個資。',
'請包含:活動基本資料、重要時程、任務狀態、通知紀錄、成果檢查、下次辦理建議、可問 NotebookLM 的問題。',
'',
'工作流資料:',
'{{WORKFLOW_DATA}}'
].join('\n')
};
/**
* ============================================================
* Utilities.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:共用工具函式。
*/
function getActiveSpreadsheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
if (!ss) throw new Error('請先開啟已綁定 Apps Script 的 Google 試算表。');
return ss;
}
function alert_(message) {
try {
SpreadsheetApp.getUi().alert(String(message));
} catch (error) {
Logger.log(String(message));
}
}
function getOrCreateSheet_(ss, name) {
return ss.getSheetByName(name) || ss.insertSheet(name);
}
function ensureHeaders_(sheet, headers) {
const width = headers.length;
const existing = sheet.getRange(1, 1, 1, Math.max(width, sheet.getLastColumn() || width)).getValues()[0];
let changed = false;
headers.forEach(function(header, index) {
if (existing[index] !== header) changed = true;
});
if (changed) {
sheet.getRange(1, 1, 1, width).setValues([headers]);
}
formatHeaderRow_(sheet, width);
return buildHeaderMap_(sheet);
}
function formatHeaderRow_(sheet, columns) {
sheet.getRange(1, 1, 1, columns)
.setFontWeight('bold')
.setBackground('#dbeafe')
.setHorizontalAlignment('center')
.setWrap(true);
sheet.setFrozenRows(1);
sheet.autoResizeColumns(1, columns);
}
function buildHeaderMap_(sheet) {
const lastColumn = sheet.getLastColumn();
if (!lastColumn) return {};
const headers = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
const map = {};
headers.forEach(function(header, index) {
if (!header) return;
map[String(header).trim()] = index + 1;
});
return map;
}
function readCellByHeader_(sheet, row, headerMap, header) {
const column = headerMap[header];
if (!column) return '';
return sheet.getRange(row, column).getValue();
}
function writeCellByHeader_(sheet, row, headerMap, header, value) {
const column = headerMap[header];
if (!column) return;
sheet.getRange(row, column).setValue(value);
}
function readRowObject_(sheet, row, headers) {
const map = buildHeaderMap_(sheet);
const object = {};
headers.forEach(function(header) {
object[header] = readCellByHeader_(sheet, row, map, header);
});
return object;
}
function getSelectedActivityRowContext_() {
const ss = getActiveSpreadsheet_();
const sheet = ss.getActiveSheet();
if (sheet.getName() !== CAMP_SHEETS.ACTIVITIES) {
throw new Error('請先到「' + CAMP_SHEETS.ACTIVITIES + '」選取一筆活動資料列。');
}
const row = sheet.getActiveCell().getRow();
if (row <= 1) throw new Error('請選取活動資料列,不要選標題列。');
const headerMap = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
const activity = readActivityFromRow_(sheet, row, headerMap);
if (!activity.name) throw new Error('選取列缺少「活動名稱」。');
return { ss: ss, sheet: sheet, row: row, headerMap: headerMap, activity: activity };
}
function readActivityFromRow_(sheet, row, headerMap) {
return {
id: String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim(),
name: String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim(),
type: String(readCellByHeader_(sheet, row, headerMap, '活動類型') || '').trim(),
registrationTemplateType: String(readCellByHeader_(sheet, row, headerMap, '報名表模板類型') || '').trim(),
activityDate: readCellByHeader_(sheet, row, headerMap, '活動日期'),
startDate: readCellByHeader_(sheet, row, headerMap, '活動開始日'),
endDate: readCellByHeader_(sheet, row, headerMap, '活動結束日'),
defaultStartTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設開始時間') || '').trim(),
defaultEndTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設結束時間') || '').trim(),
activityDays: readCellByHeader_(sheet, row, headerMap, '活動天數'),
sessionCount: readCellByHeader_(sheet, row, headerMap, '場次數'),
isMultiDay: String(readCellByHeader_(sheet, row, headerMap, '是否多日活動') || '').trim(),
allowPartialSessions: String(readCellByHeader_(sheet, row, headerMap, '是否開放部分場次報名') || '').trim(),
dailyAttendanceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要每日簽到') || '').trim(),
feedbackRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要課後回饋') || '').trim(),
outcomeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要成果繳交') || '').trim(),
registrationDeadline: readCellByHeader_(sheet, row, headerMap, '報名截止日'),
consentDeadline: readCellByHeader_(sheet, row, headerMap, '同意書回收日'),
location: String(readCellByHeader_(sheet, row, headerMap, '活動地點') || '').trim(),
participants: String(readCellByHeader_(sheet, row, headerMap, '參加對象') || '').trim(),
estimatedCount: readCellByHeader_(sheet, row, headerMap, '預估人數'),
department: String(readCellByHeader_(sheet, row, headerMap, '承辦處室') || '').trim(),
owner: String(readCellByHeader_(sheet, row, headerMap, '承辦人') || '').trim(),
ownerEmail: String(readCellByHeader_(sheet, row, headerMap, '承辦人Email') || '').trim(),
chargeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否收費') || '').trim(),
feeNote: String(readCellByHeader_(sheet, row, headerMap, '費用說明') || '').trim(),
transport: String(readCellByHeader_(sheet, row, headerMap, '交通方式') || '').trim(),
insuranceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要保險') || '').trim(),
consentRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要家長同意書') || '').trim(),
folderUrl: String(readCellByHeader_(sheet, row, headerMap, '專案資料夾連結') || '').trim(),
registrationFormUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單連結') || '').trim(),
registrationFormEditUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單編輯連結') || '').trim(),
summaryDocUrl: String(readCellByHeader_(sheet, row, headerMap, '活動摘要連結') || '').trim(),
parentNoticeUrl: String(readCellByHeader_(sheet, row, headerMap, '家長通知書連結') || '').trim(),
parentConsentUrl: String(readCellByHeader_(sheet, row, headerMap, '家長同意書連結') || '').trim(),
procurementUrl: String(readCellByHeader_(sheet, row, headerMap, '標案需求表連結') || '').trim(),
officeEmailUrl: String(readCellByHeader_(sheet, row, headerMap, '處室通知草稿連結') || '').trim(),
teacherReminderUrl: String(readCellByHeader_(sheet, row, headerMap, '導師行前提醒連結') || '').trim(),
outcomeUrl: String(readCellByHeader_(sheet, row, headerMap, '成果檢查表連結') || '').trim(),
notebookUrl: String(readCellByHeader_(sheet, row, headerMap, 'NotebookLM資料包連結') || '').trim(),
note: String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim()
};
}
function activityToText_(activity) {
return [
'活動ID:' + valueOrPending_(activity.id),
'活動名稱:' + valueOrPending_(activity.name),
'活動類型:' + valueOrPending_(activity.type),
'報名表模板類型:' + valueOrPending_(activity.registrationTemplateType || '自動判斷'),
'活動日期:' + formatDateValue_(activity.activityDate),
'活動開始日:' + formatDateValue_(activity.startDate),
'活動結束日:' + formatDateValue_(activity.endDate),
'每日預設時間:' + valueOrPending_(activity.defaultStartTime || '09:00') + '-' + valueOrPending_(activity.defaultEndTime || '12:00'),
'活動天數:' + valueOrPending_(activity.activityDays),
'場次數:' + valueOrPending_(activity.sessionCount),
'是否多日活動:' + valueOrPending_(activity.isMultiDay),
'是否開放部分場次報名:' + valueOrPending_(activity.allowPartialSessions),
'是否需要每日簽到:' + valueOrPending_(activity.dailyAttendanceRequired),
'是否需要課後回饋:' + valueOrPending_(activity.feedbackRequired),
'是否需要成果繳交:' + valueOrPending_(activity.outcomeRequired),
'報名截止日:' + formatDateValue_(activity.registrationDeadline),
'同意書回收日:' + formatDateValue_(activity.consentDeadline),
'活動地點:' + valueOrPending_(activity.location),
'參加對象:' + valueOrPending_(activity.participants),
'預估人數:' + valueOrPending_(activity.estimatedCount),
'承辦處室:' + valueOrPending_(activity.department),
'承辦人:' + valueOrPending_(activity.owner),
'承辦人Email:' + valueOrPending_(activity.ownerEmail),
'是否收費:' + valueOrPending_(activity.chargeRequired),
'費用說明:' + valueOrPending_(activity.feeNote),
'交通方式:' + valueOrPending_(activity.transport),
'是否需要保險:' + valueOrPending_(activity.insuranceRequired),
'是否需要家長同意書:' + valueOrPending_(activity.consentRequired),
'備註:' + valueOrPending_(activity.note),
'',
'場次摘要:',
formatSessionsText_(activity.id)
].join('\n');
}
function generateActivityId_() {
const dateText = Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd-HHmmss');
const props = PropertiesService.getScriptProperties();
const key = 'CAMP_ACTIVITY_SERIAL_' + Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd');
const next = Number(props.getProperty(key) || '0') + 1;
props.setProperty(key, String(next));
return 'ACT-' + dateText + '-' + ('000' + next).slice(-3);
}
function nowText_() {
return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm:ss');
}
function nowMinuteText_() {
return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm');
}
function parseDate_(value) {
if (value instanceof Date && !isNaN(value.getTime())) return value;
if (!value) return null;
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
function addDays_(date, days) {
if (!date) return null;
const copy = new Date(date.getTime());
copy.setDate(copy.getDate() + days);
return copy;
}
function formatDateValue_(value) {
const date = parseDate_(value);
if (!date) return value ? String(value) : '待確認';
return Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd');
}
function effectiveActivityStartDate_(activity) {
return parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}
function effectiveActivityEndDate_(activity) {
return parseDate_(activity.endDate) || parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}
function calculateActivityDays_(activity) {
const startDate = effectiveActivityStartDate_(activity);
const endDate = effectiveActivityEndDate_(activity);
if (!startDate || !endDate) return '';
const start = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const end = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return Math.max(1, Math.floor((end.getTime() - start.getTime()) / 86400000) + 1);
}
function formatPercent_(numerator, denominator) {
if (!denominator) return '0%';
return Math.round((Number(numerator || 0) / Number(denominator || 0)) * 100) + '%';
}
function isSameDate_(a, b) {
const da = parseDate_(a);
const db = parseDate_(b);
if (!da || !db) return false;
return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
}
function calculateDateStatus_(value, done) {
if (done) return '已完成';
const date = parseDate_(value);
if (!date) return '無日期';
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date.getTime());
target.setHours(0, 0, 0, 0);
const diffDays = Math.floor((target.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return '逾期';
if (diffDays <= CAMP_CONFIG.DATE_SOON_DAYS) return '即將到期';
return '未到期';
}
function sanitizeFileName_(value) {
return String(value || '未命名活動').replace(/[\\/:*?"<>|#%{}~&]/g, '_').slice(0, 120);
}
function valueOrPending_(value) {
if (value === null || value === undefined || value === '') return '待確認';
return String(value);
}
function extractIdFromUrl_(url) {
const text = String(url || '');
const match = text.match(/[-\w]{25,}/);
return match ? match[0] : '';
}
function getRowsByActivityId_(sheet, activityId) {
if (!sheet || sheet.getLastRow() <= 1) return [];
const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
const headers = values[0];
const idIndex = headers.indexOf('活動ID');
if (idIndex < 0) return [];
return values.slice(1).filter(function(row) {
return String(row[idIndex] || '') === String(activityId || '');
});
}
function buildRowsByHeader_(sheet) {
if (!sheet || sheet.getLastRow() <= 1) return [];
const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
const headers = values[0];
return values.slice(1).map(function(row) {
const object = {};
headers.forEach(function(header, index) {
if (header) object[String(header)] = row[index];
});
return object;
});
}
function appendDocumentListRow_(activity, type, name, purpose, url, method, status, confirmation, note) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS);
ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
sheet.appendRow([
activity.id,
activity.name,
type,
name,
purpose,
url,
method || '系統模板',
status || '草稿',
nowText_(),
confirmation || '待確認',
note || ''
]);
}
function setValidationList_(sheet, startRow, column, rowCount, values) {
if (rowCount <= 0) return;
const rule = SpreadsheetApp.newDataValidation()
.requireValueInList(values, true)
.setAllowInvalid(true)
.build();
sheet.getRange(startRow, column, rowCount, 1).setDataValidation(rule);
}
/**
* ============================================================
* SheetSetup.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:中控 Spreadsheet 初始化。
*/
function initializeSheets_(ss) {
CAMP_SHEET_ORDER.forEach(function(name) {
getOrCreateSheet_(ss, name);
});
setupDashboardSheet_(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
setupActivityListSheet_(ss.getSheetByName(CAMP_SHEETS.ACTIVITIES));
setupSessionSheet_(ss.getSheetByName(CAMP_SHEETS.SESSIONS));
setupRegistrationSheet_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS));
setupAttendanceSheet_(ss.getSheetByName(CAMP_SHEETS.ATTENDANCE));
setupTaskOverviewSheet_(ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW));
setupTaskSheet_(ss.getSheetByName(CAMP_SHEETS.TASKS));
setupDocumentListSheet_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS));
setupNotificationSheet_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS));
setupOutcomeSheet_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES));
setupNotebookSheet_(ss.getSheetByName(CAMP_SHEETS.NOTEBOOK));
setupSettingsSheet_(ss.getSheetByName(CAMP_SHEETS.SETTINGS));
restoreActivityIntakeLinksToDashboard_(ss);
CAMP_SHEET_ORDER.forEach(function(name, index) {
ss.setActiveSheet(ss.getSheetByName(name));
ss.moveActiveSheet(index + 1);
});
ss.setActiveSheet(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
}
function setupDashboardSheet_(sheet) {
sheet.clear();
sheet.getRange('A1').setValue(CAMP_CONFIG.SYSTEM_NAME).setFontSize(18).setFontWeight('bold');
sheet.getRange('A3:D23').setValues([
['指標', '目前數值', '狀態', '提示'],
['活動總數', 0, '', ''],
['多日活動數', 0, '', ''],
['場次總數', 0, '', ''],
['報名總人數', 0, '', ''],
['場次簽到總筆數', 0, '', ''],
['平均出席率', '0%', '', ''],
['未完成任務數', 0, '', ''],
['今日到期任務', 0, '', ''],
['延宕任務數', 0, '', ''],
['高風險場次數', 0, '', ''],
['待確認文件數', 0, '', ''],
['NotebookLM 匯出數', 0, '', ''],
['最後更新時間', nowText_(), '', ''],
['重要行事總數', 0, '', ''],
['已同步日曆數', 0, '', ''],
['日曆回寫成功數', 0, '', ''],
['日曆事件遺失數', 0, '', ''],
['日曆同步失敗數', 0, '', ''],
['今日到期重要行事', 0, '', ''],
['延宕重要行事', 0, '', '']
]);
formatDashboardKpiLayout_(sheet);
sheet.getRange('F3:G7').setValues([
['快速連結', '連結或說明'],
['活動新增表單', '尚未建立'],
['活動新增表單編輯', '尚未建立'],
['操作提醒', '示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。'],
['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE]
]);
sheet.getRange('F3:G3').setFontWeight('bold').setBackground('#dbeafe');
sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
sheet.getRange('G4:G7').setWrap(true);
sheet.getRange('A25').setValue('場次進度總覽').setFontWeight('bold');
sheet.getRange('A26:K26').setValues([['活動ID', '活動名稱', '場次ID', '場次日期', '場次名稱', '報名人數', '簽到人數', '出席率', '課前任務完成率', '目前狀態', '風險等級']]).setFontWeight('bold').setBackground('#e0f2fe');
sheet.getRange('A38').setValue('紅燈任務').setFontWeight('bold');
sheet.getRange('A39:G39').setValues([['活動ID', '場次ID', '任務名稱', '負責人', '建議完成日期', '狀態', '卡關原因']]).setFontWeight('bold').setBackground('#fee2e2');
sheet.getRange('A53').setValue('今日/明日場次').setFontWeight('bold');
sheet.getRange('A54:G54').setValues([['日期', '活動名稱', '場次ID', '場次名稱', '時間', '地點', '狀態']]).setFontWeight('bold').setBackground('#dcfce7');
sheet.getRange('A68').setValue('下一步建議').setFontWeight('bold');
sheet.getRange('A69').setValue('請先選擇一條流程:示範資料流程,或活動新增表單流程。示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。').setWrap(true);
sheet.setColumnWidth(1, 220);
sheet.setColumnWidth(2, 120);
sheet.setColumnWidth(3, 130);
sheet.setColumnWidth(4, 320);
sheet.setColumnWidth(6, 180);
sheet.setColumnWidth(7, 520);
}
function ensureDashboardVisualLayout_(sheet) {
sheet.getRange('A3:D3').setValues([['指標', '目前數值', '狀態', '提示']]);
sheet.getRange('E3:E7').clearContent().clearFormat();
sheet.getRange('A4:A23').setValues([
['活動總數'],
['多日活動數'],
['場次總數'],
['報名總人數'],
['場次簽到總筆數'],
['平均出席率'],
['未完成任務數'],
['今日到期任務'],
['延宕任務數'],
['高風險場次數'],
['待確認文件數'],
['NotebookLM 匯出數'],
['最後更新時間'],
['重要行事總數'],
['已同步日曆數'],
['日曆回寫成功數'],
['日曆事件遺失數'],
['日曆同步失敗數'],
['今日到期重要行事'],
['延宕重要行事']
]);
formatDashboardKpiLayout_(sheet);
sheet.getRange('F3:G3').setValues([['快速連結', '連結或說明']]).setFontWeight('bold').setBackground('#dbeafe');
sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
sheet.getRange('G4:G7').setWrap(true);
sheet.setColumnWidth(1, 220);
sheet.setColumnWidth(2, 120);
sheet.setColumnWidth(3, 130);
sheet.setColumnWidth(4, 320);
sheet.setColumnWidth(5, 28);
sheet.setColumnWidth(6, 180);
sheet.setColumnWidth(7, 520);
}
function formatDashboardKpiLayout_(sheet) {
sheet.getRange('A3:D3')
.setFontWeight('bold')
.setBackground('#dbeafe')
.setHorizontalAlignment('center');
sheet.getRange('A4:A23').setFontWeight('bold').setBackground('#f8fafc');
sheet.getRange('B4:B23').setHorizontalAlignment('right');
sheet.getRange('C4:C23').setHorizontalAlignment('center');
sheet.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}
function setupActivityListSheet_(sheet) {
ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('報名表模板類型') + 1, 200, CAMP_REGISTRATION_TEMPLATE_TYPES);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否收費') + 1, 200, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要保險') + 1, 200, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要家長同意書') + 1, 200, CAMP_YES_NO_VALUES);
}
function setupTaskOverviewSheet_(sheet) {
ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
formatTaskOverviewHeader_(sheet);
}
function setupSessionSheet_(sheet) {
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('目前狀態') + 1, 500, CAMP_SESSION_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('風險等級') + 1, 500, CAMP_RISK_LEVEL_VALUES);
}
function setupRegistrationSheet_(sheet) {
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
}
function setupAttendanceSheet_(sheet) {
ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽到狀態') + 1, 1000, CAMP_ATTENDANCE_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽退狀態') + 1, 1000, ['未簽退', '已簽退', '不適用']);
}
function setupTaskSheet_(sheet) {
ensureHeaders_(sheet, CAMP_TASK_HEADERS);
setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
}
function setupDocumentListSheet_(sheet) {
ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
setValidationList_(sheet, 2, CAMP_DOCUMENT_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}
function setupNotificationSheet_(sheet) {
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
setValidationList_(sheet, 2, CAMP_NOTIFICATION_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}
function setupOutcomeSheet_(sheet) {
ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否完成') + 1, 500, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否含個資') + 1, 500, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否可提供 NotebookLM') + 1, 500, CAMP_YES_NO_VALUES);
}
function setupNotebookSheet_(sheet) {
ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
setValidationList_(sheet, 2, CAMP_NOTEBOOK_HEADERS.indexOf('是否納入匯出') + 1, 500, CAMP_YES_NO_VALUES);
}
function setupSettingsSheet_(sheet) {
ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
if (sheet.getLastRow() === 1) {
sheet.getRange(2, 1, 7, 3).setValues([
['SYSTEM_NAME', CAMP_CONFIG.SYSTEM_NAME, '系統名稱'],
['ROOT_FOLDER_ID', CAMP_CONFIG.ROOT_FOLDER_ID || '空白時使用目前試算表所在資料夾或我的雲端硬碟根目錄', '平台根資料夾設定'],
['CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定'],
['TIMEZONE', CAMP_CONFIG.TIMEZONE, '日期計算使用時區'],
['DATE_SOON_DAYS', CAMP_CONFIG.DATE_SOON_DAYS, '幾天內視為即將到期'],
['GEMINI_API_KEY', '未在本教學 MVP 啟用', 'API key 不寫入試算表'],
['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE, '正式內容仍由承辦人確認']
]);
}
ensureSettingExists_(sheet, 'CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定');
}
function ensureSettingExists_(sheet, key, value, note) {
const map = buildHeaderMap_(sheet);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (String(readCellByHeader_(sheet, row, map, '設定項目') || '') === String(key || '')) {
return;
}
}
sheet.appendRow([key, value, note]);
}
function updateDashboard_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
ensureDashboardVisualLayout_(dashboard);
restoreActivityIntakeLinksToDashboard_(ss);
updateDashboardKpis_();
updateDashboardStatusLights_();
updateDashboardSessionOverview_();
updateDashboardRedTasks_();
updateDashboardTodaySessions_();
}
function restoreActivityIntakeLinksToDashboard_(ss) {
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
const map = buildHeaderMap_(settings);
if (!map['設定項目'] || !map['設定值']) return;
const links = {};
for (let row = 2; row <= settings.getLastRow(); row++) {
const key = String(readCellByHeader_(settings, row, map, '設定項目') || '');
if (key === '活動新增表單' || key === '活動新增表單編輯') {
links[key] = readCellByHeader_(settings, row, map, '設定值');
}
}
if (links['活動新增表單']) {
dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
dashboard.getRange('G4').setValue(links['活動新增表單']).setWrap(true);
}
if (links['活動新增表單編輯']) {
dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
dashboard.getRange('G5').setValue(links['活動新增表單編輯']).setWrap(true);
}
}
/**
* ============================================================
* DriveService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:Drive 資料夾操作。
*/
function getCampRootFolder_() {
if (CAMP_CONFIG.ROOT_FOLDER_ID) {
try {
return DriveApp.getFolderById(CAMP_CONFIG.ROOT_FOLDER_ID);
} catch (error) {
Logger.log('ROOT_FOLDER_ID 無效,改用試算表所在資料夾:' + error.message);
}
}
const ss = getActiveSpreadsheet_();
const file = DriveApp.getFileById(ss.getId());
const parents = file.getParents();
const parent = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
return getOrCreateSubFolder_(parent, '營隊活動管理中樞_資料包');
}
function createActivityFolder_(activity) {
const root = getCampRootFolder_();
const dateText = formatDateValue_(activity.activityDate).replace(/\//g, '');
const folderName = sanitizeFileName_((activity.id || generateActivityId_()) + '_' + dateText + '_' + activity.name);
return root.createFolder(folderName);
}
function createActivitySubfolders_(folder) {
const map = {};
CAMP_SUBFOLDER_NAMES.forEach(function(name) {
map[name] = getOrCreateSubFolder_(folder, name);
});
return map;
}
function getOrCreateSubFolder_(parent, name) {
const folders = parent.getFoldersByName(name);
return folders.hasNext() ? folders.next() : parent.createFolder(name);
}
function moveFileToFolder_(fileId, folder) {
const file = DriveApp.getFileById(fileId);
folder.addFile(file);
const parents = file.getParents();
while (parents.hasNext()) {
const parent = parents.next();
if (parent.getId() !== folder.getId()) {
try {
parent.removeFile(file);
} catch (error) {
Logger.log('移除原父資料夾失敗,可忽略:' + error.message);
}
}
}
return file;
}
function getFolderFromActivityUrl_(url) {
const id = extractIdFromUrl_(url);
if (!id) return null;
try {
return DriveApp.getFolderById(id);
} catch (error) {
Logger.log('無法由活動資料夾連結取得資料夾:' + error.message);
return null;
}
}
function getActivitySubfolderMap_(folder) {
const map = {};
CAMP_SUBFOLDER_NAMES.forEach(function(name) {
map[name] = getOrCreateSubFolder_(folder, name);
});
return map;
}
/**
* ============================================================
* SessionService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:多日活動、場次與簽到服務。
*/
function generateSessionsForSelectedActivity() {
const context = getSelectedActivityRowContext_();
generateSessionsForActivity_(context.activity);
updateActivitySessionFields_(context);
updateSessionStats_();
updateDashboard_();
alert_('場次設定已產生或更新完成。');
}
function updateSessionStats() {
updateSessionStats_();
updateDashboard_();
alert_('場次統計已更新。');
}
function generateAttendanceRowsForSelectedActivity() {
const context = getSelectedActivityRowContext_();
generateAttendanceRowsForActivity_(context.activity.id);
updateSessionStats_();
updateDashboard_();
alert_('場次簽到預備資料已產生。');
}
function generateSessionsForActivity_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
if (!activity.id) return [];
const existing = getSessionsByActivityId_(activity.id);
if (existing.length > 0) return existing;
const startDate = effectiveActivityStartDate_(activity);
const endDate = effectiveActivityEndDate_(activity) || startDate;
if (!startDate) return [];
const rows = [];
let index = 1;
for (let d = new Date(startDate.getTime()); d <= endDate; d.setDate(d.getDate() + 1)) {
const sessionId = 'S' + ('0' + index).slice(-2);
rows.push(buildSessionSheetRow_({
'活動ID': activity.id,
'場次ID': sessionId,
'場次日期': new Date(d.getTime()),
'開始時間': activity.defaultStartTime || '09:00',
'結束時間': activity.defaultEndTime || '12:00',
'場次名稱': '第 ' + index + ' 日',
'講師': activity.owner || '',
'地點或線上連結': activity.location || '',
'報名人數': 0,
'簽到人數': 0,
'出席率': '0%',
'課前任務完成率': '0%',
'課後回饋數': 0,
'成果整理狀態': '未整理',
'風險等級': '低',
'目前狀態': '未開始',
'備註': '',
'任務完成率': '0%',
'任務完成狀態': '未開始'
}));
index++;
}
if (rows.length) {
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
}
return getSessionsByActivityId_(activity.id);
}
function buildSessionSheetRow_(sessionData) {
return CAMP_SESSION_HEADERS.map(function(header) {
return Object.prototype.hasOwnProperty.call(sessionData, header) ? sessionData[header] : '';
});
}
function getSessionsByActivityId_(activityId) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
const rows = buildRowsByHeader_(sheet).filter(function(row) {
return String(row['活動ID'] || '') === String(activityId || '');
});
return rows.map(function(row) {
return {
activityId: row['活動ID'],
sessionId: row['場次ID'],
date: row['場次日期'],
startTime: row['開始時間'],
endTime: row['結束時間'],
title: row['場次名稱'],
topic: row['場次主題'],
instructor: row['講師'],
location: row['地點或線上連結'],
registrationCount: row['報名人數'] || 0,
checkinCount: row['簽到人數'] || 0,
attendanceRate: row['出席率'] || '0%',
preTaskRate: row['課前任務完成率'] || '0%',
outcomeStatus: row['成果整理狀態'] || '',
riskLevel: row['風險等級'] || '',
status: row['目前狀態'] || ''
};
});
}
function formatSessionsText_(activityId) {
if (!activityId) return '尚未建立場次。';
try {
const sessions = getSessionsByActivityId_(activityId);
if (!sessions.length) return '尚未建立場次。';
return sessions.map(function(session) {
return [
session.sessionId,
formatDateValue_(session.date),
valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
valueOrPending_(session.title),
valueOrPending_(session.topic),
valueOrPending_(session.location),
valueOrPending_(session.status)
].join('|');
}).join('\n');
} catch (error) {
Logger.log('場次摘要讀取失敗:' + error.message);
return '場次摘要讀取失敗。';
}
}
function appendSessionsTable_(body, activityId) {
const sessions = getSessionsByActivityId_(activityId);
if (!sessions.length) {
body.appendParagraph('尚未建立場次。');
return;
}
const rows = [['場次ID', '日期', '時間', '場次名稱', '主題', '地點']];
sessions.forEach(function(session) {
rows.push([
session.sessionId,
formatDateValue_(session.date),
valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
valueOrPending_(session.title),
valueOrPending_(session.topic),
valueOrPending_(session.location)
]);
});
appendDocTable_(body, rows);
}
function updateSessionStats_() {
const ss = getActiveSpreadsheet_();
const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
const sessionMap = buildHeaderMap_(sessionSheet);
const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));
for (let row = 2; row <= sessionSheet.getLastRow(); row++) {
const activityId = readCellByHeader_(sessionSheet, row, sessionMap, '活動ID');
const sessionId = readCellByHeader_(sessionSheet, row, sessionMap, '場次ID');
if (!activityId || !sessionId) continue;
const registered = registrationRows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId)
&& selectedSessionsContain_(item['選擇參加場次'], sessionId);
}).length;
const checkedIn = attendanceRows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId)
&& String(item['場次ID'] || '') === String(sessionId)
&& String(item['簽到狀態'] || '') === '已簽到';
}).length;
const preTasks = taskRows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId)
&& String(item['場次ID'] || '') === String(sessionId)
&& String(item['階段'] || '').indexOf('課前') >= 0;
});
const donePreTasks = preTasks.filter(function(item) {
return String(item['狀態'] || '') === '已完成';
}).length;
writeCellByHeader_(sessionSheet, row, sessionMap, '報名人數', registered);
writeCellByHeader_(sessionSheet, row, sessionMap, '簽到人數', checkedIn);
writeCellByHeader_(sessionSheet, row, sessionMap, '出席率', formatPercent_(checkedIn, registered));
writeCellByHeader_(sessionSheet, row, sessionMap, '課前任務完成率', formatPercent_(donePreTasks, preTasks.length));
}
updateActivitySessionCounts_();
}
function updateActivitySessionCounts_() {
const ss = getActiveSpreadsheet_();
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
for (let row = 2; row <= activitySheet.getLastRow(); row++) {
const activity = readActivityFromRow_(activitySheet, row, activityMap);
if (!activity.id) continue;
const sessionCount = getSessionsByActivityId_(activity.id).length;
const days = calculateActivityDays_(activity);
writeCellByHeader_(activitySheet, row, activityMap, '活動天數', days);
writeCellByHeader_(activitySheet, row, activityMap, '場次數', sessionCount);
if (!activity.isMultiDay || activity.isMultiDay === '待確認') {
writeCellByHeader_(activitySheet, row, activityMap, '是否多日活動', Number(days || 0) > 1 ? '是' : '否');
}
}
}
function updateActivitySessionFields_(context) {
const sessions = getSessionsByActivityId_(context.activity.id);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動天數', calculateActivityDays_(context.activity));
writeCellByHeader_(context.sheet, context.row, context.headerMap, '場次數', sessions.length);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '是否多日活動', sessions.length > 1 ? '是' : '否');
writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
}
function selectedSessionsContain_(selectedText, sessionId) {
return parseSelectedSessionIds_(selectedText).indexOf(String(sessionId || '')) >= 0;
}
function parseSelectedSessionIds_(text) {
const raw = String(text || '');
const direct = raw
.split(/[,,\n]/)
.map(function(part) {
const match = String(part).match(/S\d{2}/);
return match ? match[0] : '';
})
.filter(Boolean);
const inferred = [];
if (raw.indexOf('6月15日') >= 0 || raw.indexOf('6/15') >= 0) inferred.push('S01');
if (raw.indexOf('6月18日') >= 0 || raw.indexOf('6/18') >= 0) inferred.push('S02');
if (raw.indexOf('6月22日') >= 0 || raw.indexOf('6/22') >= 0) inferred.push('S03');
if (raw.indexOf('6月25日') >= 0 || raw.indexOf('6/25') >= 0) inferred.push('S04');
return direct.concat(inferred).filter(function(value, index, list) {
return value && list.indexOf(value) === index;
});
}
function buildRegistrationKey_(registration, sessionId) {
return [
registration.activityId || '',
registration.email || '',
registration.name || '',
sessionId || ''
].join('|');
}
function ensureAttendanceRowsForRegistration_(registration) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
const sessions = getRegistrationSessions_(registration);
if (!sessions.length) return 0;
const existing = buildExistingAttendanceKeys_(sheet);
const rows = [];
sessions.forEach(function(session) {
const key = buildRegistrationKey_(registration, session.sessionId);
if (existing[key]) return;
rows.push([
registration.activityId,
session.sessionId,
session.title,
session.date,
registration.name,
registration.unit,
registration.email,
key,
'是',
'未簽到',
'',
'未簽退',
'',
'',
''
]);
existing[key] = true;
});
if (rows.length) {
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_ATTENDANCE_HEADERS.length).setValues(rows);
}
return rows.length;
}
function getRegistrationSessions_(registration) {
const sessions = getSessionsByActivityId_(registration.activityId);
if (!sessions.length) return [];
const fullText = String(registration.fullParticipation || '');
if (fullText === '是' || fullText.indexOf('全程') >= 0) return sessions;
const selectedIds = parseSelectedSessionIds_(registration.selectedSessions);
if (!selectedIds.length) return sessions.length === 1 ? sessions : [];
return sessions.filter(function(session) {
return selectedIds.indexOf(session.sessionId) >= 0;
});
}
function buildExistingAttendanceKeys_(sheet) {
const rows = buildRowsByHeader_(sheet);
const keys = {};
rows.forEach(function(row) {
const key = row['報名資料Key'];
if (key) keys[String(key)] = true;
});
return keys;
}
function generateAttendanceRowsForActivity_(activityId) {
const ss = getActiveSpreadsheet_();
const registrations = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS)).filter(function(row) {
return String(row['活動ID'] || '') === String(activityId || '');
});
let added = 0;
registrations.forEach(function(row) {
added += ensureAttendanceRowsForRegistration_({
activityId: row['活動ID'],
name: row['學生姓名'] || row['學生或學員姓名'],
unit: row['班級'] || row['學校或單位'],
email: row['家長Email'] || row['Email'],
fullParticipation: row['是否全程參加'],
selectedSessions: row['選擇參加場次']
});
});
return added;
}
function updateDashboardKpis_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
const activityRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES));
const sessionRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS));
const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));
const taskOverviewRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW));
const documentRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS));
const checkedIn = attendanceRows.filter(function(row) { return row['簽到狀態'] === '已簽到'; }).length;
const multiDay = activityRows.filter(function(row) {
return row['是否多日活動'] === '是' || Number(row['活動天數'] || 0) > 1;
}).length;
const todayDueTasks = taskRows.filter(function(row) {
return isSameDate_(row['建議完成日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
}).length;
const overdueTasks = taskRows.filter(function(row) {
return calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
}).length;
const incompleteTasks = taskRows.filter(function(row) {
return row['狀態'] && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
}).length;
const highRiskSessions = sessionRows.filter(function(row) {
return row['風險等級'] === '高' || row['風險等級'] === '極高';
}).length;
const pendingDocuments = documentRows.filter(function(row) {
return row['承辦人確認'] !== '已確認' && row['承辦人確認'] !== '不適用';
}).length;
const notebookCount = activityRows.filter(function(row) { return row['NotebookLM資料包連結']; }).length;
const importantTasks = taskOverviewRows.filter(function(row) { return row['重要行事'] === '是'; });
const syncedCalendarTasks = importantTasks.filter(function(row) {
const status = String(row['日曆同步狀態'] || '');
return status === '已同步' || status === '已更新' || status === '已重新建立';
}).length;
const calendarReadSuccess = importantTasks.filter(function(row) {
return String(row['日曆回寫狀態'] || '').indexOf('已回寫') === 0;
}).length;
const calendarMissing = importantTasks.filter(function(row) {
return String(row['日曆回寫狀態'] || '') === '日曆事件遺失';
}).length;
const calendarFailed = importantTasks.filter(function(row) {
return String(row['日曆同步狀態'] || '').indexOf('同步失敗') === 0
|| String(row['日曆回寫狀態'] || '').indexOf('回寫失敗') === 0;
}).length;
const todayImportant = importantTasks.filter(function(row) {
return isSameDate_(row['到期日'] || row['日曆日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
}).length;
const delayedImportant = importantTasks.filter(function(row) {
return row['狀態'] === '延後'
|| calculateDateStatus_(row['到期日'] || row['日曆日期'], row['狀態'] === '已完成') === '逾期';
}).length;
dashboard.getRange('B4:B23').setValues([
[activityRows.length],
[multiDay],
[sessionRows.length],
[registrationRows.length],
[attendanceRows.length],
[formatPercent_(checkedIn, attendanceRows.length)],
[incompleteTasks],
[todayDueTasks],
[overdueTasks],
[highRiskSessions],
[pendingDocuments],
[notebookCount],
[nowText_()],
[importantTasks.length],
[syncedCalendarTasks],
[calendarReadSuccess],
[calendarMissing],
[calendarFailed],
[todayImportant],
[delayedImportant]
]);
}
function updateDashboardStatusLights_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
ensureDashboardVisualLayout_(dashboard);
const metricRows = dashboard.getRange('A4:B23').getValues();
const attendanceCount = Number(dashboard.getRange('B8').getValue() || 0);
const statusRows = [];
const statusBackgrounds = [];
metricRows.forEach(function(row) {
const metric = String(row[0] || '');
const value = row[1];
const result = buildDashboardStatusLight_(metric, value, attendanceCount);
statusRows.push([result.status, result.hint]);
statusBackgrounds.push([result.color, result.color]);
});
dashboard.getRange('C4:D23').setValues(statusRows).setBackgrounds(statusBackgrounds);
dashboard.getRange('B4:B23').setHorizontalAlignment('right');
dashboard.getRange('C4:C23').setHorizontalAlignment('center');
dashboard.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}
function buildDashboardStatusLight_(metric, value, attendanceCount) {
const number = Number(value || 0);
const percent = parseDashboardPercent_(value);
if (metric === '活動總數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有活動資料')
: dashboardLight_('gray', '⚪ 尚無資料', '請先新增活動');
}
if (metric === '多日活動數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有多日活動')
: dashboardLight_('gray', '⚪ 尚無資料', '目前沒有多日活動');
}
if (metric === '場次總數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有場次設定')
: dashboardLight_('yellow', '🟡 注意', '請產生或更新場次設定');
}
if (metric === '報名總人數') {
return number > 0
? dashboardLight_('green', '🟢 已有資料', '已有報名資料')
: dashboardLight_('yellow', '🟡 注意', '尚未同步報名資料');
}
if (metric === '場次簽到總筆數') {
return number > 0
? dashboardLight_('green', '🟢 已有資料', '已有簽到資料')
: dashboardLight_('yellow', '🟡 注意', '尚未產生或同步簽到資料');
}
if (metric === '平均出席率') {
if (!attendanceCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚未建立簽到統計');
if (percent >= 80) return dashboardLight_('green', '🟢 正常', '出席狀況良好');
if (percent >= 50) return dashboardLight_('yellow', '🟡 注意', '出席率偏低,建議檢查簽到狀態');
return dashboardLight_('red', '🔴 異常', '請更新場次簽到狀態');
}
if (metric === '未完成任務數') {
if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有未完成任務');
if (number <= 5) return dashboardLight_('yellow', '🟡 注意', '仍有少量任務待處理');
return dashboardLight_('red', '🔴 待處理', '未完成任務偏多,請查看任務總覽');
}
if (metric === '今日到期任務') {
return number === 0
? dashboardLight_('green', '🟢 正常', '今日沒有到期任務')
: dashboardLight_('yellow', '🟡 今日到期', '今日有任務需要完成');
}
if (metric === '延宕任務數') {
if (number === 0) return dashboardLight_('green', '🟢 正常', '沒有延宕任務');
if (number <= 3) return dashboardLight_('yellow', '🟡 注意', '有少量延宕任務');
return dashboardLight_('red', '🔴 逾期', '延宕任務偏多,請優先處理');
}
if (metric === '高風險場次數') {
return number === 0
? dashboardLight_('green', '🟢 無風險', '目前沒有高風險場次')
: dashboardLight_('red', '🔴 風險偏高', '請檢查場次人數、簽到率或資料缺漏');
}
if (metric === '待確認文件數') {
if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有待確認文件');
if (number <= 5) return dashboardLight_('yellow', '🟡 待確認', '仍有文件需要確認');
return dashboardLight_('red', '🔴 待處理', '待確認文件偏多,請整理行政文件');
}
if (metric === 'NotebookLM 匯出數') {
return number > 0
? dashboardLight_('green', '🟢 已匯出', '已有 NotebookLM 資料包')
: dashboardLight_('yellow', '🟡 尚未匯出', '活動結案前請匯出資料包');
}
if (metric === '最後更新時間') {
return value
? dashboardLight_('green', '🟢 已更新', '中控台已完成更新')
: dashboardLight_('gray', '⚪ 尚無資料', '尚未更新中控台');
}
if (metric === '重要行事總數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有重要行事')
: dashboardLight_('gray', '⚪ 尚無資料', '尚未建立重要行事');
}
if (metric === '已同步日曆數') {
const importantCount = Number(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.DASHBOARD).getRange('B17').getValue() || 0);
if (!importantCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚無重要行事');
return number >= importantCount
? dashboardLight_('green', '🟢 正常', '重要行事已同步')
: dashboardLight_('yellow', '🟡 注意', '仍有重要行事未同步');
}
if (metric === '日曆回寫成功數') {
return number > 0
? dashboardLight_('green', '🟢 正常', '已有日曆回寫紀錄')
: dashboardLight_('gray', '⚪ 尚無資料', '尚未從日曆回寫');
}
if (metric === '日曆事件遺失數') {
return number > 0
? dashboardLight_('red', '🔴 待處理', '有日曆事件遺失')
: dashboardLight_('green', '🟢 正常', '沒有日曆事件遺失');
}
if (metric === '日曆同步失敗數') {
return number > 0
? dashboardLight_('red', '🔴 待處理', '有日曆同步或回寫失敗')
: dashboardLight_('green', '🟢 正常', '沒有日曆同步失敗');
}
if (metric === '今日到期重要行事') {
return number > 0
? dashboardLight_('yellow', '🟡 今日到期', '今日有重要行事')
: dashboardLight_('green', '🟢 正常', '今日沒有到期重要行事');
}
if (metric === '延宕重要行事') {
return number > 0
? dashboardLight_('red', '🔴 逾期', '有延宕重要行事')
: dashboardLight_('green', '🟢 正常', '沒有延宕重要行事');
}
return dashboardLight_('gray', '⚪ 不適用', '此指標尚未定義燈號');
}
function dashboardLight_(level, status, hint) {
const colors = {
green: '#e8f5e9',
yellow: '#fff8e1',
red: '#ffebee',
gray: '#f5f5f5'
};
return {
status: status,
hint: hint,
color: colors[level] || colors.gray
};
}
function parseDashboardPercent_(value) {
if (typeof value === 'number') {
return value <= 1 ? value * 100 : value;
}
const text = String(value || '').trim();
if (!text) return 0;
const numeric = Number(text.replace('%', ''));
return isNaN(numeric) ? 0 : numeric;
}
function updateDashboardSessionOverview_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
dashboard.getRange('A27:K36').clearContent();
const activityNames = buildActivityNameMap_();
const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).slice(0, 10);
const rows = sessions.map(function(row) {
return [
row['活動ID'],
activityNames[row['活動ID']] || '',
row['場次ID'],
row['場次日期'],
row['場次名稱'],
row['報名人數'],
row['簽到人數'],
row['出席率'],
row['課前任務完成率'],
row['目前狀態'],
row['風險等級']
];
});
if (rows.length) dashboard.getRange(27, 1, rows.length, 11).setValues(rows);
}
function updateDashboardRedTasks_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
dashboard.getRange('A40:G51').clearContent();
const tasks = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
return row['狀態'] === '延後'
|| row['優先程度'] === '高' && row['狀態'] !== '已完成'
|| calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
}).slice(0, 12);
const rows = tasks.map(function(row) {
return [row['活動ID'], row['場次ID'], row['任務名稱'], row['負責人'], row['建議完成日期'], row['狀態'], row['卡關原因']];
});
if (rows.length) dashboard.getRange(40, 1, rows.length, 7).setValues(rows);
}
function updateDashboardTodaySessions_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
dashboard.getRange('A55:G66').clearContent();
const activityNames = buildActivityNameMap_();
const today = new Date();
const tomorrow = addDays_(today, 1);
const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).filter(function(row) {
return isSameDate_(row['場次日期'], today) || isSameDate_(row['場次日期'], tomorrow);
}).slice(0, 12);
const rows = sessions.map(function(row) {
return [
row['場次日期'],
activityNames[row['活動ID']] || '',
row['場次ID'],
row['場次名稱'],
valueOrPending_(row['開始時間']) + '-' + valueOrPending_(row['結束時間']),
row['地點或線上連結'],
row['目前狀態']
];
});
if (rows.length) dashboard.getRange(55, 1, rows.length, 7).setValues(rows);
}
function buildActivityNameMap_() {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
const map = {};
rows.forEach(function(row) {
if (row['活動ID']) map[row['活動ID']] = row['活動名稱'];
});
return map;
}
function buildSessionNameMap_() {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
const map = {};
rows.forEach(function(row) {
const activityId = row['活動ID'];
const sessionId = row['場次ID'];
if (activityId && sessionId) {
map[activityId + '::' + sessionId] = row['場次名稱'] || '';
}
});
return map;
}
/**
* ============================================================
* AttendanceFormService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:場次簽到表單與簽到回應同步。
*/
function createAttendanceFormsForSelectedActivity() {
const context = getSelectedActivityRowContext_();
const result = createAttendanceFormsForActivityContext_(context);
updateDashboard_();
alert_([
'場次簽到表單處理完成。',
'新建立:' + result.created + ' 張',
'已存在略過:' + result.skipped + ' 張',
'',
'簽到表單連結已回填到 02_場次設定。'
].join('\n'));
}
function createAttendanceFormsForActivityContext_(context) {
let activity = context.activity;
if (!activity.id) {
throw new Error('此活動缺少活動ID,請先建立活動資料包。');
}
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
activity = context.activity;
const attendanceFolder = packageContext.subfolders['01_報名表單與回覆'] || getCampRootFolder_();
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) {
sessions = generateSessionsForActivity_(activity);
}
if (!sessions.length) {
throw new Error('此活動尚未建立場次,無法產生簽到表單。');
}
const ss = getActiveSpreadsheet_();
const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
const sessionMap = ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
let created = 0;
let skipped = 0;
sessions.forEach(function(session) {
const row = findSessionRow_(sessionSheet, sessionMap, activity.id, session.sessionId);
if (!row) return;
const existingUrl = String(readCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結') || '').trim();
if (existingUrl) {
skipped++;
return;
}
const result = createAttendanceFormForSession_(ss, activity, session, attendanceFolder);
writeCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結', result.prefilledUrl);
writeCellByHeader_(sessionSheet, row, sessionMap, '目前狀態', '準備中');
appendDocumentListRow_(
activity,
'簽到表單',
result.title,
'本場研習或活動簽到使用',
result.prefilledUrl,
'Google Form',
'待使用',
'待確認',
'編輯連結:' + result.editUrl + '\nraw 回應分頁:' + result.responseSheetName
);
created++;
});
return { created: created, skipped: skipped };
}
function createAttendanceFormForSession_(ss, activity, session, folder) {
const title = [
'簽到表單',
activity.id,
session.sessionId,
sanitizeFileName_(activity.name)
].join('_');
const beforeSheetIds = getSheetIds_(ss);
const form = FormApp.create(title);
form.setDescription([
'活動名稱:' + activity.name,
'場次:' + session.sessionId + '|' + valueOrPending_(session.title),
'日期:' + formatDateValue_(session.date),
'時間:' + valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
'地點或連結:' + valueOrPending_(session.location),
'',
'本表單已自動帶入活動ID、場次ID與場次名稱。',
'請直接填寫姓名、單位與 Email 完成簽到。'
].join('\n'));
form.setCollectEmail(false);
form.setAllowResponseEdits(false);
form.addTextItem()
.setTitle('姓名')
.setRequired(true);
form.addTextItem()
.setTitle('單位')
.setRequired(true)
.setHelpText('請填寫學校、服務單位或所屬機關。');
form.addTextItem()
.setTitle('Email')
.setRequired(false)
.setHelpText('選填。若需研習時數或後續通知,建議填寫。');
form.addMultipleChoiceItem()
.setTitle('是否本人簽到')
.setChoiceValues(['本人簽到', '工作人員協助登記'])
.setRequired(true);
form.addParagraphTextItem().setTitle('備註').setRequired(false);
const activityIdItem = form.addTextItem()
.setTitle('活動ID')
.setRequired(true)
.setHelpText('系統已自動帶入,請勿修改。');
const sessionIdItem = form.addTextItem()
.setTitle('場次ID')
.setRequired(true)
.setHelpText('系統已自動帶入,請勿修改。');
const sessionNameItem = form.addTextItem()
.setTitle('場次名稱')
.setRequired(true)
.setHelpText('系統已自動帶入,請勿修改。');
form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
Utilities.sleep(1200);
const responseSheet = findNewSheetAfterFormDestination_(ss, beforeSheetIds);
const responseSheetName = renameSheetSafely_(
responseSheet,
'raw_簽到回應_' + activity.id + '_' + session.sessionId
);
if (responseSheet) {
try {
responseSheet.hideSheet();
} catch (error) {
Logger.log('隱藏簽到 raw 分頁失敗:' + error.message);
}
}
const prefilledUrl = form.createResponse()
.withItemResponse(activityIdItem.createResponse(activity.id))
.withItemResponse(sessionIdItem.createResponse(session.sessionId))
.withItemResponse(sessionNameItem.createResponse(session.title || session.sessionId))
.toPrefilledUrl();
moveFileToFolder_(form.getId(), folder);
return {
form: form,
title: title,
prefilledUrl: prefilledUrl,
editUrl: form.getEditUrl(),
responseSheetName: responseSheetName
};
}
function syncAttendanceFormResponses() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const attendanceSheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
const attendanceMap = ensureHeaders_(attendanceSheet, CAMP_ATTENDANCE_HEADERS);
let updated = 0;
let added = 0;
let skipped = 0;
ss.getSheets().forEach(function(sheet) {
if (sheet.getName().indexOf('raw_簽到回應_') !== 0) return;
if (sheet.getLastRow() < 2) return;
buildRowsByHeader_(sheet).forEach(function(row) {
const result = syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, row, sheet.getName());
if (result === 'updated') updated++;
else if (result === 'added') added++;
else skipped++;
});
});
updateSessionStats_();
updateDashboard_();
alert_([
'簽到狀態更新完成。',
'已更新既有名冊:' + updated + ' 筆',
'新增未在報名名單者:' + added + ' 筆',
'略過:' + skipped + ' 筆'
].join('\n'));
}
function syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, response, sourceSheetName) {
const activityId = String(response['活動ID'] || '').trim();
const sessionId = String(response['場次ID'] || '').trim();
const sessionName = String(response['場次名稱'] || '').trim();
const name = String(response['姓名'] || '').trim();
const unit = String(response['單位'] || '').trim();
const email = String(response['Email'] || '').trim();
const timestamp = response['時間戳記'] || response['Timestamp'] || new Date();
const note = String(response['備註'] || '').trim();
if (!activityId || !sessionId || !name) return 'skipped';
const existingRow = findAttendanceRowForResponse_(
attendanceSheet,
attendanceMap,
activityId,
sessionId,
name,
unit,
email
);
if (existingRow) {
const currentStatus = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態') || '');
if (currentStatus === '已簽到') return 'skipped';
writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態', '已簽到');
writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到時間', timestamp);
const oldNote = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註') || '').trim();
const newNote = [
oldNote,
note ? '簽到備註:' + note : '',
'簽到來源:' + sourceSheetName
].filter(Boolean).join('\n');
writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註', newNote);
return 'updated';
}
attendanceSheet.appendRow([
activityId,
sessionId,
sessionName,
'',
name,
unit,
email,
buildRegistrationKey_({
activityId: activityId,
name: name,
unit: unit,
email: email
}, sessionId),
'否',
'已簽到',
timestamp,
'未簽退',
'',
'',
[
'未在報名名單中,請人工確認。',
note ? '簽到備註:' + note : '',
'簽到來源:' + sourceSheetName
].filter(Boolean).join('\n')
]);
return 'added';
}
function findAttendanceRowForResponse_(sheet, map, activityId, sessionId, name, unit, email) {
let fallbackByName = 0;
for (let row = 2; row <= sheet.getLastRow(); row++) {
const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
if (rowActivityId !== String(activityId) || rowSessionId !== String(sessionId)) continue;
const rowName = String(readCellByHeader_(sheet, row, map, '學生或學員姓名') || '').trim();
const rowUnit = String(readCellByHeader_(sheet, row, map, '學校或單位') || '').trim();
const rowEmail = String(readCellByHeader_(sheet, row, map, 'Email') || '').trim();
if (email && rowEmail && rowEmail === email) return row;
if (rowName === name && rowUnit === unit) return row;
if (!fallbackByName && rowName === name) fallbackByName = row;
}
return fallbackByName;
}
function findSessionRow_(sheet, headerMap, activityId, sessionId) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
const rowActivityId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
const rowSessionId = String(readCellByHeader_(sheet, row, headerMap, '場次ID') || '').trim();
if (rowActivityId === String(activityId) && rowSessionId === String(sessionId)) return row;
}
return 0;
}
function getSheetIds_(ss) {
const ids = {};
ss.getSheets().forEach(function(sheet) {
ids[sheet.getSheetId()] = true;
});
return ids;
}
function findNewSheetAfterFormDestination_(ss, beforeSheetIds) {
const sheets = ss.getSheets();
for (let i = 0; i < sheets.length; i++) {
if (!beforeSheetIds[sheets[i].getSheetId()]) return sheets[i];
}
return null;
}
function renameSheetSafely_(sheet, desiredName) {
if (!sheet) return '';
const ss = sheet.getParent();
let finalName = desiredName;
let index = 2;
while (ss.getSheetByName(finalName)) {
finalName = desiredName + '_' + index;
index++;
}
sheet.setName(finalName);
return finalName;
}
/**
* ============================================================
* TaskOverviewService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:任務總覽。
*
* 將 05_任務追蹤 的 WBS 明細整理成承辦人每日檢視用的 05_任務總覽。
*/
function refreshTaskOverview() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const overviewSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
const existingOverviewState = buildExistingTaskOverviewState_(overviewSheet);
overviewSheet.clear();
setupTaskOverviewSheet_(overviewSheet);
const activityNameMap = buildActivityNameMap_();
const sessionNameMap = buildSessionNameMap_();
const taskSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
ensureHeaders_(taskSheet, CAMP_TASK_HEADERS);
const overviewRows = buildRowsByHeader_(taskSheet)
.filter(shouldIncludeTaskInOverview_)
.map(function(row) {
const activityId = row['活動ID'];
const sessionId = row['場次ID'] || '';
const taskId = buildTaskOverviewId_(row);
const preserved = existingOverviewState[taskId] || {};
const deadline = row['建議完成日期'] || '';
const completion = row['實際完成日期'] || '';
const task = {
'任務ID': taskId,
'活動ID': activityId,
'活動名稱': activityNameMap[activityId] || '',
'場次ID': sessionId,
'場次名稱': sessionNameMap[activityId + '::' + sessionId] || '',
'階段': row['階段'] || '',
'任務名稱': row['任務名稱'] || '',
'任務類型': row['階段'] || '',
'負責人': row['負責人'] || '',
'截止日': deadline,
'到期日': deadline,
'狀態': row['狀態'] || '未開始',
'優先程度': row['優先程度'] || '中',
'卡關原因/下一步': buildNextStepText_(row),
'備註': row['備註'] || row['卡關原因'] || '',
'重要行事': preserved['重要行事'] || (row['是否關鍵任務'] === '是' ? '是' : ''),
'日曆標題': preserved['日曆標題'] || '',
'日曆日期': preserved['日曆日期'] || deadline,
'開始時間': preserved['開始時間'] || '',
'結束時間': preserved['結束時間'] || '',
'日曆事件ID': preserved['日曆事件ID'] || '',
'日曆同步狀態': preserved['日曆同步狀態'] || '未同步',
'最後同步時間': preserved['最後同步時間'] || '',
'日曆回寫狀態': preserved['日曆回寫狀態'] || '未讀取',
'日曆最後讀取時間': preserved['日曆最後讀取時間'] || '',
'完成時間': completion,
'完成日': completion,
'最後更新時間': row['最後更新時間'] || ''
};
return CAMP_TASK_OVERVIEW_HEADERS.map(function(header) {
return task[header] || '';
});
});
if (overviewRows.length > 0) {
overviewSheet.getRange(2, 1, overviewRows.length, CAMP_TASK_OVERVIEW_HEADERS.length)
.setValues(overviewRows);
}
applyTaskOverviewFormatting_(overviewSheet);
syncImportantTasksToCalendar_();
moveTaskOverviewNearTaskSheet_();
updateDashboard_();
alert_('任務總覽已更新,共 ' + overviewRows.length + ' 筆。');
}
function buildExistingTaskOverviewState_(sheet) {
if (!sheet || sheet.getLastRow() <= 1) return {};
const map = buildHeaderMap_(sheet);
const stateHeaders = [
'重要行事',
'日曆標題',
'日曆日期',
'開始時間',
'結束時間',
'日曆事件ID',
'日曆同步狀態',
'最後同步時間',
'日曆回寫狀態',
'日曆最後讀取時間'
];
const state = {};
for (let row = 2; row <= sheet.getLastRow(); row++) {
const taskId = String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim();
if (!taskId) continue;
state[taskId] = {};
stateHeaders.forEach(function(header) {
state[taskId][header] = readCellByHeader_(sheet, row, map, header);
});
}
return state;
}
function buildTaskOverviewId_(row) {
const parts = [
row['活動ID'],
row['場次ID'] || 'ALL',
row['WBS編碼'] || row['階段'] || '',
row['任務名稱'] || ''
];
return 'TASK-' + parts.join('-').replace(/[^\w\u4e00-\u9fa5-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function shouldIncludeTaskInOverview_(row) {
const status = String(row['狀態'] || '');
const priority = String(row['優先程度'] || '');
const isKey = String(row['是否關鍵任務'] || '');
const taskName = String(row['任務名稱'] || '');
const phase = String(row['階段'] || '');
if (status === '不適用') {
return false;
}
if (status === '已完成') return true;
if (priority === '高') return true;
if (isKey === '是') return true;
if (status === '進行中' || status === '待確認' || status === '延後') return true;
const keywords = ['提醒', '簽到', '回饋', '成果', '講義', '連結', '通知', '名冊'];
return keywords.some(function(keyword) {
return taskName.indexOf(keyword) >= 0 || phase.indexOf(keyword) >= 0;
});
}
function buildNextStepText_(row) {
const stuck = String(row['卡關原因'] || '').trim();
if (stuck) return stuck;
const status = String(row['狀態'] || '');
const taskName = String(row['任務名稱'] || '');
const documentNeed = String(row['需要文件或表單'] || '');
if (status === '待確認') {
return '請承辦人或權責單位確認。';
}
if (status === '延後') {
return '請確認延後原因與新的完成日期。';
}
if (documentNeed) {
return '請確認或補齊:' + documentNeed;
}
if (taskName.indexOf('提醒') >= 0) {
return '請確認提醒內容與發送對象。';
}
if (taskName.indexOf('簽到') >= 0) {
return '請確認簽到方式、名單與時數需求。';
}
if (taskName.indexOf('成果') >= 0 || taskName.indexOf('回饋') >= 0) {
return '請整理回饋、照片、講義或成果摘要。';
}
return '請依任務內容處理。';
}
function formatTaskOverviewHeader_(sheet) {
sheet.getRange(1, 1, 1, CAMP_TASK_OVERVIEW_HEADERS.length)
.setFontWeight('bold')
.setBackground('#1e3a8a')
.setFontColor('#ffffff')
.setHorizontalAlignment('center')
.setVerticalAlignment('middle')
.setWrap(true);
sheet.setFrozenRows(1);
sheet.setFrozenColumns(5);
}
function applyTaskOverviewFormatting_(sheet) {
const lastRow = sheet.getLastRow();
const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
const existingFilter = sheet.getFilter();
if (existingFilter) existingFilter.remove();
if (lastRow < 2) {
sheet.autoResizeColumns(1, lastColumn);
return;
}
sheet.getRange(2, 1, lastRow - 1, lastColumn)
.setVerticalAlignment('middle')
.setWrap(true);
const statusCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1;
const priorityCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1;
const deadlineCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('到期日') + 1;
const calendarSyncCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆同步狀態') + 1;
const calendarReadCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆回寫狀態') + 1;
for (let row = 2; row <= lastRow; row++) {
const status = String(sheet.getRange(row, statusCol).getValue() || '');
const priority = String(sheet.getRange(row, priorityCol).getValue() || '');
const deadline = sheet.getRange(row, deadlineCol).getValue();
const isDone = status === '已完成' || status === '不適用';
const dateStatus = calculateDateStatus_(deadline, isDone);
if (status === '已完成') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
} else if (dateStatus === '逾期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
} else if (dateStatus === '即將到期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
} else {
sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
}
if (CAMP_TASK_STATUS_COLORS[status]) {
sheet.getRange(row, statusCol).setBackground(CAMP_TASK_STATUS_COLORS[status]);
}
if (CAMP_PRIORITY_COLORS[priority]) {
sheet.getRange(row, priorityCol).setBackground(CAMP_PRIORITY_COLORS[priority]);
}
if (calendarSyncCol > 0) {
sheet.getRange(row, calendarSyncCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarSyncCol).getValue()));
}
if (calendarReadCol > 0) {
sheet.getRange(row, calendarReadCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarReadCol).getValue()));
}
}
sheet.autoResizeColumns(1, lastColumn);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('活動名稱') + 1, 280);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('任務名稱') + 1, 220);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('卡關原因/下一步') + 1, 320);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('備註') + 1, 260);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆標題') + 1, 280);
sheet.getRange(1, 1, lastRow, lastColumn).createFilter();
}
function moveTaskOverviewNearTaskSheet_() {
const ss = getActiveSpreadsheet_();
const sheet = ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW);
if (!sheet) return;
ss.setActiveSheet(sheet);
ss.moveActiveSheet(CAMP_SHEET_ORDER.indexOf(CAMP_SHEETS.TASK_OVERVIEW) + 1);
}
function getCalendarStatusColor_(value) {
const text = String(value || '');
if (!text || text === '未讀取' || text === '尚無資料') return '#f5f5f5';
if (text.indexOf('失敗') >= 0 || text.indexOf('遺失') >= 0) return '#ffebee';
if (text.indexOf('需人工確認') >= 0 || text === '未同步' || text === '需重新同步') return '#fff8e1';
if (text.indexOf('已同步') >= 0 || text.indexOf('已更新') >= 0 || text.indexOf('已回寫') >= 0 || text.indexOf('已重新建立') >= 0) return '#e8f5e9';
return '#f5f5f5';
}
function handleTaskOverviewStatusEdit_(e) {
if (!e || !e.range) return;
const sheet = e.range.getSheet();
if (sheet.getName() !== CAMP_SHEETS.TASK_OVERVIEW) return;
if (e.range.getRow() <= 1 || e.range.getNumRows() !== 1 || e.range.getNumColumns() !== 1) return;
const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
const statusCol = map['狀態'];
if (!statusCol || e.range.getColumn() !== statusCol) return;
const row = e.range.getRow();
const newStatus = String(e.value || e.range.getValue() || '').trim();
const oldStatus = String(e.oldValue || '').trim();
const completionCol = map['完成時間'];
const completionDayCol = map['完成日'];
const updatedCol = map['最後更新時間'];
const timestamp = nowMinuteText_();
if (newStatus === '已完成') {
const currentCompletion = completionCol ? String(sheet.getRange(row, completionCol).getValue() || '').trim() : '';
if (completionCol && oldStatus !== '已完成' && !currentCompletion) {
sheet.getRange(row, completionCol).setValue(timestamp);
}
const currentCompletionDay = completionDayCol ? String(sheet.getRange(row, completionDayCol).getValue() || '').trim() : '';
if (completionDayCol && oldStatus !== '已完成' && !currentCompletionDay) {
sheet.getRange(row, completionDayCol).setValue(timestamp);
}
}
if (updatedCol) {
sheet.getRange(row, updatedCol).setValue(timestamp);
}
applyTaskOverviewRowFormatting_(sheet, row);
const task = readTaskOverviewRow_(sheet, row, map);
syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp);
if (task.activityId) {
refreshTaskProgress_(task.activityId, task.sessionId);
}
updateDashboard_();
}
function readTaskOverviewRow_(sheet, row, map) {
return {
activityId: String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim(),
activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || '').trim(),
sessionId: String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim(),
phase: String(readCellByHeader_(sheet, row, map, '階段') || '').trim(),
taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
lastUpdated: readCellByHeader_(sheet, row, map, '最後更新時間')
};
}
function applyTaskOverviewRowFormatting_(sheet, row) {
const map = buildHeaderMap_(sheet);
const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
const status = String(readCellByHeader_(sheet, row, map, '狀態') || '');
const priority = String(readCellByHeader_(sheet, row, map, '優先程度') || '');
const deadline = readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日');
const dateStatus = calculateDateStatus_(deadline, status === '已完成' || status === '不適用');
if (status === '已完成') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
} else if (dateStatus === '逾期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
} else if (dateStatus === '即將到期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
} else {
sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
}
if (CAMP_TASK_STATUS_COLORS[status] && map['狀態']) {
sheet.getRange(row, map['狀態']).setBackground(CAMP_TASK_STATUS_COLORS[status]);
}
if (CAMP_PRIORITY_COLORS[priority] && map['優先程度']) {
sheet.getRange(row, map['優先程度']).setBackground(CAMP_PRIORITY_COLORS[priority]);
}
if (map['日曆同步狀態']) {
sheet.getRange(row, map['日曆同步狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆同步狀態')));
}
if (map['日曆回寫狀態']) {
sheet.getRange(row, map['日曆回寫狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆回寫狀態')));
}
}
function syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp) {
if (!task.activityId || !task.taskName) return;
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
const map = ensureHeaders_(sheet, CAMP_TASK_HEADERS);
const row = findTaskRowForOverview_(sheet, map, task);
if (!row) return;
writeCellByHeader_(sheet, row, map, '狀態', newStatus);
writeCellByHeader_(sheet, row, map, '最後更新時間', timestamp);
if (newStatus === '已完成') {
const currentDoneAt = String(readCellByHeader_(sheet, row, map, '實際完成日期') || '').trim();
if (!currentDoneAt) {
writeCellByHeader_(sheet, row, map, '實際完成日期', task.completionTime || timestamp);
}
}
}
function findTaskRowForOverview_(sheet, map, task) {
let bestRow = 0;
let bestScore = -1;
const targetDeadline = normalizeTaskDateForCompare_(task.deadline);
for (let row = 2; row <= sheet.getLastRow(); row++) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
const phase = String(readCellByHeader_(sheet, row, map, '階段') || '').trim();
const taskName = String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim();
if (activityId !== task.activityId || sessionId !== task.sessionId || phase !== task.phase || taskName !== task.taskName) {
continue;
}
let score = 4;
const owner = String(readCellByHeader_(sheet, row, map, '負責人') || '').trim();
const deadline = normalizeTaskDateForCompare_(readCellByHeader_(sheet, row, map, '建議完成日期'));
if (task.owner && owner === task.owner) score++;
if (targetDeadline && deadline === targetDeadline) score++;
if (score > bestScore) {
bestScore = score;
bestRow = row;
}
}
return bestRow;
}
function normalizeTaskDateForCompare_(value) {
const date = parseDate_(value);
return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd') : String(value || '').trim();
}
function refreshTaskProgress_(activityId, sessionId) {
if (!activityId) return;
const ss = getActiveSpreadsheet_();
const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
return String(row['活動ID'] || '') === String(activityId || '')
&& String(row['任務名稱'] || '').trim()
&& String(row['狀態'] || '') !== '不適用';
});
const total = taskRows.length;
const done = taskRows.filter(function(row) {
return String(row['狀態'] || '') === '已完成';
}).length;
const activityRate = formatPercent_(done, total);
refreshActivityTaskProgress_(activityId, activityRate);
if (sessionId && sessionId !== 'ALL' && sessionId !== 'POST') {
const sessionRows = taskRows.filter(function(row) {
return String(row['場次ID'] || '') === String(sessionId || '');
});
refreshSessionTaskProgress_(activityId, sessionId, sessionRows);
}
}
function refreshActivityTaskProgress_(activityId, activityRate) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (String(readCellByHeader_(sheet, row, map, '活動ID') || '') !== String(activityId || '')) continue;
writeCellByHeader_(sheet, row, map, '任務完成率', activityRate);
writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
return;
}
}
function refreshSessionTaskProgress_(activityId, sessionId, sessionTaskRows) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
const map = ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
const total = sessionTaskRows.length;
const done = sessionTaskRows.filter(function(row) {
return String(row['狀態'] || '') === '已完成';
}).length;
const rate = formatPercent_(done, total);
const status = total === 0 ? '無任務' : (done === total ? '已完成' : (done === 0 ? '未開始' : '進行中'));
for (let row = 2; row <= sheet.getLastRow(); row++) {
const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '');
if (rowActivityId !== String(activityId || '') || rowSessionId !== String(sessionId || '')) continue;
writeCellByHeader_(sheet, row, map, '任務完成率', rate);
writeCellByHeader_(sheet, row, map, '任務完成狀態', status);
return;
}
}
/**
* ============================================================
* CalendarTaskService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:關鍵任務與 Google Calendar 同步。
*/
function syncImportantTasksToCalendar() {
const result = syncImportantTasksToCalendar_();
updateDashboard_();
alert_('重要行事同步完成。建立:' + result.created + ',更新:' + result.updated + ',略過:' + result.skipped + ',失敗:' + result.failed + '。');
}
function syncCalendarBackToTaskOverview() {
const result = syncTaskStatusFromCalendar_();
updateDashboard_();
alert_('已從 Google Calendar 同步任務狀態。回寫:' + result.updated + ',遺失:' + result.missing + ',失敗:' + result.failed + '。');
}
function createCalendarSyncTrigger() {
createCalendarSyncTrigger_();
alert_('已建立日曆同步觸發器:每 30 分鐘從 Google Calendar 回寫任務狀態。');
}
function removeCalendarSyncTrigger() {
const removed = removeCalendarSyncTrigger_();
alert_('已移除日曆同步觸發器:' + removed + ' 個。');
}
function syncImportantTasksToCalendar_() {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
const calendar = getTaskCalendar_();
const result = { created: 0, updated: 0, skipped: 0, failed: 0 };
if (!calendar) {
writeCalendarSyncStatusForAll_(sheet, map, '同步失敗:找不到日曆');
result.failed = Math.max(0, sheet.getLastRow() - 1);
return result;
}
for (let row = 2; row <= sheet.getLastRow(); row++) {
const task = readCalendarTaskFromRow_(sheet, row, map);
if (!task.taskName) continue;
const important = isImportantCalendarTask_(task);
writeCellByHeader_(sheet, row, map, '重要行事', important ? '是' : '否');
if (!important) {
result.skipped++;
continue;
}
const date = getTaskCalendarDate_(task);
if (!date) {
writeCellByHeader_(sheet, row, map, '日曆同步狀態', '同步失敗:缺少日期');
result.failed++;
continue;
}
try {
const title = buildCalendarTitle_(task);
const description = buildCalendarDescription_(task);
const eventInfo = upsertTaskCalendarEvent_(calendar, task, title, description, date);
writeCellByHeader_(sheet, row, map, '日曆標題', title);
writeCellByHeader_(sheet, row, map, '日曆日期', date);
writeCellByHeader_(sheet, row, map, '日曆事件ID', eventInfo.event.getId());
writeCellByHeader_(sheet, row, map, '日曆同步狀態', eventInfo.status);
writeCellByHeader_(sheet, row, map, '最後同步時間', nowText_());
if (eventInfo.status === '已同步' || eventInfo.status === '已重新建立') result.created++;
if (eventInfo.status === '已更新') result.updated++;
} catch (error) {
writeCellByHeader_(sheet, row, map, '日曆同步狀態', classifyCalendarError_(error, '同步失敗'));
result.failed++;
}
}
applyTaskOverviewFormatting_(sheet);
return result;
}
function syncTaskStatusFromCalendar_() {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
const calendar = getTaskCalendar_();
const result = { updated: 0, missing: 0, failed: 0 };
if (!calendar) {
writeCalendarReadStatusForAll_(sheet, map, '回寫失敗:其他錯誤');
result.failed = Math.max(0, sheet.getLastRow() - 1);
return result;
}
for (let row = 2; row <= sheet.getLastRow(); row++) {
const task = readCalendarTaskFromRow_(sheet, row, map);
if (!task.eventId) continue;
try {
const event = calendar.getEventById(task.eventId);
if (!event) {
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', '日曆事件遺失');
writeCellByHeader_(sheet, row, map, '日曆同步狀態', '需重新同步');
writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
result.missing++;
continue;
}
const changes = [];
const parsedStatus = parseTaskStatusFromCalendarEvent_(event);
if (parsedStatus && parsedStatus !== task.status) {
writeCellByHeader_(sheet, row, map, '狀態', parsedStatus);
changes.push('狀態更新');
if (parsedStatus === '已完成' && task.status !== '已完成') {
if (!task.completionTime) writeCellByHeader_(sheet, row, map, '完成時間', nowMinuteText_());
if (!task.completionDay) writeCellByHeader_(sheet, row, map, '完成日', nowMinuteText_());
}
}
const eventStart = event.getStartTime();
const eventEnd = event.getEndTime();
if (eventStart) {
writeCellByHeader_(sheet, row, map, '日曆日期', eventStart);
writeCellByHeader_(sheet, row, map, '到期日', eventStart);
changes.push('日期更新');
}
if (!event.isAllDayEvent()) {
writeCellByHeader_(sheet, row, map, '開始時間', formatTaskTime_(eventStart));
writeCellByHeader_(sheet, row, map, '結束時間', formatTaskTime_(eventEnd));
changes.push('時間更新');
}
const note = parseCalendarField_(event.getDescription(), '任務備註');
if (note) {
writeCellByHeader_(sheet, row, map, '備註', note);
changes.push('備註更新');
}
writeCellByHeader_(sheet, row, map, '日曆標題', event.getTitle());
writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', changes.length ? '已回寫:' + changes.join('、') : '已回寫');
applyTaskOverviewRowFormatting_(sheet, row);
result.updated++;
} catch (error) {
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', classifyCalendarError_(error, '回寫失敗'));
result.failed++;
}
}
return result;
}
function isImportantCalendarTask_(task) {
if (!getTaskCalendarDate_(task)) return false;
const text = [task.type, task.taskName, task.note].join(' ');
const exclude = ['同步報名資料與統計', '更新場次統計', '更新任務總覽', '更新期限狀態', '一鍵更新場次簽到狀態', '產生 50 筆假資料', '系統初始化', '建立示範資料'];
if (exclude.some(function(keyword) { return text.indexOf(keyword) >= 0; })) return false;
const include = ['活動日期', '活動開始', '活動結束', '正式活動', '營隊日期', '研習日期', '報名開始', '報名截止', '錄取公告', '候補通知', '場次日期', '梯次日期', '課程日期', '研習場次', '活動場次', '行前通知', '寄送通知', '提醒信', '錄取通知', '簽到表單建立', '簽到準備', '簽到 QR Code', '簽到測試', '活動計畫', '行政文件', '成果報告', '成果檢查', '結案', '核銷', 'NotebookLM 匯出', '提醒', '通知', '簽到', '成果', '報名'];
return task.isKey === '是' || include.some(function(keyword) { return text.indexOf(keyword) >= 0; });
}
function buildCalendarTitle_(task) {
const text = [task.type, task.taskName].join(' ');
let prefix = '【任務】';
if (/活動|營隊|研習場次|正式/.test(text)) prefix = '【活動】';
if (/報名截止|報名開始|錄取|候補/.test(text)) prefix = '【報名截止】';
if (/通知|提醒信|行前/.test(text)) prefix = '【通知】';
if (/簽到/.test(text)) prefix = '【簽到準備】';
if (/文件|計畫|成果報告/.test(text)) prefix = '【文件】';
if (/結案|核銷|NotebookLM/.test(text)) prefix = '【結案】';
return prefix + valueOrPending_(task.activityName) + (task.sessionName ? '|' + task.sessionName : '|' + task.taskName);
}
function buildCalendarDescription_(task) {
return [
'此事件由營隊活動管理中樞自動建立。',
'',
'【系統資料】',
'任務ID:' + task.taskId,
'活動ID:' + task.activityId,
task.sessionId ? '場次ID:' + task.sessionId : '',
'',
'【任務資料】',
task.activityName ? '活動名稱:' + task.activityName : '',
task.sessionName ? '場次名稱:' + task.sessionName : '',
'任務名稱:' + task.taskName,
task.type ? '任務類型:' + task.type : '',
task.owner ? '負責人:' + task.owner : '',
'任務狀態:' + valueOrPending_(task.status),
task.deadline ? '到期日:' + formatDateValue_(task.deadline) : '',
'',
'【可由日曆修改】',
'任務狀態:' + valueOrPending_(task.status),
'任務備註:' + (task.note || ''),
'',
'【相關連結】',
task.folderUrl ? '資料夾連結:' + task.folderUrl : '',
task.registrationUrl ? '報名表連結:' + task.registrationUrl : '',
task.attendanceUrl ? '簽到表連結:' + task.attendanceUrl : ''
].filter(function(line) { return line !== ''; }).join('\n');
}
function getTaskCalendarDate_(task) {
return parseDate_(task.calendarDate) || parseDate_(task.deadline) || parseDate_(task.fallbackDate);
}
function parseTaskStatusFromCalendarEvent_(event) {
const description = event.getDescription() || '';
const fromDescription = parseCalendarField_(description, '任務狀態');
return normalizeCalendarTaskStatus_(fromDescription) || parseCalendarStatusFromTitle_(event.getTitle());
}
function createCalendarSyncTrigger_() {
removeCalendarSyncTrigger_();
ScriptApp.newTrigger('syncCalendarBackToTaskOverview')
.timeBased()
.everyMinutes(30)
.create();
}
function removeCalendarSyncTrigger_() {
let removed = 0;
ScriptApp.getProjectTriggers().forEach(function(trigger) {
if (trigger.getHandlerFunction() === 'syncCalendarBackToTaskOverview') {
ScriptApp.deleteTrigger(trigger);
removed++;
}
});
return removed;
}
function getTaskCalendar_() {
try {
if (CAMP_CONFIG.CALENDAR_ID) {
return CalendarApp.getCalendarById(CAMP_CONFIG.CALENDAR_ID);
}
return CalendarApp.getDefaultCalendar();
} catch (error) {
return null;
}
}
function upsertTaskCalendarEvent_(calendar, task, title, description, date) {
let event = task.eventId ? calendar.getEventById(task.eventId) : null;
let status = '已更新';
if (!event) {
event = createTaskCalendarEvent_(calendar, task, title, description, date);
return { event: event, status: task.eventId ? '已重新建立' : '已同步' };
}
event.setTitle(title);
event.setDescription(description);
applyTaskCalendarEventTime_(event, task, date);
return { event: event, status: status };
}
function createTaskCalendarEvent_(calendar, task, title, description, date) {
const start = buildTaskDateTime_(date, task.startTime);
const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
if (task.startTime) {
return calendar.createEvent(title, start, end, { description: description });
}
return calendar.createAllDayEvent(title, date, { description: description });
}
function applyTaskCalendarEventTime_(event, task, date) {
if (task.startTime) {
const start = buildTaskDateTime_(date, task.startTime);
const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
event.setTime(start, end);
} else {
event.setAllDayDate(date);
}
}
function readCalendarTaskFromRow_(sheet, row, map) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
const activity = getActivityCalendarContext_(activityId);
const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
const session = getSessionCalendarContext_(activityId, sessionId);
return {
row: row,
taskId: String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim(),
activityId: activityId,
activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || activity.name || '').trim(),
sessionId: sessionId,
sessionName: String(readCellByHeader_(sheet, row, map, '場次名稱') || session.name || '').trim(),
type: String(readCellByHeader_(sheet, row, map, '任務類型') || readCellByHeader_(sheet, row, map, '階段') || '').trim(),
taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
completionDay: readCellByHeader_(sheet, row, map, '完成日'),
note: String(readCellByHeader_(sheet, row, map, '備註') || readCellByHeader_(sheet, row, map, '卡關原因/下一步') || '').trim(),
isKey: String(readCellByHeader_(sheet, row, map, '重要行事') || '').trim(),
calendarTitle: String(readCellByHeader_(sheet, row, map, '日曆標題') || '').trim(),
calendarDate: readCellByHeader_(sheet, row, map, '日曆日期'),
startTime: String(readCellByHeader_(sheet, row, map, '開始時間') || '').trim(),
endTime: String(readCellByHeader_(sheet, row, map, '結束時間') || '').trim(),
eventId: String(readCellByHeader_(sheet, row, map, '日曆事件ID') || '').trim(),
fallbackDate: session.date || activity.date,
folderUrl: activity.folderUrl,
registrationUrl: activity.registrationUrl,
attendanceUrl: session.attendanceUrl
};
}
function getActivityCalendarContext_(activityId) {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
const row = rows.filter(function(item) { return String(item['活動ID'] || '') === String(activityId || ''); })[0] || {};
return {
name: row['活動名稱'] || '',
date: row['活動日期'] || row['活動開始日'],
folderUrl: row['專案資料夾連結'] || '',
registrationUrl: row['報名表單連結'] || ''
};
}
function getSessionCalendarContext_(activityId, sessionId) {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
const row = rows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId || '') && String(item['場次ID'] || '') === String(sessionId || '');
})[0] || {};
return {
name: row['場次名稱'] || '',
date: row['場次日期'],
attendanceUrl: row['簽到表單連結'] || ''
};
}
function buildTaskDateTime_(date, timeText) {
const base = parseDate_(date);
const parts = String(timeText || '09:00').split(':');
return new Date(base.getFullYear(), base.getMonth(), base.getDate(), Number(parts[0] || 9), Number(parts[1] || 0), 0);
}
function addHours_(date, hours) {
return new Date(date.getTime() + Number(hours || 1) * 60 * 60 * 1000);
}
function formatTaskTime_(date) {
return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'HH:mm') : '';
}
function parseCalendarField_(description, label) {
const lines = String(description || '').split(/\r?\n/);
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.indexOf(label + ':') === 0) return line.replace(label + ':', '').trim();
}
return '';
}
function parseCalendarStatusFromTitle_(title) {
const match = String(title || '').match(/^\[(完成|已完成|進行中|待確認|延宕|延後|取消|不適用)\]/);
return match ? normalizeCalendarTaskStatus_(match[1]) : '';
}
function normalizeCalendarTaskStatus_(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return '';
if (text === '完成' || text === '已完成' || text === 'done' || text === 'ok') return '已完成';
if (text === '進行中') return '進行中';
if (text === '待確認') return '待確認';
if (text === '延宕' || text === '延後' || text === 'delay' || text === 'delayed') return '延後';
if (text === '取消' || text === 'cancel') return '取消';
if (text === '不適用') return '不適用';
if (CAMP_TASK_STATUS_VALUES.indexOf(value) >= 0) return value;
return '';
}
function classifyCalendarError_(error, prefix) {
const message = String(error && error.message || '');
if (message.indexOf('Authorization') >= 0 || message.indexOf('permission') >= 0 || message.indexOf('權限') >= 0) {
return prefix + ':權限不足';
}
return prefix + ':其他錯誤';
}
function writeCalendarSyncStatusForAll_(sheet, map, status) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
writeCellByHeader_(sheet, row, map, '日曆同步狀態', status);
}
}
function writeCalendarReadStatusForAll_(sheet, map, status) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', status);
}
}
/**
* ============================================================
* ActivityIntakeForm.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:活動新增表單。
*
* Google Form 的回應會寫入同一份 Spreadsheet,再由同步函式整理到
* 「01_活動清單」。這樣承辦人可以用表單新增活動,後續仍以活動清單
* 作為單一管理入口。
*/
function createActivityIntakeForm() {
const result = createActivityIntakeForm_();
alert_('活動新增表單已建立或確認存在:\n' + result.formUrl + '\n\n表單回應後,請執行「使用表單新增活動時才按:匯入活動回應」。若已使用新北示範資料,則不需要再匯入活動表單回應。');
return result.formUrl;
}
function createActivityIntakeForm_() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const props = PropertiesService.getScriptProperties();
const existingFormId = props.getProperty('CAMP_ACTIVITY_INTAKE_FORM_ID');
if (existingFormId) {
try {
const form = FormApp.openById(existingFormId);
ensureActivityIntakeFormItems_(form);
form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
writeActivityIntakeFormLinks_(ss, form);
ensureActivityIntakeSubmitTrigger_(ss);
return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
} catch (error) {
Logger.log('既有活動新增表單無法開啟,將重新建立:' + error.message);
}
}
const beforeSheetIds = ss.getSheets().map(function(sheet) {
return sheet.getSheetId();
});
const form = FormApp.create('營隊/活動新增表單');
form.setDescription([
'請填寫一筆營隊或活動資料。',
'表單回應會寫入本中控 Spreadsheet,並可同步到「01_活動清單」。',
'',
CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
].join('\n'));
form.addTextItem().setTitle('活動名稱').setRequired(true);
form.addListItem().setTitle('活動類型').setChoiceValues(['營隊', '校外教學', '校內活動', '研習', '競賽', '成果發表', '其他']).setRequired(true);
form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
form.addDateItem().setTitle('活動日期').setHelpText('若是單日活動可填此欄;若是多日活動,請改填活動開始日與活動結束日。').setRequired(false);
form.addDateItem().setTitle('活動開始日').setRequired(false);
form.addDateItem().setTitle('活動結束日').setRequired(false);
form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
form.addDateItem().setTitle('報名截止日').setRequired(false);
form.addDateItem().setTitle('同意書回收日').setRequired(false);
form.addTextItem().setTitle('活動地點').setRequired(false);
form.addTextItem().setTitle('參加對象').setRequired(false);
form.addTextItem().setTitle('預估人數').setRequired(false);
form.addTextItem().setTitle('承辦處室').setRequired(false);
form.addTextItem().setTitle('承辦人').setRequired(false);
form.addTextItem().setTitle('承辦人Email').setRequired(false);
form.addMultipleChoiceItem().setTitle('是否收費').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('費用說明').setRequired(false);
form.addListItem().setTitle('交通方式').setChoiceValues(['校內活動無交通', '步行', '遊覽車', '大眾運輸', '家長接送', '其他或待確認']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要保險').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要家長同意書').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('備註').setRequired(false);
form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
Utilities.sleep(1000);
const responseSheet = resolveActivityIntakeResponseSheet_(ss, beforeSheetIds);
props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_ID', form.getId());
props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_URL', form.getPublishedUrl());
props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_EDIT_URL', form.getEditUrl());
props.setProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME', responseSheet.getName());
moveFileToFolder_(form.getId(), getCampRootFolder_());
writeActivityIntakeFormLinks_(ss, form);
ensureActivityIntakeSubmitTrigger_(ss);
return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
}
function syncActivityIntakeResponsesToActivityList() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const responseSheet = getActivityIntakeResponseSheet_(ss);
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
const responseMap = ensureActivityIntakeSyncColumns_(responseSheet);
const existingKeys = buildExistingActivityIntakeKeys_(activitySheet, activityMap);
let added = 0;
let skipped = 0;
for (let row = 2; row <= responseSheet.getLastRow(); row++) {
const currentStatus = String(readCellByHeader_(responseSheet, row, responseMap, '同步狀態') || '').trim();
if (currentStatus && currentStatus !== '同步失敗') {
skipped++;
continue;
}
const timestamp = readCellByHeader_(responseSheet, row, responseMap, '時間戳記') || readCellByHeader_(responseSheet, row, responseMap, 'Timestamp');
const name = String(readCellByHeader_(responseSheet, row, responseMap, '活動名稱') || '').trim();
if (!name) {
writeActivityIntakeSyncNote_(responseSheet, row, responseMap, '已略過', '缺少活動名稱,未同步。');
skipped++;
continue;
}
const key = buildActivityIntakeKey_(name, timestamp);
if (existingKeys[key]) {
writeActivityIntakeSyncNote_(
responseSheet,
row,
responseMap,
'已略過:同一表單回應已同步',
'活動名稱與表單時間戳記已存在於 01_活動清單備註欄。'
);
skipped++;
continue;
}
const existingActivity = findExistingActivityByName_(activitySheet, activityMap, name);
if (existingActivity) {
writeActivityIntakeSyncNote_(
responseSheet,
row,
responseMap,
'已略過:同名活動已存在',
'已存在活動ID:' + existingActivity.id + ';活動名稱:' + existingActivity.name + '。未新增第二筆。'
);
appendNoteToExistingActivity_(activitySheet, activityMap, existingActivity.row, [
'活動新增表單有同名資料,系統已略過避免重複。',
'表單時間戳記:' + formatDateIfNeeded_(timestamp),
'略過時間:' + nowText_()
].join('\n'));
skipped++;
continue;
}
const activityId = generateActivityId_();
const startDate = readCellByHeader_(responseSheet, row, responseMap, '活動開始日') || readCellByHeader_(responseSheet, row, responseMap, '活動日期');
const endDate = readCellByHeader_(responseSheet, row, responseMap, '活動結束日') || startDate;
const activityDraft = {
id: activityId,
activityDate: readCellByHeader_(responseSheet, row, responseMap, '活動日期'),
startDate: startDate,
endDate: endDate
};
const activityDays = calculateActivityDays_(activityDraft);
const object = {};
object['活動ID'] = activityId;
object['活動名稱'] = name;
object['活動類型'] = readCellByHeader_(responseSheet, row, responseMap, '活動類型');
object['報名表模板類型'] = readCellByHeader_(responseSheet, row, responseMap, '報名表模板類型') || '自動判斷';
object['活動日期'] = readCellByHeader_(responseSheet, row, responseMap, '活動日期');
object['活動開始日'] = startDate;
object['活動結束日'] = endDate;
object['每日預設開始時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設開始時間') || '09:00';
object['每日預設結束時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設結束時間') || '12:00';
object['活動天數'] = activityDays;
object['場次數'] = 0;
object['是否多日活動'] = readCellByHeader_(responseSheet, row, responseMap, '是否多日活動') || (Number(activityDays || 0) > 1 ? '是' : '否');
object['是否開放部分場次報名'] = readCellByHeader_(responseSheet, row, responseMap, '是否開放部分場次報名') || '否';
object['是否需要每日簽到'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要每日簽到') || '是';
object['是否需要課後回饋'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要課後回饋') || '是';
object['是否需要成果繳交'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要成果繳交') || '是';
object['報名截止日'] = readCellByHeader_(responseSheet, row, responseMap, '報名截止日');
object['同意書回收日'] = readCellByHeader_(responseSheet, row, responseMap, '同意書回收日');
object['活動地點'] = readCellByHeader_(responseSheet, row, responseMap, '活動地點');
object['參加對象'] = readCellByHeader_(responseSheet, row, responseMap, '參加對象');
object['預估人數'] = readCellByHeader_(responseSheet, row, responseMap, '預估人數');
object['承辦處室'] = readCellByHeader_(responseSheet, row, responseMap, '承辦處室');
object['承辦人'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人');
object['承辦人Email'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人Email');
object['是否收費'] = readCellByHeader_(responseSheet, row, responseMap, '是否收費');
object['費用說明'] = readCellByHeader_(responseSheet, row, responseMap, '費用說明');
object['交通方式'] = readCellByHeader_(responseSheet, row, responseMap, '交通方式');
object['是否需要保險'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要保險');
object['是否需要家長同意書'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要家長同意書');
object['報名人數'] = 0;
object['同意參加'] = 0;
object['不參加'] = 0;
object['特殊需求數'] = 0;
object['目前狀態'] = '表單新增';
object['最近更新時間'] = nowText_();
object['備註'] = '由活動新增表單同步;表單時間戳記:' + formatDateIfNeeded_(timestamp);
activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) { return object[header] || ''; }));
writeActivityIntakeSyncNote_(
responseSheet,
row,
responseMap,
'已新增',
'已新增到 01_活動清單,活動ID:' + activityId
);
const newRow = activitySheet.getLastRow();
const activity = readActivityFromRow_(activitySheet, newRow, activityMap);
generateSessionsForActivity_(activity);
updateActivitySessionFields_({ sheet: activitySheet, row: newRow, headerMap: activityMap, activity: activity });
existingKeys[key] = true;
added++;
}
updateDashboard_();
alert_('活動表單同步完成。新增到 01_活動清單:' + added + ' 筆;略過:' + skipped + ' 筆。');
return added;
}
function writeActivityIntakeFormLinks_(ss, form) {
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
ensureDashboardVisualLayout_(dashboard);
dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
dashboard.getRange('G4').setValue(form.getPublishedUrl()).setWrap(true);
dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
dashboard.getRange('G5').setValue(form.getEditUrl()).setWrap(true);
dashboard.getRange('F6').setValue('操作提醒').setFontWeight('bold');
dashboard.getRange('G6').setValue('如果已使用「新增示範資料:新北AI研習」,不需要再執行「匯入活動新增表單回應到活動清單」。').setWrap(true);
const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
ensureHeaders_(settings, CAMP_SETTINGS_HEADERS);
upsertSettingRow_(settings, '活動新增表單', form.getPublishedUrl(), '承辦人填寫後可同步到 01_活動清單');
upsertSettingRow_(settings, '活動新增表單編輯', form.getEditUrl(), '講師或管理者調整表單欄位使用');
}
function getActivityIntakeResponseSheet_(ss) {
const savedName = PropertiesService.getScriptProperties().getProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME');
if (savedName) {
const sheet = ss.getSheetByName(savedName);
if (sheet && looksLikeActivityIntakeResponseSheet_(sheet)) return sheet;
}
const sheets = ss.getSheets();
for (let i = 0; i < sheets.length; i++) {
if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
}
throw new Error('找不到活動新增表單回應分頁。請先執行「建立/開啟活動新增表單」,並送出至少一筆表單或確認表單已連結此試算表。');
}
function resolveActivityIntakeResponseSheet_(ss, beforeSheetIds) {
const before = {};
beforeSheetIds.forEach(function(id) {
before[id] = true;
});
const sheets = ss.getSheets();
for (let i = 0; i < sheets.length; i++) {
if (!before[sheets[i].getSheetId()] && looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
}
for (let i = 0; i < sheets.length; i++) {
if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
}
return sheets[sheets.length - 1];
}
function looksLikeActivityIntakeResponseSheet_(sheet) {
if (sheet.getLastColumn() < 6) return false;
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
return headers.indexOf('活動名稱') >= 0
&& headers.indexOf('活動類型') >= 0
&& headers.indexOf('承辦人Email') >= 0
&& headers.indexOf('是否需要家長同意書') >= 0;
}
function ensureActivityIntakeFormItems_(form) {
const titles = {};
form.getItems().forEach(function(item) {
titles[item.getTitle()] = true;
});
if (!titles['活動開始日']) form.addDateItem().setTitle('活動開始日').setRequired(false);
if (!titles['報名表模板類型']) form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
if (!titles['活動結束日']) form.addDateItem().setTitle('活動結束日').setRequired(false);
if (!titles['每日預設開始時間']) form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
if (!titles['每日預設結束時間']) form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
if (!titles['是否多日活動']) form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
if (!titles['是否開放部分場次報名']) form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
if (!titles['是否需要每日簽到']) form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
if (!titles['是否需要課後回饋']) form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
if (!titles['是否需要成果繳交']) form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
}
function buildExistingActivityIntakeKeys_(activitySheet, activityMap) {
const keys = {};
for (let row = 2; row <= activitySheet.getLastRow(); row++) {
const name = readCellByHeader_(activitySheet, row, activityMap, '活動名稱');
const note = String(readCellByHeader_(activitySheet, row, activityMap, '備註') || '');
const match = note.match(/表單時間戳記:([^\n]+)/);
const timestamp = match ? match[1] : '';
if (name && timestamp) {
keys[buildActivityIntakeKey_(name, timestamp)] = true;
}
}
return keys;
}
function buildActivityIntakeKey_(name, timestamp) {
return String(name || '').trim() + '|' + String(timestamp || '').trim();
}
function upsertSettingRow_(sheet, key, value, note) {
const map = ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (readCellByHeader_(sheet, row, map, '設定項目') === key) {
writeCellByHeader_(sheet, row, map, '設定值', value);
writeCellByHeader_(sheet, row, map, '說明', note);
return;
}
}
sheet.appendRow([key, value, note]);
}
function ensureActivityIntakeSubmitTrigger_(ss) {
const functionName = 'syncActivityIntakeResponsesToActivityList';
const exists = ScriptApp.getProjectTriggers().some(function(trigger) {
return trigger.getHandlerFunction() === functionName
&& trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT;
});
if (!exists) {
ScriptApp.newTrigger(functionName).forSpreadsheet(ss).onFormSubmit().create();
}
}
function normalizeActivityName_(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/[\s ]/g, '')
.replace(/[||\--—–_::,,。.、//()()「」『』【】\[\]]/g, '');
}
function findExistingActivityByName_(sheet, headerMap, activityName) {
const targetName = String(activityName || '').trim();
const normalizedTarget = normalizeActivityName_(targetName);
if (!targetName) return null;
for (let row = 2; row <= sheet.getLastRow(); row++) {
const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
const existingStatus = String(readCellByHeader_(sheet, row, headerMap, '目前狀態') || '').trim();
const existingNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();
if (!existingName) continue;
if (existingName === targetName || normalizeActivityName_(existingName) === normalizedTarget) {
return {
row: row,
id: existingId,
name: existingName,
status: existingStatus,
note: existingNote
};
}
}
return null;
}
function ensureActivityIntakeSyncColumns_(sheet) {
const requiredHeaders = ['同步狀態', '同步時間', '同步備註'];
let headers = sheet.getRange(1, 1, 1, Math.max(sheet.getLastColumn(), 1)).getValues()[0].map(String);
requiredHeaders.forEach(function(header) {
if (headers.indexOf(header) < 0) {
sheet.getRange(1, sheet.getLastColumn() + 1).setValue(header);
headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
}
});
return buildHeaderMap_(sheet);
}
function writeActivityIntakeSyncNote_(sheet, row, responseMap, status, note) {
writeCellByHeader_(sheet, row, responseMap, '同步狀態', status);
writeCellByHeader_(sheet, row, responseMap, '同步時間', nowText_());
writeCellByHeader_(sheet, row, responseMap, '同步備註', note);
}
function appendNoteToExistingActivity_(sheet, headerMap, row, note) {
const currentNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();
if (currentNote.indexOf(note) >= 0) return;
const newNote = [
currentNote,
'',
'【同步防呆紀錄】',
note
].filter(Boolean).join('\n');
writeCellByHeader_(sheet, row, headerMap, '備註', newNote);
writeCellByHeader_(sheet, row, headerMap, '最近更新時間', nowText_());
}
/**
* ============================================================
* ActivityService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:活動資料包建立與回填。
*/
function createActivityPackageFromSelectedRow() {
const context = getSelectedActivityRowContext_();
const result = ensureActivityPackageForContext_(context);
updateDashboard_();
alert_([
'活動資料包已建立或確認存在。',
'活動:' + result.activity.name,
'資料夾:' + result.folder.getUrl(),
'',
'下一步可建立報名表單或行政文件模板。'
].join('\n'));
return result.folder.getUrl();
}
function ensureActivityPackageForContext_(context) {
initializeSheets_(context.ss);
let activity = context.activity;
if (!activity.id) {
activity.id = generateActivityId_();
writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動ID', activity.id);
}
let folder = getFolderFromActivityUrl_(activity.folderUrl);
if (!folder) {
folder = createActivityFolder_(activity);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '專案資料夾連結', folder.getUrl());
activity.folderUrl = folder.getUrl();
}
const subfolders = createActivitySubfolders_(folder);
generateSessionsForActivity_(activity);
updateActivitySessionFields_(context);
if (!activity.summaryDocUrl) {
const summaryDoc = createActivitySummaryDoc_(activity, subfolders['00_活動基本資料']);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動摘要連結', summaryDoc.getUrl());
appendDocumentListRow_(activity, '活動摘要', summaryDoc.getName(), '集中呈現活動基本資料與承辦人確認事項', summaryDoc.getUrl(), '系統模板', '草稿', '待確認', '建立活動資料包時產生');
}
appendDefaultTaskRows_(activity);
appendDefaultNotificationRows_(activity);
appendDefaultOutcomeRows_(activity);
appendNotebookIndexRows_(activity);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '目前狀態', '資料包已建立');
writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
return { activity: activity, folder: folder, subfolders: subfolders };
}
function ensureActivityPackageForSelectedActivity_() {
const context = getSelectedActivityRowContext_();
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
packageContext.activity = context.activity;
packageContext.rowContext = context;
return packageContext;
}
function createActivitySummaryDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_活動摘要_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('活動摘要').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('一、活動基本資料').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph(activityToText_(activity));
body.appendParagraph('二、承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
[
'活動日期、地點與時間是否正確。',
'費用、收費方式、保險與交通是否符合校內程序。',
'家長通知書與同意書正式發出前是否已核定。',
'報名資料與 NotebookLM 匯出資料是否已排除不必要個資。'
].forEach(function(item) {
body.appendParagraph('□ ' + item);
});
body.appendParagraph('三、AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph(CAMP_PROMPTS.activityDiagnosis.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function appendDefaultTaskRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
ensureHeaders_(sheet, CAMP_TASK_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) sessions = generateSessionsForActivity_(activity);
const startDate = effectiveActivityStartDate_(activity);
const endDate = effectiveActivityEndDate_(activity) || startDate;
const rows = [];
[
['ALL', 'A01', '啟動', '建立活動資料包', '建立活動主資料夾、場次設定與中控台索引', '活動資料包', '資料夾與活動摘要已建立', '承辦人', activity.owner, '', addDays_(startDate, -30), '', '', '是', '活動摘要', '未開始', '高', '', ''],
['ALL', 'A02', '報名', '建立報名表單', '確認全程與部分場次報名欄位', '報名表單', '表單連結已回填活動清單', '承辦人', activity.owner, '', addDays_(startDate, -21), '', 'A01', '是', '報名表單', '未開始', '高', '', ''],
['ALL', 'A03', '通知', '發布活動通知', '家長或學員通知與處室通知完成確認', '通知文件', '正式通知已由承辦人確認', '承辦人', activity.owner, '', addDays_(startDate, -14), '', 'A02', '是', '家長通知書/處室通知', '未開始', '高', '', ''],
['ALL', 'A04', '名冊', '彙整總報名名單', '整理全程與部分場次報名統計', '報名統計', '場次報名人數已更新', '承辦人', activity.owner, '', activity.registrationDeadline, '', 'A02', '是', '報名資料', '未開始', '高', '', ''],
['ALL', 'A05', '行政確認', '確認收費/保險/交通', '確認活動行政風險與校內程序', '行政確認紀錄', '正式內容已確認', '承辦人', activity.owner, '', addDays_(startDate, -10), '', 'A03', '是', '標案需求表', '未開始', '高', '', ''],
['POST', 'P01', '結案', '建立 NotebookLM 資料包', '去識別化後整理多日活動資料包', 'NotebookLM資料包', '匯出文件已建立', '承辦人', activity.owner, '', addDays_(endDate, 7), '', '', '否', 'NotebookLM資料包', '未開始', '中', '', ''],
['POST', 'P02', '結案', '結案成果報告', '彙整成果、回饋、照片說明與下次建議', '成果報告素材', '成果檢查表已完成', '承辦人', activity.owner, '', addDays_(endDate, 14), '', 'P01', '否', '成果檢查表', '未開始', '中', '', '']
].forEach(function(item) {
rows.push(buildTaskSheetRow_({
'活動ID': activity.id,
'場次ID': item[0],
'WBS編碼': item[1],
'階段': item[2],
'任務名稱': item[3],
'具體任務': item[4],
'交付物': item[5],
'完成判準': item[6],
'負責角色': item[7],
'負責人': item[8],
'建議開始日期': item[9],
'建議完成日期': item[10],
'實際完成日期': item[11],
'前置任務ID': item[12],
'是否關鍵任務': item[13],
'需要文件或表單': item[14],
'狀態': item[15],
'優先程度': item[16],
'卡關原因': item[17],
'備註': item[18],
'最後更新時間': ''
}));
});
sessions.forEach(function(session) {
[
['課前', '確認本場講義', '確認本場講義、材料或簡報', '本場講義', '講義連結已放入場次設定', addDays_(parseDate_(session.date), -3), '高'],
['課前', '確認本場場地或線上連結', '確認場地、設備、線上會議連結與助教分工', '場地或線上連結', '場次地點與設備已確認', addDays_(parseDate_(session.date), -2), '高'],
['課前', '課前提醒學員', '提醒日期、時間、地點、攜帶物品與簽到方式', '課前提醒', '提醒已發出或公告', addDays_(parseDate_(session.date), -1), '中'],
['課前', '確認簽到表單', '確認逐場簽到資料與簽到流程', '簽到資料', '簽到預備列已建立', addDays_(parseDate_(session.date), -1), '高'],
['課後', '課後回收回饋', '回收本場回饋與問題紀錄', '回饋摘要', '回饋數已更新', parseDate_(session.date), '中'],
['課後', '整理本場成果', '整理照片、講義、錄影、回饋與成果說明', '本場成果資料', '成果整理狀態已更新', addDays_(parseDate_(session.date), 2), '中']
].forEach(function(item, index) {
rows.push(buildTaskSheetRow_({
'活動ID': activity.id,
'場次ID': session.sessionId,
'WBS編碼': session.sessionId + '-T' + ('0' + (index + 1)).slice(-2),
'階段': item[0],
'任務名稱': item[1],
'具體任務': item[2],
'交付物': item[3],
'完成判準': item[4],
'負責角色': '承辦人/協作人員',
'負責人': activity.owner,
'建議開始日期': '',
'建議完成日期': item[5],
'實際完成日期': '',
'前置任務ID': '',
'是否關鍵任務': item[6] === '高' ? '是' : '否',
'需要文件或表單': item[3],
'狀態': '未開始',
'優先程度': item[6],
'卡關原因': '',
'備註': '',
'最後更新時間': ''
}));
});
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_TASK_HEADERS.length).setValues(rows);
}
function buildTaskSheetRow_(taskData) {
return CAMP_TASK_HEADERS.map(function(header) {
return Object.prototype.hasOwnProperty.call(taskData, header) ? taskData[header] : '';
});
}
function appendDefaultNotificationRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
const activityDate = parseDate_(activity.activityDate);
const rows = [
['家長活動通知', '家長', '紙本/Email/班級群組', addDays_(activityDate, -14), '', activity.parentNoticeUrl || '', '草稿', '待確認', '正式發出前需確認日期、費用、交通與保險'],
['家長同意書回收提醒', '家長', '紙本/班級群組', activity.consentDeadline || activity.registrationDeadline, '', activity.parentConsentUrl || '', '未通知', '待確認', '追蹤未回覆名單'],
['導師行前提醒', '導師', '校內群組/Email', addDays_(activityDate, -7), '', activity.teacherReminderUrl || '', '草稿', '待確認', '確認點名與回報方式'],
['相關處室協調', '總務/學務/健康中心/會計', 'Email/會議', addDays_(activityDate, -14), '', activity.officeEmailUrl || '', '草稿', '待確認', '採購、保險、交通、安全與核銷'],
['成果資料收件提醒', '導師/協辦人', '校內群組/Email', addDays_(activityDate, 3), '', '', '未通知', '待確認', '活動後整理成果與照片說明']
].map(function(row) {
return [activity.id].concat(row);
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTIFICATION_HEADERS.length).setValues(rows);
}
function appendDefaultOutcomeRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.OUTCOMES);
ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) sessions = generateSessionsForActivity_(activity);
const rows = [
['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '需補照片說明'],
['簽到或報名統計', '待確認', '', '是', '否', activity.owner, '含學生資料,不建議直接匯入 NotebookLM'],
['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可作為成果報告素材'],
['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
['經費或核銷附件', '不適用', '', '待確認', '否', activity.owner, '依校內規定處理'],
['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM 資料包']
].map(function(row) {
return [activity.id].concat(row);
});
sessions.forEach(function(session) {
[
[session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
[session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
[session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
[session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
].forEach(function(row) {
rows.push([activity.id].concat(row));
});
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
}
function appendNotebookIndexRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTEBOOK);
ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
const rows = [
['活動基本資料', activityToText_(activity), '01_活動清單', '是', '不含敏感個資時可匯出'],
['報名統計', '報名人數、參加人數、不參加人數、特殊需求摘要', '02_報名資料', '是', '只放統計與摘要,不放電話與健康細節'],
['任務狀態', '任務追蹤與完成狀態', '03_任務追蹤', '是', ''],
['通知紀錄', '家長、導師、處室通知狀態', '05_通知管理', '是', '正式通知內容需承辦人確認'],
['成果檢查', '成果資料、照片說明、下次建議', '06_成果檢查', '是', ''],
['敏感資料', '學生姓名、電話、健康、身分資料、收費明細', '各表單原始回覆', '否', '不放入 NotebookLM 資料包']
].map(function(row) {
return [activity.id].concat(row);
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTEBOOK_HEADERS.length).setValues(rows);
}
/**
* ============================================================
* RegistrationTemplateService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:報名表模板與條件欄位判斷。
*/
function isYes_(value) {
return String(value || '').trim() === '是';
}
function getRegistrationTemplateType_(activity) {
const configured = String(activity.registrationTemplateType || '').trim();
if (configured && configured !== '自動判斷') return configured;
if (isWorkshopActivity_(activity) && isOnlineActivity_(activity)) return '線上研習';
if (isWorkshopActivity_(activity)) return '教師研習';
if (isStudentActivity_(activity)) return '學生營隊';
return '自動判斷';
}
function isWorkshopActivity_(activity) {
const template = String(activity.registrationTemplateType || '');
const type = String(activity.type || '');
const name = String(activity.name || '');
if (template.indexOf('教師研習') >= 0 || template.indexOf('線上研習') >= 0) return true;
return type.indexOf('研習') >= 0
|| type.indexOf('工作坊') >= 0
|| name.indexOf('研習') >= 0
|| name.indexOf('工作坊') >= 0;
}
function isStudentActivity_(activity) {
const template = String(activity.registrationTemplateType || '');
const type = String(activity.type || '');
if (template.indexOf('學生營隊') >= 0 || template.indexOf('校外教學') >= 0 || template.indexOf('親子活動') >= 0 || template.indexOf('競賽活動') >= 0) return true;
return type.indexOf('營隊') >= 0
|| type.indexOf('校外教學') >= 0
|| type.indexOf('校內活動') >= 0
|| type.indexOf('競賽') >= 0;
}
function isOnlineActivity_(activity) {
const template = String(activity.registrationTemplateType || '');
const location = String(activity.location || '');
const transport = String(activity.transport || '');
if (template.indexOf('線上研習') >= 0) return true;
return location.indexOf('Google Meet') >= 0
|| location.indexOf('線上') >= 0
|| transport.indexOf('線上') >= 0;
}
function needsDietSurvey_(activity) {
if (isOnlineActivity_(activity)) return false;
const type = String(activity.type || '');
const feeNote = String(activity.feeNote || '');
const note = String(activity.note || '');
return type.indexOf('營隊') >= 0
|| type.indexOf('校外教學') >= 0
|| feeNote.indexOf('餐') >= 0
|| note.indexOf('餐') >= 0
|| note.indexOf('便當') >= 0;
}
function needsInsuranceFields_(activity) {
return isYes_(activity.insuranceRequired);
}
function needsConsentFields_(activity) {
return isYes_(activity.consentRequired);
}
function needsPaymentFields_(activity) {
return isYes_(activity.chargeRequired);
}
function needsPartialSessionFields_(activity) {
return isYes_(activity.allowPartialSessions);
}
function buildWorkshopSessionChoiceValues_(activity, sessions) {
if (String(activity.id || '') === 'ACT-NTPC-AI-11506') {
return [
'S01|6月15日(一) 課程主題|從公文啟動專案 主案例:教育局來文「AI 融入教學教師研習計畫」',
'S02|6月18日(四) 課程主題|活動型專案管理 主案例:校外教學/親子活動辦理',
'S03|6月22日(一) 課程主題|成果型專案與 AI 文件整理 主案例:教育優先區/補助計畫成果報告',
'S04|6月25日(四) 課程主題|大型專案整合與制度化 主案例:運動會/校慶大型行政專案'
];
}
return buildSessionChoiceValues_(sessions);
}
/**
* ============================================================
* RegistrationForm.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:報名表單與回應同步。
*/
function createRegistrationFormForSelectedActivity() {
const packageContext = getRegistrationPackageContextFromSelectedActivity_();
if (!packageContext) return '';
return createRegistrationFormForPackageContext_(packageContext);
}
function getRegistrationPackageContextFromSelectedActivity_() {
const context = getSelectedActivityRowContext_();
if (warnIfPossibleDuplicateNewTaipeiWorkshop_(context.activity)) {
return null;
}
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
packageContext.activity = context.activity;
packageContext.rowContext = context;
return packageContext;
}
function warnIfPossibleDuplicateNewTaipeiWorkshop_(activity) {
const name = String(activity.name || '');
const id = String(activity.id || '');
if (name.indexOf('新北AI共創大聯盟') >= 0 && id !== 'ACT-NTPC-AI-11506') {
alert_([
'你目前選取的活動名稱像是新北AI研習,但活動ID不是預設示範資料 ID。',
'',
'目前活動ID:' + valueOrPending_(id),
'建議示範資料ID:ACT-NTPC-AI-11506',
'',
'如果這是重複同步產生的資料,請改選 ACT-NTPC-AI-11506 那一列。'
].join('\n'));
return true;
}
return false;
}
function createRegistrationFormForPackageContext_(packageContext) {
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
if (activity.registrationFormUrl && activity.registrationFormEditUrl) {
alert_('此活動已經有報名表單:\n' + activity.registrationFormUrl);
return activity.registrationFormUrl;
}
generateSessionsForActivity_(activity);
const sessions = getSessionsByActivityId_(activity.id);
const form = FormApp.create('報名表單_' + activity.name);
form.setDescription([
'活動名稱:' + activity.name,
'活動日期:' + formatDateValue_(activity.activityDate),
'活動地點:' + valueOrPending_(activity.location),
'報名截止日:' + formatDateValue_(activity.registrationDeadline),
'',
'個資使用提醒:本表單資料僅供本次活動報名、通知、保險、聯繫與行政確認使用。正式保存、分享與刪除仍依校內規範辦理。'
].join('\n'));
form.setAllowResponseEdits(true);
form.setDestination(FormApp.DestinationType.SPREADSHEET, rowContext.ss.getId());
const activityIdItem = form.addTextItem().setTitle('活動ID').setRequired(true).setHelpText('系統欄位,請勿修改。');
const activityNameItem = form.addTextItem().setTitle('活動名稱').setRequired(true).setHelpText('系統欄位,請勿修改。');
if (isWorkshopActivity_(activity)) {
form.addTextItem().setTitle('單位').setRequired(true);
form.addTextItem().setTitle('科別/職稱').setRequired(true);
form.addTextItem().setTitle('姓名').setRequired(true);
form.addTextItem().setTitle('Email').setRequired(false);
form.addCheckboxItem()
.setTitle('參加場次')
.setChoiceValues(buildWorkshopSessionChoiceValues_(activity, sessions))
.setRequired(true);
form.addMultipleChoiceItem()
.setTitle('需要研習時數')
.setChoiceValues(['需要', '不需要', '待確認'])
.setRequired(false);
form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);
form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
} else {
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);
if (needsPartialSessionFields_(activity)) {
form.addMultipleChoiceItem().setTitle('是否全程參加').setChoiceValues(['是,我會全程參加', '否,我只參加部分場次']).setRequired(true);
form.addCheckboxItem().setTitle('選擇參加場次').setChoiceValues(buildSessionChoiceValues_(sessions)).setRequired(false);
}
form.addMultipleChoiceItem().setTitle('需要研習時數').setChoiceValues(['需要', '不需要', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);
if (needsDietSurvey_(activity)) {
form.addMultipleChoiceItem().setTitle('飲食需求').setChoiceValues(['無特殊需求', '葷食', '素食', '其他']).setRequired(false);
}
if (needsInsuranceFields_(activity)) {
form.addTextItem().setTitle('出生日期').setRequired(false);
form.addTextItem().setTitle('身分證字號').setRequired(false);
form.addMultipleChoiceItem().setTitle('是否同意提供保險所需資料').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
}
if (needsConsentFields_(activity)) {
form.addMultipleChoiceItem().setTitle('家長是否同意參加').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
form.addMultipleChoiceItem().setTitle('同意書狀態').setChoiceValues(['已繳交', '尚未繳交', '不適用']).setRequired(false);
}
if (needsPaymentFields_(activity)) {
form.addMultipleChoiceItem().setTitle('繳費方式').setChoiceValues(['現金', '轉帳', '學校代收', '其他', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('繳費備註').setRequired(false);
}
form.addCheckboxItem().setTitle('特殊需求').setChoiceValues(['無', '暈車', '醫療需求', '過敏', '交通接送', '其他']).setRequired(false);
}
form.addMultipleChoiceItem().setTitle('是否同意通知').setChoiceValues(['同意', '不同意']).setRequired(true);
form.addParagraphTextItem().setTitle('備註').setRequired(false);
const prefilledUrl = form.createResponse()
.withItemResponse(activityIdItem.createResponse(activity.id))
.withItemResponse(activityNameItem.createResponse(activity.name))
.toPrefilledUrl();
moveFileToFolder_(form.getId(), packageContext.subfolders['01_報名表單與回覆']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單連結', prefilledUrl);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單編輯連結', form.getEditUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名回應表連結', rowContext.ss.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, '報名表單', form.getTitle(), '收集活動報名、場次選擇與行政需求資料', prefilledUrl, 'Google Form', '待確認', '待確認', '發出前需確認欄位與個資告知文字;模板:' + getRegistrationTemplateType_(activity));
updateDashboard_();
alert_('報名表單已建立:\n' + prefilledUrl);
return prefilledUrl;
}
function syncRegistrationResponses() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const targetSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
ensureHeaders_(targetSheet, CAMP_REGISTRATION_HEADERS);
const existingKeys = buildExistingRegistrationKeys_(targetSheet);
const systemSheetNames = {};
CAMP_SHEET_ORDER.forEach(function(name) {
systemSheetNames[name] = true;
});
let added = 0;
ss.getSheets().forEach(function(sheet) {
if (systemSheetNames[sheet.getName()]) return;
if (!looksLikeRegistrationResponseSheet_(sheet)) return;
const values = sheet.getDataRange().getValues();
const headers = values[0];
const index = buildArrayHeaderIndex_(headers);
for (let i = 1; i < values.length; i++) {
const row = values[i];
const activityId = readArrayCell_(row, index, '活動ID');
const studentName = readArrayCell_(row, index, '學生姓名')
|| readArrayCell_(row, index, '姓名');
const unit = readArrayCell_(row, index, '班級')
|| readArrayCell_(row, index, '單位');
const title = readArrayCell_(row, index, '座號')
|| readArrayCell_(row, index, '科別/職稱')
|| readArrayCell_(row, index, '科別/職稱')
|| readArrayCell_(row, index, '科別/ 職稱');
const activityName = readArrayCell_(row, index, '活動名稱');
const timestamp = readArrayCell_(row, index, '時間戳記') || readArrayCell_(row, index, 'Timestamp');
if (!activityId || !studentName) continue;
const key = [activityId, studentName, timestamp].join('|');
if (existingKeys[key]) continue;
const selectedText = readArrayCell_(row, index, '選擇參加場次') || readArrayCell_(row, index, '參加場次');
const fullParticipation = readArrayCell_(row, index, '是否全程參加')
|| inferFullParticipationFromSelectedSessions_(selectedText);
const selectedSessions = normalizeSelectedSessions_(activityId, fullParticipation, selectedText);
const email = readArrayCell_(row, index, '家長Email') || readArrayCell_(row, index, 'Email');
targetSheet.appendRow([
activityId,
activityName,
timestamp,
studentName,
unit,
title,
readArrayCell_(row, index, '家長姓名'),
email,
readArrayCell_(row, index, '家長電話'),
readArrayCell_(row, index, '是否參加') || '參加',
fullParticipation,
selectedSessions,
readArrayCell_(row, index, '需要研習時數'),
readArrayCell_(row, index, '學習期待'),
readArrayCell_(row, index, '目前行政痛點'),
readArrayCell_(row, index, '是否同意通知'),
readArrayCell_(row, index, '飲食需求'),
readArrayCell_(row, index, '特殊需求'),
readArrayCell_(row, index, '同意書狀態'),
'否',
sheet.getName(),
buildRegistrationNote_(row, index, title)
]);
ensureAttendanceRowsForRegistration_({
activityId: activityId,
name: studentName,
unit: unit,
email: email,
fullParticipation: fullParticipation,
selectedSessions: selectedSessions
});
existingKeys[key] = true;
added++;
}
});
updateRegistrationStats_();
updateSessionStats_();
updateDashboard_();
alert_('報名資料同步完成。新增 ' + added + ' 筆。');
return added;
}
function buildSessionChoiceValues_(sessions) {
if (!sessions.length) return ['S01|第1場'];
return sessions.map(function(session) {
return [
session.sessionId,
formatDateValue_(session.date),
session.title || ('第' + session.sessionId.replace('S', '') + '場'),
session.topic || ''
].filter(String).join('|');
});
}
function normalizeSelectedSessions_(activityId, fullParticipation, selectedText) {
const sessions = getSessionsByActivityId_(activityId);
if (String(fullParticipation || '').indexOf('全程') >= 0 || String(fullParticipation || '') === '是') {
return sessions.map(function(session) { return session.sessionId; }).join(',');
}
const selected = parseSelectedSessionIds_(selectedText);
if (selected.length) return selected.join(',');
return sessions.length === 1 ? sessions[0].sessionId : '';
}
function inferFullParticipationFromSelectedSessions_(selectedText) {
const count = parseSelectedSessionIds_(selectedText).length;
return count >= 4 ? '是,我會全程參加' : '否,我只參加部分場次';
}
function buildRegistrationNote_(row, index, title) {
const note = String(readArrayCell_(row, index, '備註') || '').trim();
const workshopTitle = String(title || '').trim();
if (!workshopTitle) return note;
return [note, '科別/職稱:' + workshopTitle].filter(String).join('\n');
}
function updateRegistrationStats_() {
const ss = getActiveSpreadsheet_();
const registrationSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const registrationRows = getRegistrationStatsByActivity_(registrationSheet);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
for (let row = 2; row <= activitySheet.getLastRow(); row++) {
const activityId = readCellByHeader_(activitySheet, row, activityMap, '活動ID');
if (!activityId) continue;
const stats = registrationRows[String(activityId)] || { total: 0, join: 0, notJoin: 0, special: 0 };
writeCellByHeader_(activitySheet, row, activityMap, '報名人數', stats.total);
writeCellByHeader_(activitySheet, row, activityMap, '同意參加', stats.join);
writeCellByHeader_(activitySheet, row, activityMap, '不參加', stats.notJoin);
writeCellByHeader_(activitySheet, row, activityMap, '特殊需求數', stats.special);
writeCellByHeader_(activitySheet, row, activityMap, '最近更新時間', nowText_());
}
}
function getRegistrationStatsByActivity_(sheet) {
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
const map = buildHeaderMap_(sheet);
const stats = {};
for (let row = 2; row <= sheet.getLastRow(); row++) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
if (!activityId) continue;
if (!stats[activityId]) stats[activityId] = { total: 0, join: 0, notJoin: 0, special: 0 };
stats[activityId].total++;
const participate = String(readCellByHeader_(sheet, row, map, '是否參加') || '');
if (participate === '參加') stats[activityId].join++;
if (participate === '不參加') stats[activityId].notJoin++;
const special = String(readCellByHeader_(sheet, row, map, '特殊需求') || '').trim();
if (special && special !== '無') stats[activityId].special++;
}
return stats;
}
function buildExistingRegistrationKeys_(sheet) {
const map = buildHeaderMap_(sheet);
const keys = {};
for (let row = 2; row <= sheet.getLastRow(); row++) {
const key = [
readCellByHeader_(sheet, row, map, '活動ID'),
readCellByHeader_(sheet, row, map, '學生姓名'),
readCellByHeader_(sheet, row, map, '時間戳記')
].join('|');
keys[key] = true;
}
return keys;
}
function looksLikeRegistrationResponseSheet_(sheet) {
if (sheet.getLastRow() < 1 || sheet.getLastColumn() < 3) return false;
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
const hasName = headers.indexOf('學生姓名') >= 0 || headers.indexOf('姓名') >= 0;
const hasParticipation = headers.indexOf('是否參加') >= 0
|| headers.indexOf('選擇參加場次') >= 0
|| headers.indexOf('參加場次') >= 0;
return headers.indexOf('活動ID') >= 0 && hasName && hasParticipation;
}
function buildArrayHeaderIndex_(headers) {
const index = {};
headers.forEach(function(header, i) {
if (header) index[String(header).trim()] = i;
});
return index;
}
function readArrayCell_(row, index, header) {
return index[header] === undefined ? '' : row[index[header]];
}
/**
* ============================================================
* SeedRegistrationData.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:教學用報名假資料。
*/
function createRegistrationFormAndSeedSampleData() {
const packageContext = getRegistrationPackageContextFromSelectedActivity_();
if (!packageContext) return '';
const formUrl = createRegistrationFormForPackageContext_(packageContext);
const seeded = seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
alert_([
'報名表單已建立或確認存在。',
'',
'本次新增假報名資料:' + seeded + ' 筆。',
'已更新報名統計、場次統計、簽到預備資料與中控台。',
'',
'報名表單:',
formUrl
].join('\n'));
return formUrl;
}
function seedSampleWorkshopRegistrationsForSelectedActivity() {
const packageContext = getRegistrationPackageContextFromSelectedActivity_();
if (!packageContext) return 0;
return seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
}
function seedSampleWorkshopRegistrationsForPackageContext_(packageContext) {
const activity = packageContext.activity;
if (!activity.id) {
throw new Error('此活動缺少活動ID,請先建立活動資料包或補上活動ID。');
}
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
const existingRows = getRowsByActivityId_(sheet, activity.id);
const hasOfficialRows = existingRows.some(function(row) {
return String(row['來源分頁'] || '') !== '系統假資料';
});
if (hasOfficialRows) {
alert_('此活動已有正式報名資料,為避免混入教學假資料,本次不自動產生 50 筆假資料。');
return 0;
}
if (existingRows.length >= 50) {
alert_('此活動已有 ' + existingRows.length + ' 筆報名資料,不重複 seed 50 筆。');
return 0;
}
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) {
generateSessionsForActivity_(activity);
sessions = getSessionsByActivityId_(activity.id);
}
const remaining = Math.max(0, 50 - existingRows.length);
const sampleRows = buildSampleWorkshopRegistrationRows_(activity, remaining, existingRows.length);
if (!sampleRows.length) return 0;
sheet.getRange(sheet.getLastRow() + 1, 1, sampleRows.length, CAMP_REGISTRATION_HEADERS.length)
.setValues(sampleRows);
sampleRows.forEach(function(row) {
ensureAttendanceRowsForRegistration_({
activityId: row[CAMP_REGISTRATION_HEADERS.indexOf('活動ID')],
name: row[CAMP_REGISTRATION_HEADERS.indexOf('學生姓名')],
unit: row[CAMP_REGISTRATION_HEADERS.indexOf('班級')],
email: row[CAMP_REGISTRATION_HEADERS.indexOf('家長Email')],
fullParticipation: row[CAMP_REGISTRATION_HEADERS.indexOf('是否全程參加')],
selectedSessions: row[CAMP_REGISTRATION_HEADERS.indexOf('選擇參加場次')]
});
});
updateRegistrationStats_();
updateSessionStats_();
updateDashboard_();
return sampleRows.length;
}
function buildSampleWorkshopRegistrationRows_(activity, count, offset) {
const units = [
'新北市政府教育局', '八里國小', '三和國中', '三芝國中', '三義高中',
'竹南國小', '新莊國小', '板橋國小', '泰山高中', '淡水國中',
'一般企業', '一般民眾', '人力學院', '教育科技中心', '資訊教育輔導團'
];
const titles = [
'國小教育科', '主任', '教師', '科任教師', '資訊組長',
'教務主任', '學務主任', '行政人員', '採購', '上班族',
'綜合規劃組', '輔導員', '研究教師', '自然科教師', '總務主任'
];
const names = [
'王品安', '林書宇', '陳郁庭', '張雅筑', '李承恩',
'黃子晴', '劉冠廷', '吳怡萱', '蔡孟潔', '楊皓宇',
'許庭瑄', '鄭宇翔', '謝佳穎', '洪柏翰', '郭采蓉',
'曾昱廷', '邱郁涵', '廖品妤', '賴冠宇', '周宜蓁',
'徐子軒', '蘇映潔', '葉哲維', '江佩珊', '何宥辰',
'蕭雅雯', '羅品萱', '高承翰', '潘怡君', '游宗翰',
'程心瑜', '方柏宇', '石婉婷', '沈昱翔', '戴佳蓉',
'彭宇恩', '詹雅婷', '卓冠霖', '簡若涵', '藍柏翰',
'梁芷晴', '康家豪', '姜宜庭', '白承祐', '任品妍',
'歐陽子晴', '范庭瑋', '宋宜蓁', '古承恩', '杜欣妤'
];
const painPoints = [
'資料分散在不同雲端資料夾,常常找不到最新版。',
'活動報名、通知、名冊整理需要重複複製貼上。',
'公文轉成待辦任務時,容易漏掉期限。',
'想把表單、試算表、文件和提醒整合起來。',
'希望學會用 AI 協助整理成果報告草稿。',
'行政流程常需要跨處室協作,希望能更清楚追蹤。',
'想知道 NotebookLM 可以如何用在行政資料整理。',
'希望了解 Apps Script 在校務行政的實際應用。'
];
const expectations = [
'希望能完成一套可複製的行政專案流程。',
'想學會從公文建立專案追蹤表。',
'希望能降低人工整理資料的時間。',
'想把活動報名與成果整理變得更有系統。',
'希望能做出適合學校同仁使用的範本。',
'想理解 AI 在行政流程中的合理位置。',
'希望回校後能實際套用在研習或活動辦理。',
'想學會建立 NotebookLM 資料包。'
];
const rows = [];
for (let i = 0; i < count; i++) {
const serial = offset + i;
const name = names[serial % names.length];
const unit = units[serial % units.length];
const title = titles[serial % titles.length];
const email = 'sample' + ('000' + (serial + 1)).slice(-3) + '@example.com';
const selectedSessions = buildSeedSelectedSessions_(serial);
const fullParticipation = selectedSessions === 'S01,S02,S03,S04' ? '是,我會全程參加' : '否,我只參加部分場次';
const timestamp = new Date(2026, 5, 1 + (serial % 5), 9 + (serial % 10), (serial * 7) % 60, (serial * 11) % 60);
const object = {};
object['活動ID'] = activity.id;
object['活動名稱'] = activity.name;
object['時間戳記'] = timestamp;
object['學生姓名'] = name;
object['班級'] = unit;
object['座號'] = title;
object['家長姓名'] = '';
object['家長Email'] = email;
object['家長電話'] = '';
object['是否參加'] = '參加';
object['是否全程參加'] = fullParticipation;
object['選擇參加場次'] = selectedSessions;
object['需要研習時數'] = serial % 4 === 0 ? '不需要' : '需要';
object['學習期待'] = expectations[serial % expectations.length];
object['目前行政痛點'] = painPoints[serial % painPoints.length];
object['是否同意通知'] = '同意';
object['飲食需求'] = '';
object['特殊需求'] = '';
object['同意書狀態'] = '不適用';
object['是否可提供 NotebookLM'] = '是';
object['來源分頁'] = '系統假資料';
object['備註'] = '教師研習示範資料,非真實個資。單位:' + unit + ';職稱:' + title;
rows.push(CAMP_REGISTRATION_HEADERS.map(function(header) {
return object[header] || '';
}));
}
return rows;
}
function buildSeedSelectedSessions_(index) {
const patterns = [
'S01,S02,S03,S04',
'S01,S02,S03,S04',
'S01,S02,S03,S04',
'S01,S02,S03,S04',
'S01',
'S02',
'S03',
'S04',
'S01,S02',
'S02,S03',
'S03,S04',
'S01,S04',
'S01,S03',
'S02,S04',
'S01,S02,S03',
'S02,S03,S04'
];
return patterns[index % patterns.length];
}
function clearSeedRegistrationDataForSelectedActivity() {
const context = getSelectedActivityRowContext_();
const activity = context.activity;
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
const map = buildHeaderMap_(sheet);
for (let row = sheet.getLastRow(); row >= 2; row--) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
const source = String(readCellByHeader_(sheet, row, map, '來源分頁') || '');
if (activityId === activity.id && source === '系統假資料') {
sheet.deleteRow(row);
}
}
updateRegistrationStats_();
updateSessionStats_();
updateDashboard_();
alert_('已清除本活動的系統假報名資料。');
}
/**
* ============================================================
* DocumentTemplates.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:行政文件模板。
*/
function createDocumentTemplatesForSelectedActivity() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const subfolders = packageContext.subfolders;
const parentNotice = createParentNoticeDoc_(activity, subfolders['02_家長通知與同意書']);
const parentConsent = createParentConsentDoc_(activity, subfolders['02_家長通知與同意書']);
const procurement = createProcurementRequirementDoc_(activity, subfolders['03_處室聯繫與採購']);
const officeEmail = createOfficeEmailDraftDoc_(activity, subfolders['03_處室聯繫與採購']);
const teacherReminder = createTeacherReminderDoc_(activity, subfolders['03_處室聯繫與採購']);
const outcomeSheet = createOutcomeChecklistSheet_(activity, subfolders['06_成果檢查與結案']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長通知書連結', parentNotice.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長同意書連結', parentConsent.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '標案需求表連結', procurement.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '處室通知草稿連結', officeEmail.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '導師行前提醒連結', teacherReminder.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '目前狀態', '文件模板已建立');
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
activity.parentNoticeUrl = parentNotice.getUrl();
activity.parentConsentUrl = parentConsent.getUrl();
activity.procurementUrl = procurement.getUrl();
activity.officeEmailUrl = officeEmail.getUrl();
activity.teacherReminderUrl = teacherReminder.getUrl();
activity.outcomeUrl = outcomeSheet.getUrl();
appendDocumentListRow_(activity, '家長通知書', parentNotice.getName(), '提供家長活動資訊與回覆提醒', parentNotice.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '家長同意書', parentConsent.getName(), '提供家長簽署或確認參加意願', parentConsent.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '標案需求表', procurement.getName(), '整理採購、交通、保險或物資需求', procurement.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '處室通知草稿', officeEmail.getName(), '提供總務、學務、健康中心、會計或協辦處室通知草稿', officeEmail.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '導師行前提醒', teacherReminder.getName(), '提供導師活動前與活動當日提醒事項', teacherReminder.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '檢查活動後成果、照片、附件與 NotebookLM 可用資料', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
refreshNotificationDocumentLinks_(activity);
updateDashboard_();
alert_('行政文件模板已建立完成。請逐份確認後再正式公告、寄送或送簽。');
}
function createParentNoticeDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_家長通知書_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('家長通知書').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('親愛的家長您好:');
body.appendParagraph('本校預計辦理「' + activity.name + '」,以下為活動資訊草稿,正式內容仍以承辦人確認後公告為準。');
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['活動開始日', formatDateValue_(activity.startDate)],
['活動結束日', formatDateValue_(activity.endDate)],
['活動地點', valueOrPending_(activity.location)],
['參加對象', valueOrPending_(activity.participants)],
['報名截止日', formatDateValue_(activity.registrationDeadline)],
['費用說明', valueOrPending_(activity.feeNote)],
['交通方式', valueOrPending_(activity.transport)],
['保險需求', valueOrPending_(activity.insuranceRequired)]
]);
body.appendParagraph('場次表').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendSessionsTable_(body, activity.id);
body.appendParagraph('家長需協助事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph('請於期限內完成報名或同意書回覆,並留意導師或學校後續通知。');
body.appendParagraph('AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph(CAMP_PROMPTS.parentNotice.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['日期、時間、地點正確', '費用與繳交方式正確', '交通、保險與安全資訊已確認', '正式發出前已依校內程序核定']);
body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createParentConsentDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_家長同意書_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('家長同意書').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('本人已詳閱「' + activity.name + '」活動說明,並同意學生依學校規劃參與本次活動。');
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['活動地點', valueOrPending_(activity.location)],
['同意書回收日', formatDateValue_(activity.consentDeadline)],
['是否收費', valueOrPending_(activity.chargeRequired)],
['是否需要保險', valueOrPending_(activity.insuranceRequired)]
]);
body.appendParagraph('學生姓名:____________________ 班級座號:____________________');
body.appendParagraph('家長簽名:____________________ 聯絡電話:____________________');
body.appendParagraph('特殊需求或注意事項:');
body.appendParagraph('\n\n');
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['同意書欄位符合校內格式', '未收集不必要個資', '回收期限與保管方式已確認']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createProcurementRequirementDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_標案採購需求表_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('標案/採購需求表').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('本文件用於整理活動所需之交通、場地、保險、餐點、材料、設備或其他採購需求草稿。正式採購仍依校內與法規程序辦理。');
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['預估人數', valueOrPending_(activity.estimatedCount)],
['活動地點', valueOrPending_(activity.location)],
['交通方式', valueOrPending_(activity.transport)],
['費用說明', valueOrPending_(activity.feeNote)],
['承辦處室', valueOrPending_(activity.department)],
['承辦人', valueOrPending_(activity.owner)]
]);
body.appendParagraph('需求項目草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendDocTable_(body, [
['需求類別', '需求內容', '數量', '規格', '預估日期', '備註'],
['交通', activity.transport || '待確認', activity.estimatedCount || '', '', formatDateValue_(activity.activityDate), ''],
['保險', activity.insuranceRequired || '待確認', activity.estimatedCount || '', '', '', ''],
['餐點或材料', '待確認', '', '', '', '']
]);
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['是否需要總務或會計確認', '採購方式是否符合規定', '經費來源與核銷附件是否明確']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createOfficeEmailDraftDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_處室通知Email草稿_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('處室通知 Email 草稿').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('主旨:【活動協作通知】' + activity.name);
body.appendParagraph('收件對象:總務處、學務處、健康中心、會計室、導師或協辦同仁(請承辦人依實際情況調整)');
body.appendParagraph('Email 內容草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph([
'各位同仁您好:',
'',
'本校預計辦理「' + activity.name + '」,活動日期為 ' + formatDateValue_(activity.activityDate) + ',地點為 ' + valueOrPending_(activity.location) + '。',
'敬請協助確認相關行政事項:',
'1. 交通、場地或物資需求。',
'2. 保險、健康或特殊需求學生提醒。',
'3. 經費、採購、核銷或成果附件需求。',
'4. 活動當日安全、集合、點名與回報方式。',
'',
'相關資料夾或文件連結請見中控台。',
'',
'承辦人:' + valueOrPending_(activity.owner),
'承辦處室:' + valueOrPending_(activity.department)
].join('\n'));
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['收件對象是否正確', '不包含不應外寄的學生個資', '採購、保險、交通與安全內容已確認']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createTeacherReminderDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_導師行前提醒_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('導師/工作人員行前提醒').setHeading(DocumentApp.ParagraphHeading.TITLE);
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['活動地點', valueOrPending_(activity.location)],
['參加對象', valueOrPending_(activity.participants)]
]);
body.appendParagraph('活動前一週').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['確認參加名冊', '提醒學生攜帶物品', '確認特殊需求或醫療提醒']);
const sessions = getSessionsByActivityId_(activity.id);
if (sessions.length > 1) {
body.appendParagraph('逐場提醒').setHeading(DocumentApp.ParagraphHeading.HEADING1);
sessions.forEach(function(session) {
body.appendParagraph(session.sessionId + '|' + formatDateValue_(session.date) + '|' + valueOrPending_(session.title)).setHeading(DocumentApp.ParagraphHeading.HEADING2);
appendChecklistParagraphs_(body, [
'確認本場地點或線上連結:' + valueOrPending_(session.location),
'確認本場簽到方式與名冊',
'確認本場講義、設備與回報方式'
]);
});
}
body.appendParagraph('活動前一天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['再次提醒集合時間與地點', '確認未回覆或特殊需求學生', '確認緊急聯絡方式']);
body.appendParagraph('活動當天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['集合點名', '活動中定時確認人數', '返校或結束後回報']);
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['提醒內容是否符合實際活動', '不公開不必要個資', '安全與回報方式已確認']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createOutcomeChecklistSheet_(activity, folder) {
const ss = SpreadsheetApp.create(activity.id + '_成果檢查表_' + sanitizeFileName_(activity.name));
const sheet = ss.getSheets()[0];
sheet.setName('成果檢查');
sheet.getRange(1, 1, 1, CAMP_OUTCOME_HEADERS.length).setValues([CAMP_OUTCOME_HEADERS]);
const rows = [
['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '請補照片說明'],
['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可用於成果報告'],
['參與統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放學生個資'],
['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM']
].map(function(row) {
return [activity.id].concat(row);
});
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) sessions = generateSessionsForActivity_(activity);
sessions.forEach(function(session) {
[
[session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
[session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
[session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
[session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
].forEach(function(row) {
rows.push([activity.id].concat(row));
});
});
sheet.getRange(2, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
formatHeaderRow_(sheet, CAMP_OUTCOME_HEADERS.length);
moveFileToFolder_(ss.getId(), folder);
return ss;
}
function appendDocTable_(body, rows) {
const table = body.appendTable(rows);
table.setBorderWidth(1);
return table;
}
function appendChecklistParagraphs_(body, items) {
items.forEach(function(item) {
body.appendParagraph('□ ' + item);
});
}
/**
* ============================================================
* NotificationService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:通知草稿與測試信。
*/
function sendTestEmailToOwner() {
const context = getSelectedActivityRowContext_();
const activity = context.activity;
if (!activity.ownerEmail) throw new Error('此活動缺少「承辦人Email」,無法寄送測試信。');
const subject = '【測試信】' + activity.name + ' 活動通知草稿檢查';
const body = [
'這是營隊活動管理中樞寄出的測試信,尚未寄送給家長或其他處室。',
'',
'活動名稱:' + activity.name,
'活動日期:' + formatDateValue_(activity.activityDate),
'活動地點:' + valueOrPending_(activity.location),
'',
'重要文件連結:',
'專案資料夾:' + valueOrPending_(activity.folderUrl),
'家長通知書:' + valueOrPending_(activity.parentNoticeUrl),
'家長同意書:' + valueOrPending_(activity.parentConsentUrl),
'處室通知草稿:' + valueOrPending_(activity.officeEmailUrl),
'導師行前提醒:' + valueOrPending_(activity.teacherReminderUrl),
'',
CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
].join('\n');
MailApp.sendEmail(activity.ownerEmail, subject, body);
recordNotificationStatus_(activity, '承辦人測試信', '承辦人', 'Email', new Date(), activity.ownerEmail, '已寄送', '已確認', '測試信只寄給承辦人');
updateDashboard_();
alert_('測試信已寄給承辦人:' + activity.ownerEmail);
}
function recordNotificationStatus_(activity, name, target, channel, date, fileLink, status, confirmation, note) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
sheet.appendRow([
activity.id,
name,
target,
channel,
date,
nowText_(),
fileLink || '',
status || '已記錄',
confirmation || '待確認',
note || ''
]);
}
function refreshNotificationDocumentLinks_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
const map = buildHeaderMap_(sheet);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (String(readCellByHeader_(sheet, row, map, '活動ID')) !== String(activity.id)) continue;
const name = String(readCellByHeader_(sheet, row, map, '通知名稱') || '');
if (name.indexOf('家長活動通知') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentNoticeUrl);
if (name.indexOf('家長同意書') >= 0 || name.indexOf('回收提醒') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentConsentUrl);
if (name.indexOf('導師') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.teacherReminderUrl);
if (name.indexOf('處室') >= 0 || name.indexOf('協調') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.officeEmailUrl);
}
}
/**
* ============================================================
* DeadlineService.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:期限狀態與自動標記。
*/
function updateDeadlineStatuses() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
let updated = 0;
for (let row = 2; row <= sheet.getLastRow(); row++) {
const activity = readActivityFromRow_(sheet, row, map);
if (!activity.name) continue;
const registrationStatus = calculateDateStatus_(activity.registrationDeadline, false);
const consentStatus = calculateDateStatus_(activity.consentDeadline, false);
const activityStatus = calculateDateStatus_(activity.activityDate, false);
const status = buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus);
writeCellByHeader_(sheet, row, map, '目前狀態', status);
writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
updated++;
}
updateDashboard_();
alert_('期限狀態已更新:' + updated + ' 筆活動。');
}
function buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus) {
const urgent = [];
if (registrationStatus === '逾期' || registrationStatus === '即將到期') urgent.push('報名' + registrationStatus);
if (consentStatus === '逾期' || consentStatus === '即將到期') urgent.push('同意書' + consentStatus);
if (activityStatus === '逾期') urgent.push('活動日期已過');
if (activityStatus === '即將到期') urgent.push('活動即將到期');
return urgent.length ? urgent.join(';') : '期限正常';
}
function buildDeadlineRows_(activity) {
const activityDate = parseDate_(activity.activityDate);
return [
['報名截止日', activity.registrationDeadline, '確認報名人數、未回覆名單與特殊需求'],
['同意書回收日', activity.consentDeadline, '確認同意書、保險與名冊'],
['活動前提醒日', addDays_(activityDate, -3), '確認導師、處室、家長與學生行前提醒'],
['成果整理日', addDays_(activityDate, 3), '整理照片、流程紀錄、回饋與附件'],
['NotebookLM 匯出日', addDays_(activityDate, 7), '去識別化後匯出資料包']
];
}
/**
* ============================================================
* NotebookExport.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:NotebookLM 資料包匯出。
*/
function exportNotebookLMPackageForSelectedActivity() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const content = buildNotebookMarkdown_(activity);
const doc = createNotebookDoc_(activity, content, packageContext.subfolders['07_NotebookLM資料包']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, 'NotebookLM資料包連結', doc.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, 'NotebookLM資料包', doc.getName(), '匯入 NotebookLM 的去識別化專案摘要', doc.getUrl(), '系統匯出', '草稿', '待確認', '匯入前請再次檢查個資');
updateDashboard_();
alert_('NotebookLM 資料包已匯出:\n' + doc.getUrl());
return doc.getUrl();
}
function buildNotebookMarkdown_(activity) {
const ss = getActiveSpreadsheet_();
const taskRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.TASKS), activity.id);
const documentRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS), activity.id);
const notificationRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS), activity.id);
const outcomeRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES), activity.id);
const sessionRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.SESSIONS), activity.id);
const registrationStats = getRegistrationStatsByActivity_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS))[activity.id] || { total: 0, join: 0, notJoin: 0, special: 0 };
return [
'# NotebookLM匯入用|活動行政專案資料包',
'',
'## 一、活動基本資料',
activityToText_(activity),
'',
'## 二、報名統計',
'- 報名總人數:' + registrationStats.total,
'- 參加人數:' + registrationStats.join,
'- 不參加人數:' + registrationStats.notJoin,
'- 特殊需求筆數:' + registrationStats.special,
'- 注意:此處只放統計,不放學生電話、健康細節或身分資料。',
'',
'## 三、場次總覽',
formatRowsForNotebook_(sessionRows, CAMP_SESSION_HEADERS, ['場次ID', '場次日期', '開始時間', '結束時間', '場次名稱', '場次主題', '講師', '地點或線上連結', '目前狀態', '報名人數', '簽到人數', '出席率']),
'',
'## 四、逐場任務狀態',
formatSessionTaskSummary_(taskRows),
'',
'## 五、逐場成果整理',
formatSessionOutcomeSummary_(outcomeRows),
'',
'## 六、多日活動行政觀察',
'- 請檢查哪些場次報名人數偏低、簽到率偏低或課前任務未完成。',
'- 請確認高風險場次是否已補上場地、講義、簽到、回饋與成果整理。',
'- 若後續場次會受前一場成果影響,請在任務追蹤表補上前置任務。',
'',
'## 七、重要期限',
buildDeadlineRows_(activity).map(function(row) {
return '- ' + row[0] + ':' + formatDateValue_(row[1]) + ',' + row[2];
}).join('\n'),
'',
'## 八、任務執行情形',
formatRowsForNotebook_(taskRows, CAMP_TASK_HEADERS, ['階段', '任務名稱', '負責人', '建議完成日期', '狀態', '優先程度']),
'',
'## 九、通知紀錄',
formatRowsForNotebook_(notificationRows, CAMP_NOTIFICATION_HEADERS, ['通知名稱', '通知對象', '通知管道', '建議通知日期', '寄送或通知狀態', '承辦人確認']),
'',
'## 十、文件清單',
formatRowsForNotebook_(documentRows, CAMP_DOCUMENT_HEADERS, ['文件類型', '文件名稱', '用途', '連結', '狀態', '承辦人確認']),
'',
'## 十一、成果與檢查',
formatRowsForNotebook_(outcomeRows, CAMP_OUTCOME_HEADERS, ['成果項目', '是否完成', '是否含個資', '是否可提供 NotebookLM', '負責人', '備註']),
'',
'## 十二、個資與匯出提醒',
'- 不匯出學生電話、家長電話、健康細節、身分資料、完整名冊。',
'- 不匯出完整簽到名冊、個別缺席原因或個別健康需求。',
'- 需要使用學生回饋時,請先去識別化。',
'- 正式公告、收費、保險、交通、安全與核銷仍由承辦人確認。',
'',
'## 十三、可問 NotebookLM 的問題範例',
'1. 這個活動目前最需要先確認哪些事項?',
'2. 哪些任務還沒有完成?',
'3. 家長通知或導師提醒有哪些缺漏?',
'4. 成果報告還缺哪些素材?',
'5. 下次辦理有哪些可沿用與需調整事項?',
'',
'## 十四、AI 提示詞',
CAMP_PROMPTS.notebook.replace('{{WORKFLOW_DATA}}', activityToText_(activity))
].join('\n');
}
function formatSessionTaskSummary_(taskRows) {
if (!taskRows.length) return '目前尚無逐場任務。';
const grouped = {};
taskRows.forEach(function(row) {
const sessionId = row[CAMP_TASK_HEADERS.indexOf('場次ID')] || 'ALL';
if (!grouped[sessionId]) grouped[sessionId] = [];
grouped[sessionId].push(row);
});
return Object.keys(grouped).sort().map(function(sessionId) {
const rows = grouped[sessionId];
const done = rows.filter(function(row) {
return row[CAMP_TASK_HEADERS.indexOf('狀態')] === '已完成';
}).length;
return '- ' + sessionId + ':任務 ' + rows.length + ' 筆,已完成 ' + done + ' 筆,完成率 ' + formatPercent_(done, rows.length);
}).join('\n');
}
function formatSessionOutcomeSummary_(outcomeRows) {
if (!outcomeRows.length) return '目前尚無逐場成果資料。';
const itemIndex = CAMP_OUTCOME_HEADERS.indexOf('成果項目');
const statusIndex = CAMP_OUTCOME_HEADERS.indexOf('是否完成');
return outcomeRows.filter(function(row) {
return /^S\d{2}/.test(String(row[itemIndex] || ''));
}).map(function(row) {
return '- ' + row[itemIndex] + ':' + valueOrPending_(row[statusIndex]);
}).join('\n') || '目前尚無逐場成果資料。';
}
function createNotebookDoc_(activity, content, folder) {
const doc = DocumentApp.create(activity.id + '_NotebookLM資料包_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
content.split('\n').forEach(function(line) {
if (line.indexOf('# ') === 0) {
body.appendParagraph(line.replace(/^# /, '')).setHeading(DocumentApp.ParagraphHeading.TITLE);
} else if (line.indexOf('## ') === 0) {
body.appendParagraph(line.replace(/^## /, '')).setHeading(DocumentApp.ParagraphHeading.HEADING1);
} else {
body.appendParagraph(line);
}
});
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function formatRowsForNotebook_(rows, headers, selectedHeaders) {
if (!rows.length) return '目前尚無資料。';
return rows.map(function(row) {
return '- ' + selectedHeaders.map(function(header) {
const index = headers.indexOf(header);
const value = index >= 0 ? row[index] : '';
return header + ':' + valueOrPending_(formatDateIfNeeded_(value));
}).join(';');
}).join('\n');
}
function formatDateIfNeeded_(value) {
if (value instanceof Date) return formatDateValue_(value);
return value;
}
/**
* ============================================================
* SampleData.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:研習示範資料。
*/
function addNewTaipeiAiWorkshopSample() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const activityId = 'ACT-NTPC-AI-11506';
const activityName = '新北AI共創大聯盟|Google Workspace 行政專案工作流自動化實作';
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
const existingRow = findActivityRowByIdOrName_(activitySheet, activityMap, activityId, activityName);
if (existingRow) {
const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
if (getRowsByActivityId_(sessionSheet, activityId).length === 0) {
appendNewTaipeiAiWorkshopSessions_(activityId);
updateDashboard_();
alert_('示範活動已存在,已補上缺漏的 4 筆場次設定。\n\n活動ID:' + activityId);
return;
}
alert_('示範資料已存在,不重複新增。\n\n活動ID:' + activityId);
return;
}
const startDate = new Date(2026, 5, 15);
const endDate = new Date(2026, 5, 25);
const registrationDeadline = new Date(2026, 5, 15);
const activity = {};
activity['活動ID'] = activityId;
activity['活動名稱'] = activityName;
activity['活動類型'] = '研習';
activity['報名表模板類型'] = '線上研習';
activity['活動日期'] = startDate;
activity['活動開始日'] = startDate;
activity['活動結束日'] = endDate;
activity['每日預設開始時間'] = '19:00';
activity['每日預設結束時間'] = '21:00';
activity['活動天數'] = 4;
activity['場次數'] = 4;
activity['是否多日活動'] = '是';
activity['是否開放部分場次報名'] = '是';
activity['是否需要每日簽到'] = '是';
activity['是否需要課後回饋'] = '是';
activity['是否需要成果繳交'] = '否';
activity['報名截止日'] = registrationDeadline;
activity['同意書回收日'] = '';
activity['活動地點'] = 'Google Meet 線上研習:https://meet.google.com/usb-mjie-ett';
activity['參加對象'] = '新北市各校教師及行政人員';
activity['預估人數'] = '';
activity['承辦處室'] = '新北市政府教育局/新北AI共創大聯盟';
activity['承辦人'] = '彭智標主任';
activity['承辦人Email'] = Session.getActiveUser().getEmail() || '';
activity['是否收費'] = '否';
activity['費用說明'] = '線上研習,未載明收費';
activity['交通方式'] = '線上研習無交通';
activity['是否需要保險'] = '否';
activity['是否需要家長同意書'] = '否';
activity['報名表單連結'] = 'https://forms.gle/heMoe3KLF97RB6Hy5';
activity['報名人數'] = 0;
activity['同意參加'] = 0;
activity['不參加'] = 0;
activity['特殊需求數'] = 0;
activity['目前狀態'] = '示範資料';
activity['最近更新時間'] = nowText_();
activity['備註'] = [
'示範資料:新北AI研習。',
'本筆資料已直接寫入活動清單與場次設定。',
'若使用活動新增表單同步功能,系統應略過同名活動,避免重複建立。',
'',
'公文:新北教技字第1151036018號。',
'研習名稱:「新北AI共創大聯盟」人工智慧增能研習—Google Workspace 行政專案工作流自動化實作。',
'課程綱要:https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
'研習主軸:從公文到成果,建立半自動行政專案管理工作流。',
'正式公告、研習時數、簽到與行政作業仍依主辦單位規定辦理。'
].join('\n');
activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) {
return activity[header] || '';
}));
appendNewTaipeiAiWorkshopSessions_(activityId);
updateDashboard_();
alert_([
'示範研習資料已新增完成。',
'',
'活動:' + activityName,
'活動ID:' + activityId,
'',
'已新增 1 筆活動資料與 4 筆場次設定。',
'請到「01_活動清單」選取這筆活動,再執行「從選取列建立活動資料包」。'
].join('\n'));
}
function appendNewTaipeiAiWorkshopSessions_(activityId) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
if (getRowsByActivityId_(sheet, activityId).length > 0) {
return;
}
const sessions = [
{
sessionId: 'S01',
date: new Date(2026, 5, 15),
title: '第一場|從公文啟動專案',
topic: '以教育局公文導入教學研習計畫為情境,學習判斷公文是否值得建立專案,並建立專案資料夾、追蹤表與成果檢核文件。'
},
{
sessionId: 'S02',
date: new Date(2026, 5, 18),
title: '第二場|活動型專案管理',
topic: '以校外教學/親子活動辦理為案例,拆解活動流程、建立階段性日期、設計報名表與回饋表。'
},
{
sessionId: 'S03',
date: new Date(2026, 5, 22),
title: '第三場|成果型專案與AI文件整理',
topic: '以補助計畫/成果報告為案例,建立 NotebookLM 專案筆記本,並運用 Gemini 輔助公告、摘要與成果報告草稿。'
},
{
sessionId: 'S04',
date: new Date(2026, 5, 25),
title: '第四場|大型專案協作與制度化',
topic: '以運動會/校慶大型活動為案例,學習跨處室分工、大量日期與任務管理、成果檢核與制度化。'
}
];
const rows = sessions.map(function(session) {
return buildSessionSheetRow_({
'活動ID': activityId,
'場次ID': session.sessionId,
'場次日期': session.date,
'開始時間': '19:00',
'結束時間': '21:00',
'場次名稱': session.title,
'場次主題': session.topic,
'場次目標': 'Google Workspace、Apps Script、NotebookLM、Gemini 行政工作流實作',
'講師': '彭智標主任',
'助教或協作人員': '新北市立泰山高中協助簽到與研習時數行政作業',
'地點或線上連結': 'Google Meet:https://meet.google.com/usb-mjie-ett',
'本場需要設備': 'Google 帳號、可操作 Google Workspace 的電腦、近期實際行政專案案例',
'講義連結': 'https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
'報名人數': 0,
'簽到人數': 0,
'出席率': '0%',
'課前任務完成率': '0%',
'課後回饋數': 0,
'成果整理狀態': '未整理',
'風險等級': '低',
'目前狀態': '準備中',
'備註': '由新北研習公文建立之示範場次',
'任務完成率': '0%',
'任務完成狀態': '未開始'
});
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
}
function findActivityRowByIdOrName_(sheet, headerMap, activityId, activityName) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
if (existingId === activityId || existingName === activityName) {
return row;
}
}
return 0;
}
/**
* ============================================================
* MenuFlows.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:選單流程入口與相容函式。
*/
function updateDashboard() {
updateDashboard_();
alert_('中控台已重新整理。');
}
function createOfficialRegistrationFormForSelectedActivity() {
return createRegistrationFormForSelectedActivity();
}
function syncCalendarStatusBackToTasks() {
return syncCalendarBackToTaskOverview();
}
function runOfficialActivitySetupFlow() {
const context = getSelectedActivityRowContext_();
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
generateSessionsForActivity_(context.activity);
updateActivitySessionFields_(context);
appendDefaultTaskRows_(context.activity);
appendDefaultNotificationRows_(context.activity);
appendDefaultOutcomeRows_(context.activity);
appendNotebookIndexRows_(context.activity);
refreshTaskOverview();
updateDeadlineStatuses();
updateDashboard_();
alert_([
'正式活動骨架已建立或確認完成。',
'',
'活動:' + context.activity.name,
'資料夾:' + packageContext.folder.getUrl(),
'',
'已確認:資料包、場次、任務、通知、成果與 NotebookLM 索引。'
].join('\n'));
}
function runDemoSessionAttendanceFlow() {
const context = getSelectedActivityRowContext_();
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
const sessions = generateSessionsForActivity_(context.activity);
updateActivitySessionFields_(context);
const attendanceRows = generateAttendanceRowsForActivity_(context.activity.id);
const formResult = createAttendanceFormsForActivityContext_(context);
updateSessionStats_();
updateDashboard_();
alert_([
'一鍵展示流程已完成。',
'',
'活動:' + context.activity.name,
'場次數:' + sessions.length,
'新增簽到預備資料:' + attendanceRows + ' 筆',
'新建立簽到表單:' + formResult.created + ' 張',
'已存在略過:' + formResult.skipped + ' 張',
'',
'資料包:' + packageContext.folder.getUrl()
].join('\n'));
}
function createOutcomeChecklistSheetForSelectedActivity() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const outcomeSheet = createOutcomeChecklistSheet_(activity, packageContext.subfolders['06_成果檢查與結案']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '檢查活動後成果、照片、附件與 NotebookLM 可用資料', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
updateDashboard_();
alert_('成果檢查表已建立:\n' + outcomeSheet.getUrl());
return outcomeSheet.getUrl();
}
function runOutcomeCloseoutFlow() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const outcomeSheet = createOutcomeChecklistSheet_(activity, packageContext.subfolders['06_成果檢查與結案']);
const notebookContent = buildNotebookMarkdown_(activity);
const notebookDoc = createNotebookDoc_(activity, notebookContent, packageContext.subfolders['07_NotebookLM資料包']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, 'NotebookLM資料包連結', notebookDoc.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '目前狀態', '成果結案資料已整理');
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '活動後成果、照片、附件與 NotebookLM 可用資料檢查', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, 'NotebookLM資料包', notebookDoc.getName(), '匯入 NotebookLM 的去識別化專案摘要', notebookDoc.getUrl(), '系統匯出', '草稿', '待確認', '匯入前請再次檢查個資');
refreshTaskOverview();
updateDashboard_();
alert_([
'成果與結案資料已整理。',
'',
'成果檢查表:' + outcomeSheet.getUrl(),
'NotebookLM 資料包:' + notebookDoc.getUrl()
].join('\n'));
}
/**
* ============================================================
* Code.gs
* ============================================================
*/
/**
* 營隊/活動管理中樞:選單與主要入口。
*/
function onOpen() {
buildCampAdminMenu_();
}
function onEdit(e) {
handleTaskOverviewStatusEdit_(e);
}
function buildCampAdminMenu_() {
try {
const ui = SpreadsheetApp.getUi();
const demoMenu = ui.createMenu('一、研習展示')
.addItem('初始化展示環境', 'setupCampAdminHub')
.addItem('新增示範資料:新北AI研習', 'addNewTaipeiAiWorkshopSample')
.addItem('建立報名表單+50筆假資料', 'createRegistrationFormAndSeedSampleData')
.addItem('一鍵展示:場次、簽到、統計', 'runDemoSessionAttendanceFlow')
.addItem('匯出展示用 NotebookLM 資料包', 'exportNotebookLMPackageForSelectedActivity');
const activityMenu = ui.createMenu('二、正式活動建案')
.addItem('建立/開啟活動新增表單', 'createActivityIntakeForm')
.addItem('匯入活動表單資料', 'syncActivityIntakeResponsesToActivityList')
.addItem('為選取活動建立資料包', 'createActivityPackageFromSelectedRow')
.addItem('一鍵建立正式活動骨架', 'runOfficialActivitySetupFlow')
.addItem('重新整理中控台', 'updateDashboard');
const registrationMenu = ui.createMenu('三、報名管理')
.addItem('建立正式報名表單', 'createOfficialRegistrationFormForSelectedActivity')
.addItem('建立報名表單+50筆假資料(研習用)', 'createRegistrationFormAndSeedSampleData')
.addItem('更新報名名冊與統計', 'syncRegistrationResponses');
const sessionMenu = ui.createMenu('四、場次與簽到')
.addItem('產生/更新場次設定', 'generateSessionsForSelectedActivity')
.addItem('更新場次統計', 'updateSessionStats')
.addItem('建立各場次應到名單', 'generateAttendanceRowsForSelectedActivity')
.addItem('產生場次簽到表單', 'createAttendanceFormsForSelectedActivity')
.addItem('比對簽到回應並更新出席狀態', 'syncAttendanceFormResponses');
const taskMenu = ui.createMenu('五、任務、期限與日曆')
.addItem('更新任務進度與重要行事', 'refreshTaskOverview')
.addItem('檢查期限與逾期風險', 'updateDeadlineStatuses')
.addItem('同步重要任務到 Google 日曆', 'syncImportantTasksToCalendar')
.addItem('從 Google 日曆回寫任務狀態', 'syncCalendarStatusBackToTasks')
.addItem('建立日曆同步觸發器', 'createCalendarSyncTrigger')
.addItem('移除日曆同步觸發器', 'removeCalendarSyncTrigger')
.addItem('更新中控台燈號', 'updateDashboard');
const docMenu = ui.createMenu('六、行政文件與通知')
.addItem('建立行政文件模板', 'createDocumentTemplatesForSelectedActivity')
.addItem('寄送測試信給承辦人', 'sendTestEmailToOwner');
const outcomeMenu = ui.createMenu('七、成果與結案')
.addItem('建立成果檢查表', 'createOutcomeChecklistSheetForSelectedActivity')
.addItem('一鍵整理成果與結案資料', 'runOutcomeCloseoutFlow')
.addItem('匯出 AI 成果整理資料包', 'exportNotebookLMPackageForSelectedActivity');
const systemMenu = ui.createMenu('八、系統維護與說明')
.addItem('建立/修復中控台環境', 'setupCampAdminHub')
.addItem('重新整理中控台', 'updateDashboard')
.addItem('開啟操作說明', 'showAboutCampAdminHub');
ui.createMenu('營隊活動管理')
.addSubMenu(demoMenu)
.addSubMenu(activityMenu)
.addSubMenu(registrationMenu)
.addSubMenu(sessionMenu)
.addSubMenu(taskMenu)
.addSubMenu(docMenu)
.addSubMenu(outcomeMenu)
.addSubMenu(systemMenu)
.addToUi();
} catch (error) {
Logger.log('建立選單失敗:' + error.message);
}
}
function setupCampAdminHub() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
createActivityIntakeForm_();
updateDashboard_();
alert_('營隊活動管理中樞初始化完成。已建立活動新增表單,請從「00_中控台」開啟表單填寫,或到「01_活動清單」手動新增活動。');
}
function showAboutCampAdminHub() {
alert_([
CAMP_CONFIG.SYSTEM_NAME,
'',
'選單已依使用情境分組:',
'一、研習展示:初始化展示環境、示範資料、假報名資料與展示流程。',
'二、正式活動建案:活動新增表單、匯入資料、建立資料包與正式活動骨架。',
'三、報名管理:正式報名表、研習用假資料、報名名冊與統計。',
'四、場次與簽到:場次設定、應到名單、簽到表單與出席狀態。',
'五、任務、期限與日曆:任務總覽、期限風險、Google 日曆雙向同步。',
'六、行政文件與通知:行政文件模板與承辦人測試信。',
'七、成果與結案:成果檢查、結案資料與 NotebookLM 資料包。',
'八、系統維護與說明:修復中控台、重新整理中控台與操作說明。',
'',
'建議先選擇流程:研習展示請從「一、研習展示」開始;正式活動請從「二、正式活動建案」開始。',
'',
CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
].join('\n'));
}
六、分檔學習版
以下是同一套程式的分檔版本,用來講解架構與後續維護。若已貼完整一次貼上版,Apps Script 專案內不需要再重複貼這些分檔程式碼。
檔案 01
Code.gs|入口選單與主流程
/**
* 營隊/活動管理中樞:選單與主要入口。
*/
function onOpen() {
buildCampAdminMenu_();
}
function onEdit(e) {
handleTaskOverviewStatusEdit_(e);
}
function buildCampAdminMenu_() {
try {
const ui = SpreadsheetApp.getUi();
const demoMenu = ui.createMenu('一、研習展示')
.addItem('初始化展示環境', 'setupCampAdminHub')
.addItem('新增示範資料:新北AI研習', 'addNewTaipeiAiWorkshopSample')
.addItem('建立報名表單+50筆假資料', 'createRegistrationFormAndSeedSampleData')
.addItem('一鍵展示:場次、簽到、統計', 'runDemoSessionAttendanceFlow')
.addItem('匯出展示用 NotebookLM 資料包', 'exportNotebookLMPackageForSelectedActivity');
const activityMenu = ui.createMenu('二、正式活動建案')
.addItem('建立/開啟活動新增表單', 'createActivityIntakeForm')
.addItem('匯入活動表單資料', 'syncActivityIntakeResponsesToActivityList')
.addItem('為選取活動建立資料包', 'createActivityPackageFromSelectedRow')
.addItem('一鍵建立正式活動骨架', 'runOfficialActivitySetupFlow')
.addItem('重新整理中控台', 'updateDashboard');
const registrationMenu = ui.createMenu('三、報名管理')
.addItem('建立正式報名表單', 'createOfficialRegistrationFormForSelectedActivity')
.addItem('建立報名表單+50筆假資料(研習用)', 'createRegistrationFormAndSeedSampleData')
.addItem('更新報名名冊與統計', 'syncRegistrationResponses');
const sessionMenu = ui.createMenu('四、場次與簽到')
.addItem('產生/更新場次設定', 'generateSessionsForSelectedActivity')
.addItem('更新場次統計', 'updateSessionStats')
.addItem('建立各場次應到名單', 'generateAttendanceRowsForSelectedActivity')
.addItem('產生場次簽到表單', 'createAttendanceFormsForSelectedActivity')
.addItem('比對簽到回應並更新出席狀態', 'syncAttendanceFormResponses');
const taskMenu = ui.createMenu('五、任務、期限與日曆')
.addItem('更新任務進度與重要行事', 'refreshTaskOverview')
.addItem('檢查期限與逾期風險', 'updateDeadlineStatuses')
.addItem('同步重要任務到 Google 日曆', 'syncImportantTasksToCalendar')
.addItem('從 Google 日曆回寫任務狀態', 'syncCalendarStatusBackToTasks')
.addItem('建立日曆同步觸發器', 'createCalendarSyncTrigger')
.addItem('移除日曆同步觸發器', 'removeCalendarSyncTrigger')
.addItem('更新中控台燈號', 'updateDashboard');
const docMenu = ui.createMenu('六、行政文件與通知')
.addItem('建立行政文件模板', 'createDocumentTemplatesForSelectedActivity')
.addItem('寄送測試信給承辦人', 'sendTestEmailToOwner');
const outcomeMenu = ui.createMenu('七、成果與結案')
.addItem('建立成果檢查表', 'createOutcomeChecklistSheetForSelectedActivity')
.addItem('一鍵整理成果與結案資料', 'runOutcomeCloseoutFlow')
.addItem('匯出 AI 成果整理資料包', 'exportNotebookLMPackageForSelectedActivity');
const systemMenu = ui.createMenu('八、系統維護與說明')
.addItem('建立/修復中控台環境', 'setupCampAdminHub')
.addItem('重新整理中控台', 'updateDashboard')
.addItem('開啟操作說明', 'showAboutCampAdminHub');
ui.createMenu('營隊活動管理')
.addSubMenu(demoMenu)
.addSubMenu(activityMenu)
.addSubMenu(registrationMenu)
.addSubMenu(sessionMenu)
.addSubMenu(taskMenu)
.addSubMenu(docMenu)
.addSubMenu(outcomeMenu)
.addSubMenu(systemMenu)
.addToUi();
} catch (error) {
Logger.log('建立選單失敗:' + error.message);
}
}
function setupCampAdminHub() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
createActivityIntakeForm_();
updateDashboard_();
alert_('營隊活動管理中樞初始化完成。已建立活動新增表單,請從「00_中控台」開啟表單填寫,或到「01_活動清單」手動新增活動。');
}
function showAboutCampAdminHub() {
alert_([
CAMP_CONFIG.SYSTEM_NAME,
'',
'選單已依使用情境分組:',
'一、研習展示:初始化展示環境、示範資料、假報名資料與展示流程。',
'二、正式活動建案:活動新增表單、匯入資料、建立資料包與正式活動骨架。',
'三、報名管理:正式報名表、研習用假資料、報名名冊與統計。',
'四、場次與簽到:場次設定、應到名單、簽到表單與出席狀態。',
'五、任務、期限與日曆:任務總覽、期限風險、Google 日曆雙向同步。',
'六、行政文件與通知:行政文件模板與承辦人測試信。',
'七、成果與結案:成果檢查、結案資料與 NotebookLM 資料包。',
'八、系統維護與說明:修復中控台、重新整理中控台與操作說明。',
'',
'建議先選擇流程:研習展示請從「一、研習展示」開始;正式活動請從「二、正式活動建案」開始。',
'',
CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
].join('\n'));
}
檔案 02
Config.gs|集中設定與欄位常數
/**
* 營隊/活動管理中樞:集中設定與欄位常數。
*
* 教學重點:
* 1. 所有工作表名稱、欄位、資料夾名稱集中管理。
* 2. 後續模組只引用這裡的設定,不把欄位名稱寫散在各處。
*/
const CAMP_CONFIG = {
SYSTEM_NAME: '營隊活動管理中樞',
ROOT_FOLDER_ID: '',
CALENDAR_ID: '',
TIMEZONE: 'Asia/Taipei',
DATE_SOON_DAYS: 7,
HUMAN_CONFIRMATION_NOTICE: '本系統協助建檔、整理、提醒與產出草稿。正式公告、收費、保險、交通、安全、個資、採購與核銷仍須由承辦人或權責單位確認。'
};
const CAMP_SHEETS = {
DASHBOARD: '00_中控台',
ACTIVITIES: '01_活動清單',
SESSIONS: '02_場次設定',
REGISTRATIONS: '03_報名資料',
ATTENDANCE: '04_場次簽到',
TASK_OVERVIEW: '05_任務總覽',
TASKS: '05_任務追蹤',
DOCUMENTS: '06_文件清單',
NOTIFICATIONS: '07_通知管理',
OUTCOMES: '08_成果檢查',
NOTEBOOK: '09_NotebookLM匯出',
SETTINGS: '10_系統設定'
};
const CAMP_SHEET_ORDER = [
CAMP_SHEETS.DASHBOARD,
CAMP_SHEETS.ACTIVITIES,
CAMP_SHEETS.SESSIONS,
CAMP_SHEETS.REGISTRATIONS,
CAMP_SHEETS.ATTENDANCE,
CAMP_SHEETS.TASK_OVERVIEW,
CAMP_SHEETS.TASKS,
CAMP_SHEETS.DOCUMENTS,
CAMP_SHEETS.NOTIFICATIONS,
CAMP_SHEETS.OUTCOMES,
CAMP_SHEETS.NOTEBOOK,
CAMP_SHEETS.SETTINGS
];
const CAMP_ACTIVITY_HEADERS = [
'活動ID',
'活動名稱',
'活動類型',
'活動日期',
'活動開始日',
'活動結束日',
'每日預設開始時間',
'每日預設結束時間',
'活動天數',
'場次數',
'是否多日活動',
'是否開放部分場次報名',
'是否需要每日簽到',
'是否需要課後回饋',
'是否需要成果繳交',
'報名截止日',
'同意書回收日',
'活動地點',
'參加對象',
'預估人數',
'承辦處室',
'承辦人',
'承辦人Email',
'是否收費',
'費用說明',
'交通方式',
'是否需要保險',
'是否需要家長同意書',
'專案資料夾連結',
'報名表單連結',
'報名表單編輯連結',
'報名回應表連結',
'活動摘要連結',
'家長通知書連結',
'家長同意書連結',
'標案需求表連結',
'處室通知草稿連結',
'導師行前提醒連結',
'成果檢查表連結',
'NotebookLM資料包連結',
'報名人數',
'同意參加',
'不參加',
'特殊需求數',
'目前狀態',
'最近更新時間',
'備註',
'報名表模板類型',
'任務完成率'
];
const CAMP_SESSION_HEADERS = [
'活動ID',
'場次ID',
'場次日期',
'開始時間',
'結束時間',
'場次名稱',
'場次主題',
'場次目標',
'講師',
'助教或協作人員',
'地點或線上連結',
'本場需要設備',
'講義連結',
'簽到表單連結',
'回饋表單連結',
'錄影連結',
'報名人數',
'簽到人數',
'出席率',
'課前任務完成率',
'課後回饋數',
'成果整理狀態',
'風險等級',
'目前狀態',
'備註',
'任務完成率',
'任務完成狀態'
];
const CAMP_REGISTRATION_HEADERS = [
'活動ID',
'活動名稱',
'時間戳記',
'學生姓名',
'班級',
'座號',
'家長姓名',
'家長Email',
'家長電話',
'是否參加',
'是否全程參加',
'選擇參加場次',
'需要研習時數',
'學習期待',
'目前行政痛點',
'是否同意通知',
'飲食需求',
'特殊需求',
'同意書狀態',
'是否可提供 NotebookLM',
'來源分頁',
'備註'
];
const CAMP_ATTENDANCE_HEADERS = [
'活動ID',
'場次ID',
'場次名稱',
'場次日期',
'學生或學員姓名',
'學校或單位',
'Email',
'報名資料Key',
'是否報名本場',
'簽到狀態',
'簽到時間',
'簽退狀態',
'簽退時間',
'可核發時數',
'備註'
];
const CAMP_TASK_HEADERS = [
'活動ID',
'場次ID',
'WBS編碼',
'階段',
'任務名稱',
'具體任務',
'交付物',
'完成判準',
'負責角色',
'負責人',
'建議開始日期',
'建議完成日期',
'實際完成日期',
'前置任務ID',
'是否關鍵任務',
'需要文件或表單',
'狀態',
'優先程度',
'卡關原因',
'備註',
'最後更新時間'
];
const CAMP_TASK_OVERVIEW_HEADERS = [
'任務ID',
'活動ID',
'活動名稱',
'場次ID',
'場次名稱',
'階段',
'任務名稱',
'任務類型',
'負責人',
'截止日',
'到期日',
'狀態',
'優先程度',
'卡關原因/下一步',
'備註',
'重要行事',
'日曆標題',
'日曆日期',
'開始時間',
'結束時間',
'日曆事件ID',
'日曆同步狀態',
'最後同步時間',
'日曆回寫狀態',
'日曆最後讀取時間',
'完成時間',
'完成日',
'最後更新時間'
];
const CAMP_DOCUMENT_HEADERS = [
'活動ID',
'活動名稱',
'文件類型',
'文件名稱',
'用途',
'連結',
'產生方式',
'狀態',
'最後更新時間',
'承辦人確認',
'備註'
];
const CAMP_NOTIFICATION_HEADERS = [
'活動ID',
'通知名稱',
'通知對象',
'通知管道',
'建議通知日期',
'實際通知日期',
'文件連結',
'寄送或通知狀態',
'承辦人確認',
'備註'
];
const CAMP_OUTCOME_HEADERS = [
'活動ID',
'成果項目',
'是否完成',
'檔案或資料夾連結',
'是否含個資',
'是否可提供 NotebookLM',
'負責人',
'備註'
];
const CAMP_NOTEBOOK_HEADERS = [
'活動ID',
'資料區塊',
'內容摘要',
'來源文件或工作表',
'是否納入匯出',
'備註'
];
const CAMP_SETTINGS_HEADERS = ['設定項目', '設定值', '說明'];
const CAMP_SUBFOLDER_NAMES = [
'00_活動基本資料',
'01_報名表單與回覆',
'02_家長通知與同意書',
'03_處室聯繫與採購',
'04_任務追蹤與會議紀錄',
'05_活動照片與成果',
'06_成果檢查與結案',
'07_NotebookLM資料包',
'99_系統產生文件'
];
const CAMP_TASK_STATUS_VALUES = ['未開始', '進行中', '待確認', '已完成', '延後', '取消', '不適用'];
const CAMP_PRIORITY_VALUES = ['高', '中', '低'];
const CAMP_TASK_STATUS_COLORS = {
'未開始': '#f3f4f6',
'進行中': '#dbeafe',
'待確認': '#fef3c7',
'已完成': '#dcfce7',
'延後': '#fee2e2',
'取消': '#f5f5f5',
'不適用': '#ede9fe'
};
const CAMP_PRIORITY_COLORS = {
'高': '#fecaca',
'中': '#fde68a',
'低': '#bbf7d0'
};
const CAMP_OVERDUE_ROW_COLOR = '#fee2e2';
const CAMP_SOON_ROW_COLOR = '#fef9c3';
const CAMP_CONFIRM_VALUES = ['未確認', '待確認', '已確認', '退回修改', '不適用'];
const CAMP_YES_NO_VALUES = ['是', '否', '待確認', '不適用'];
const CAMP_DATE_STATUS_VALUES = ['未到期', '即將到期', '逾期', '已完成', '無日期'];
const CAMP_SESSION_STATUS_VALUES = ['未開始', '準備中', '進行中', '已完成', '待補件', '取消'];
const CAMP_RISK_LEVEL_VALUES = ['低', '中', '高', '極高'];
const CAMP_ATTENDANCE_STATUS_VALUES = ['未簽到', '已簽到', '請假', '缺席', '不適用'];
const CAMP_REGISTRATION_TEMPLATE_TYPES = ['自動判斷', '教師研習', '線上研習', '學生營隊', '校外教學', '親子活動', '競賽活動', '自訂'];
檔案 03
Utilities.gs|共用工具函式
/**
* 營隊/活動管理中樞:共用工具函式。
*/
function getActiveSpreadsheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
if (!ss) throw new Error('請先開啟已綁定 Apps Script 的 Google 試算表。');
return ss;
}
function alert_(message) {
try {
SpreadsheetApp.getUi().alert(String(message));
} catch (error) {
Logger.log(String(message));
}
}
function getOrCreateSheet_(ss, name) {
return ss.getSheetByName(name) || ss.insertSheet(name);
}
function ensureHeaders_(sheet, headers) {
const width = headers.length;
const existing = sheet.getRange(1, 1, 1, Math.max(width, sheet.getLastColumn() || width)).getValues()[0];
let changed = false;
headers.forEach(function(header, index) {
if (existing[index] !== header) changed = true;
});
if (changed) {
sheet.getRange(1, 1, 1, width).setValues([headers]);
}
formatHeaderRow_(sheet, width);
return buildHeaderMap_(sheet);
}
function formatHeaderRow_(sheet, columns) {
sheet.getRange(1, 1, 1, columns)
.setFontWeight('bold')
.setBackground('#dbeafe')
.setHorizontalAlignment('center')
.setWrap(true);
sheet.setFrozenRows(1);
sheet.autoResizeColumns(1, columns);
}
function buildHeaderMap_(sheet) {
const lastColumn = sheet.getLastColumn();
if (!lastColumn) return {};
const headers = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
const map = {};
headers.forEach(function(header, index) {
if (!header) return;
map[String(header).trim()] = index + 1;
});
return map;
}
function readCellByHeader_(sheet, row, headerMap, header) {
const column = headerMap[header];
if (!column) return '';
return sheet.getRange(row, column).getValue();
}
function writeCellByHeader_(sheet, row, headerMap, header, value) {
const column = headerMap[header];
if (!column) return;
sheet.getRange(row, column).setValue(value);
}
function readRowObject_(sheet, row, headers) {
const map = buildHeaderMap_(sheet);
const object = {};
headers.forEach(function(header) {
object[header] = readCellByHeader_(sheet, row, map, header);
});
return object;
}
function getSelectedActivityRowContext_() {
const ss = getActiveSpreadsheet_();
const sheet = ss.getActiveSheet();
if (sheet.getName() !== CAMP_SHEETS.ACTIVITIES) {
throw new Error('請先到「' + CAMP_SHEETS.ACTIVITIES + '」選取一筆活動資料列。');
}
const row = sheet.getActiveCell().getRow();
if (row <= 1) throw new Error('請選取活動資料列,不要選標題列。');
const headerMap = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
const activity = readActivityFromRow_(sheet, row, headerMap);
if (!activity.name) throw new Error('選取列缺少「活動名稱」。');
return { ss: ss, sheet: sheet, row: row, headerMap: headerMap, activity: activity };
}
function readActivityFromRow_(sheet, row, headerMap) {
return {
id: String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim(),
name: String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim(),
type: String(readCellByHeader_(sheet, row, headerMap, '活動類型') || '').trim(),
registrationTemplateType: String(readCellByHeader_(sheet, row, headerMap, '報名表模板類型') || '').trim(),
activityDate: readCellByHeader_(sheet, row, headerMap, '活動日期'),
startDate: readCellByHeader_(sheet, row, headerMap, '活動開始日'),
endDate: readCellByHeader_(sheet, row, headerMap, '活動結束日'),
defaultStartTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設開始時間') || '').trim(),
defaultEndTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設結束時間') || '').trim(),
activityDays: readCellByHeader_(sheet, row, headerMap, '活動天數'),
sessionCount: readCellByHeader_(sheet, row, headerMap, '場次數'),
isMultiDay: String(readCellByHeader_(sheet, row, headerMap, '是否多日活動') || '').trim(),
allowPartialSessions: String(readCellByHeader_(sheet, row, headerMap, '是否開放部分場次報名') || '').trim(),
dailyAttendanceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要每日簽到') || '').trim(),
feedbackRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要課後回饋') || '').trim(),
outcomeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要成果繳交') || '').trim(),
registrationDeadline: readCellByHeader_(sheet, row, headerMap, '報名截止日'),
consentDeadline: readCellByHeader_(sheet, row, headerMap, '同意書回收日'),
location: String(readCellByHeader_(sheet, row, headerMap, '活動地點') || '').trim(),
participants: String(readCellByHeader_(sheet, row, headerMap, '參加對象') || '').trim(),
estimatedCount: readCellByHeader_(sheet, row, headerMap, '預估人數'),
department: String(readCellByHeader_(sheet, row, headerMap, '承辦處室') || '').trim(),
owner: String(readCellByHeader_(sheet, row, headerMap, '承辦人') || '').trim(),
ownerEmail: String(readCellByHeader_(sheet, row, headerMap, '承辦人Email') || '').trim(),
chargeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否收費') || '').trim(),
feeNote: String(readCellByHeader_(sheet, row, headerMap, '費用說明') || '').trim(),
transport: String(readCellByHeader_(sheet, row, headerMap, '交通方式') || '').trim(),
insuranceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要保險') || '').trim(),
consentRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要家長同意書') || '').trim(),
folderUrl: String(readCellByHeader_(sheet, row, headerMap, '專案資料夾連結') || '').trim(),
registrationFormUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單連結') || '').trim(),
registrationFormEditUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單編輯連結') || '').trim(),
summaryDocUrl: String(readCellByHeader_(sheet, row, headerMap, '活動摘要連結') || '').trim(),
parentNoticeUrl: String(readCellByHeader_(sheet, row, headerMap, '家長通知書連結') || '').trim(),
parentConsentUrl: String(readCellByHeader_(sheet, row, headerMap, '家長同意書連結') || '').trim(),
procurementUrl: String(readCellByHeader_(sheet, row, headerMap, '標案需求表連結') || '').trim(),
officeEmailUrl: String(readCellByHeader_(sheet, row, headerMap, '處室通知草稿連結') || '').trim(),
teacherReminderUrl: String(readCellByHeader_(sheet, row, headerMap, '導師行前提醒連結') || '').trim(),
outcomeUrl: String(readCellByHeader_(sheet, row, headerMap, '成果檢查表連結') || '').trim(),
notebookUrl: String(readCellByHeader_(sheet, row, headerMap, 'NotebookLM資料包連結') || '').trim(),
note: String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim()
};
}
function activityToText_(activity) {
return [
'活動ID:' + valueOrPending_(activity.id),
'活動名稱:' + valueOrPending_(activity.name),
'活動類型:' + valueOrPending_(activity.type),
'報名表模板類型:' + valueOrPending_(activity.registrationTemplateType || '自動判斷'),
'活動日期:' + formatDateValue_(activity.activityDate),
'活動開始日:' + formatDateValue_(activity.startDate),
'活動結束日:' + formatDateValue_(activity.endDate),
'每日預設時間:' + valueOrPending_(activity.defaultStartTime || '09:00') + '-' + valueOrPending_(activity.defaultEndTime || '12:00'),
'活動天數:' + valueOrPending_(activity.activityDays),
'場次數:' + valueOrPending_(activity.sessionCount),
'是否多日活動:' + valueOrPending_(activity.isMultiDay),
'是否開放部分場次報名:' + valueOrPending_(activity.allowPartialSessions),
'是否需要每日簽到:' + valueOrPending_(activity.dailyAttendanceRequired),
'是否需要課後回饋:' + valueOrPending_(activity.feedbackRequired),
'是否需要成果繳交:' + valueOrPending_(activity.outcomeRequired),
'報名截止日:' + formatDateValue_(activity.registrationDeadline),
'同意書回收日:' + formatDateValue_(activity.consentDeadline),
'活動地點:' + valueOrPending_(activity.location),
'參加對象:' + valueOrPending_(activity.participants),
'預估人數:' + valueOrPending_(activity.estimatedCount),
'承辦處室:' + valueOrPending_(activity.department),
'承辦人:' + valueOrPending_(activity.owner),
'承辦人Email:' + valueOrPending_(activity.ownerEmail),
'是否收費:' + valueOrPending_(activity.chargeRequired),
'費用說明:' + valueOrPending_(activity.feeNote),
'交通方式:' + valueOrPending_(activity.transport),
'是否需要保險:' + valueOrPending_(activity.insuranceRequired),
'是否需要家長同意書:' + valueOrPending_(activity.consentRequired),
'備註:' + valueOrPending_(activity.note),
'',
'場次摘要:',
formatSessionsText_(activity.id)
].join('\n');
}
function generateActivityId_() {
const dateText = Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd-HHmmss');
const props = PropertiesService.getScriptProperties();
const key = 'CAMP_ACTIVITY_SERIAL_' + Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd');
const next = Number(props.getProperty(key) || '0') + 1;
props.setProperty(key, String(next));
return 'ACT-' + dateText + '-' + ('000' + next).slice(-3);
}
function nowText_() {
return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm:ss');
}
function nowMinuteText_() {
return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm');
}
function parseDate_(value) {
if (value instanceof Date && !isNaN(value.getTime())) return value;
if (!value) return null;
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
function addDays_(date, days) {
if (!date) return null;
const copy = new Date(date.getTime());
copy.setDate(copy.getDate() + days);
return copy;
}
function formatDateValue_(value) {
const date = parseDate_(value);
if (!date) return value ? String(value) : '待確認';
return Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd');
}
function effectiveActivityStartDate_(activity) {
return parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}
function effectiveActivityEndDate_(activity) {
return parseDate_(activity.endDate) || parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}
function calculateActivityDays_(activity) {
const startDate = effectiveActivityStartDate_(activity);
const endDate = effectiveActivityEndDate_(activity);
if (!startDate || !endDate) return '';
const start = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const end = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return Math.max(1, Math.floor((end.getTime() - start.getTime()) / 86400000) + 1);
}
function formatPercent_(numerator, denominator) {
if (!denominator) return '0%';
return Math.round((Number(numerator || 0) / Number(denominator || 0)) * 100) + '%';
}
function isSameDate_(a, b) {
const da = parseDate_(a);
const db = parseDate_(b);
if (!da || !db) return false;
return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
}
function calculateDateStatus_(value, done) {
if (done) return '已完成';
const date = parseDate_(value);
if (!date) return '無日期';
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date.getTime());
target.setHours(0, 0, 0, 0);
const diffDays = Math.floor((target.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return '逾期';
if (diffDays <= CAMP_CONFIG.DATE_SOON_DAYS) return '即將到期';
return '未到期';
}
function sanitizeFileName_(value) {
return String(value || '未命名活動').replace(/[\\/:*?"<>|#%{}~&]/g, '_').slice(0, 120);
}
function valueOrPending_(value) {
if (value === null || value === undefined || value === '') return '待確認';
return String(value);
}
function extractIdFromUrl_(url) {
const text = String(url || '');
const match = text.match(/[-\w]{25,}/);
return match ? match[0] : '';
}
function getRowsByActivityId_(sheet, activityId) {
if (!sheet || sheet.getLastRow() <= 1) return [];
const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
const headers = values[0];
const idIndex = headers.indexOf('活動ID');
if (idIndex < 0) return [];
return values.slice(1).filter(function(row) {
return String(row[idIndex] || '') === String(activityId || '');
});
}
function buildRowsByHeader_(sheet) {
if (!sheet || sheet.getLastRow() <= 1) return [];
const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
const headers = values[0];
return values.slice(1).map(function(row) {
const object = {};
headers.forEach(function(header, index) {
if (header) object[String(header)] = row[index];
});
return object;
});
}
function appendDocumentListRow_(activity, type, name, purpose, url, method, status, confirmation, note) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS);
ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
sheet.appendRow([
activity.id,
activity.name,
type,
name,
purpose,
url,
method || '系統模板',
status || '草稿',
nowText_(),
confirmation || '待確認',
note || ''
]);
}
function setValidationList_(sheet, startRow, column, rowCount, values) {
if (rowCount <= 0) return;
const rule = SpreadsheetApp.newDataValidation()
.requireValueInList(values, true)
.setAllowInvalid(true)
.build();
sheet.getRange(startRow, column, rowCount, 1).setDataValidation(rule);
}
檔案 04
SheetSetup.gs|中控台與工作表初始化
/**
* 營隊/活動管理中樞:中控 Spreadsheet 初始化。
*/
function initializeSheets_(ss) {
CAMP_SHEET_ORDER.forEach(function(name) {
getOrCreateSheet_(ss, name);
});
setupDashboardSheet_(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
setupActivityListSheet_(ss.getSheetByName(CAMP_SHEETS.ACTIVITIES));
setupSessionSheet_(ss.getSheetByName(CAMP_SHEETS.SESSIONS));
setupRegistrationSheet_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS));
setupAttendanceSheet_(ss.getSheetByName(CAMP_SHEETS.ATTENDANCE));
setupTaskOverviewSheet_(ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW));
setupTaskSheet_(ss.getSheetByName(CAMP_SHEETS.TASKS));
setupDocumentListSheet_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS));
setupNotificationSheet_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS));
setupOutcomeSheet_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES));
setupNotebookSheet_(ss.getSheetByName(CAMP_SHEETS.NOTEBOOK));
setupSettingsSheet_(ss.getSheetByName(CAMP_SHEETS.SETTINGS));
restoreActivityIntakeLinksToDashboard_(ss);
CAMP_SHEET_ORDER.forEach(function(name, index) {
ss.setActiveSheet(ss.getSheetByName(name));
ss.moveActiveSheet(index + 1);
});
ss.setActiveSheet(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
}
function setupDashboardSheet_(sheet) {
sheet.clear();
sheet.getRange('A1').setValue(CAMP_CONFIG.SYSTEM_NAME).setFontSize(18).setFontWeight('bold');
sheet.getRange('A3:D23').setValues([
['指標', '目前數值', '狀態', '提示'],
['活動總數', 0, '', ''],
['多日活動數', 0, '', ''],
['場次總數', 0, '', ''],
['報名總人數', 0, '', ''],
['場次簽到總筆數', 0, '', ''],
['平均出席率', '0%', '', ''],
['未完成任務數', 0, '', ''],
['今日到期任務', 0, '', ''],
['延宕任務數', 0, '', ''],
['高風險場次數', 0, '', ''],
['待確認文件數', 0, '', ''],
['NotebookLM 匯出數', 0, '', ''],
['最後更新時間', nowText_(), '', ''],
['重要行事總數', 0, '', ''],
['已同步日曆數', 0, '', ''],
['日曆回寫成功數', 0, '', ''],
['日曆事件遺失數', 0, '', ''],
['日曆同步失敗數', 0, '', ''],
['今日到期重要行事', 0, '', ''],
['延宕重要行事', 0, '', '']
]);
formatDashboardKpiLayout_(sheet);
sheet.getRange('F3:G7').setValues([
['快速連結', '連結或說明'],
['活動新增表單', '尚未建立'],
['活動新增表單編輯', '尚未建立'],
['操作提醒', '示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。'],
['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE]
]);
sheet.getRange('F3:G3').setFontWeight('bold').setBackground('#dbeafe');
sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
sheet.getRange('G4:G7').setWrap(true);
sheet.getRange('A25').setValue('場次進度總覽').setFontWeight('bold');
sheet.getRange('A26:K26').setValues([['活動ID', '活動名稱', '場次ID', '場次日期', '場次名稱', '報名人數', '簽到人數', '出席率', '課前任務完成率', '目前狀態', '風險等級']]).setFontWeight('bold').setBackground('#e0f2fe');
sheet.getRange('A38').setValue('紅燈任務').setFontWeight('bold');
sheet.getRange('A39:G39').setValues([['活動ID', '場次ID', '任務名稱', '負責人', '建議完成日期', '狀態', '卡關原因']]).setFontWeight('bold').setBackground('#fee2e2');
sheet.getRange('A53').setValue('今日/明日場次').setFontWeight('bold');
sheet.getRange('A54:G54').setValues([['日期', '活動名稱', '場次ID', '場次名稱', '時間', '地點', '狀態']]).setFontWeight('bold').setBackground('#dcfce7');
sheet.getRange('A68').setValue('下一步建議').setFontWeight('bold');
sheet.getRange('A69').setValue('請先選擇一條流程:示範資料流程,或活動新增表單流程。示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。').setWrap(true);
sheet.setColumnWidth(1, 220);
sheet.setColumnWidth(2, 120);
sheet.setColumnWidth(3, 130);
sheet.setColumnWidth(4, 320);
sheet.setColumnWidth(6, 180);
sheet.setColumnWidth(7, 520);
}
function ensureDashboardVisualLayout_(sheet) {
sheet.getRange('A3:D3').setValues([['指標', '目前數值', '狀態', '提示']]);
sheet.getRange('E3:E7').clearContent().clearFormat();
sheet.getRange('A4:A23').setValues([
['活動總數'],
['多日活動數'],
['場次總數'],
['報名總人數'],
['場次簽到總筆數'],
['平均出席率'],
['未完成任務數'],
['今日到期任務'],
['延宕任務數'],
['高風險場次數'],
['待確認文件數'],
['NotebookLM 匯出數'],
['最後更新時間'],
['重要行事總數'],
['已同步日曆數'],
['日曆回寫成功數'],
['日曆事件遺失數'],
['日曆同步失敗數'],
['今日到期重要行事'],
['延宕重要行事']
]);
formatDashboardKpiLayout_(sheet);
sheet.getRange('F3:G3').setValues([['快速連結', '連結或說明']]).setFontWeight('bold').setBackground('#dbeafe');
sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
sheet.getRange('G4:G7').setWrap(true);
sheet.setColumnWidth(1, 220);
sheet.setColumnWidth(2, 120);
sheet.setColumnWidth(3, 130);
sheet.setColumnWidth(4, 320);
sheet.setColumnWidth(5, 28);
sheet.setColumnWidth(6, 180);
sheet.setColumnWidth(7, 520);
}
function formatDashboardKpiLayout_(sheet) {
sheet.getRange('A3:D3')
.setFontWeight('bold')
.setBackground('#dbeafe')
.setHorizontalAlignment('center');
sheet.getRange('A4:A23').setFontWeight('bold').setBackground('#f8fafc');
sheet.getRange('B4:B23').setHorizontalAlignment('right');
sheet.getRange('C4:C23').setHorizontalAlignment('center');
sheet.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}
function setupActivityListSheet_(sheet) {
ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('報名表模板類型') + 1, 200, CAMP_REGISTRATION_TEMPLATE_TYPES);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否收費') + 1, 200, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要保險') + 1, 200, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要家長同意書') + 1, 200, CAMP_YES_NO_VALUES);
}
function setupTaskOverviewSheet_(sheet) {
ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
formatTaskOverviewHeader_(sheet);
}
function setupSessionSheet_(sheet) {
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('目前狀態') + 1, 500, CAMP_SESSION_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('風險等級') + 1, 500, CAMP_RISK_LEVEL_VALUES);
}
function setupRegistrationSheet_(sheet) {
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
}
function setupAttendanceSheet_(sheet) {
ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽到狀態') + 1, 1000, CAMP_ATTENDANCE_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽退狀態') + 1, 1000, ['未簽退', '已簽退', '不適用']);
}
function setupTaskSheet_(sheet) {
ensureHeaders_(sheet, CAMP_TASK_HEADERS);
setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
}
function setupDocumentListSheet_(sheet) {
ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
setValidationList_(sheet, 2, CAMP_DOCUMENT_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}
function setupNotificationSheet_(sheet) {
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
setValidationList_(sheet, 2, CAMP_NOTIFICATION_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}
function setupOutcomeSheet_(sheet) {
ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否完成') + 1, 500, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否含個資') + 1, 500, CAMP_YES_NO_VALUES);
setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否可提供 NotebookLM') + 1, 500, CAMP_YES_NO_VALUES);
}
function setupNotebookSheet_(sheet) {
ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
setValidationList_(sheet, 2, CAMP_NOTEBOOK_HEADERS.indexOf('是否納入匯出') + 1, 500, CAMP_YES_NO_VALUES);
}
function setupSettingsSheet_(sheet) {
ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
if (sheet.getLastRow() === 1) {
sheet.getRange(2, 1, 7, 3).setValues([
['SYSTEM_NAME', CAMP_CONFIG.SYSTEM_NAME, '系統名稱'],
['ROOT_FOLDER_ID', CAMP_CONFIG.ROOT_FOLDER_ID || '空白時使用目前試算表所在資料夾或我的雲端硬碟根目錄', '平台根資料夾設定'],
['CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定'],
['TIMEZONE', CAMP_CONFIG.TIMEZONE, '日期計算使用時區'],
['DATE_SOON_DAYS', CAMP_CONFIG.DATE_SOON_DAYS, '幾天內視為即將到期'],
['GEMINI_API_KEY', '未在本教學 MVP 啟用', 'API key 不寫入試算表'],
['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE, '正式內容仍由承辦人確認']
]);
}
ensureSettingExists_(sheet, 'CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定');
}
function ensureSettingExists_(sheet, key, value, note) {
const map = buildHeaderMap_(sheet);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (String(readCellByHeader_(sheet, row, map, '設定項目') || '') === String(key || '')) {
return;
}
}
sheet.appendRow([key, value, note]);
}
function updateDashboard_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
ensureDashboardVisualLayout_(dashboard);
restoreActivityIntakeLinksToDashboard_(ss);
updateDashboardKpis_();
updateDashboardStatusLights_();
updateDashboardSessionOverview_();
updateDashboardRedTasks_();
updateDashboardTodaySessions_();
}
function restoreActivityIntakeLinksToDashboard_(ss) {
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
const map = buildHeaderMap_(settings);
if (!map['設定項目'] || !map['設定值']) return;
const links = {};
for (let row = 2; row <= settings.getLastRow(); row++) {
const key = String(readCellByHeader_(settings, row, map, '設定項目') || '');
if (key === '活動新增表單' || key === '活動新增表單編輯') {
links[key] = readCellByHeader_(settings, row, map, '設定值');
}
}
if (links['活動新增表單']) {
dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
dashboard.getRange('G4').setValue(links['活動新增表單']).setWrap(true);
}
if (links['活動新增表單編輯']) {
dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
dashboard.getRange('G5').setValue(links['活動新增表單編輯']).setWrap(true);
}
}
檔案 05
DriveService.gs|Drive 資料夾管理
/**
* 營隊/活動管理中樞:Drive 資料夾操作。
*/
function getCampRootFolder_() {
if (CAMP_CONFIG.ROOT_FOLDER_ID) {
try {
return DriveApp.getFolderById(CAMP_CONFIG.ROOT_FOLDER_ID);
} catch (error) {
Logger.log('ROOT_FOLDER_ID 無效,改用試算表所在資料夾:' + error.message);
}
}
const ss = getActiveSpreadsheet_();
const file = DriveApp.getFileById(ss.getId());
const parents = file.getParents();
const parent = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
return getOrCreateSubFolder_(parent, '營隊活動管理中樞_資料包');
}
function createActivityFolder_(activity) {
const root = getCampRootFolder_();
const dateText = formatDateValue_(activity.activityDate).replace(/\//g, '');
const folderName = sanitizeFileName_((activity.id || generateActivityId_()) + '_' + dateText + '_' + activity.name);
return root.createFolder(folderName);
}
function createActivitySubfolders_(folder) {
const map = {};
CAMP_SUBFOLDER_NAMES.forEach(function(name) {
map[name] = getOrCreateSubFolder_(folder, name);
});
return map;
}
function getOrCreateSubFolder_(parent, name) {
const folders = parent.getFoldersByName(name);
return folders.hasNext() ? folders.next() : parent.createFolder(name);
}
function moveFileToFolder_(fileId, folder) {
const file = DriveApp.getFileById(fileId);
folder.addFile(file);
const parents = file.getParents();
while (parents.hasNext()) {
const parent = parents.next();
if (parent.getId() !== folder.getId()) {
try {
parent.removeFile(file);
} catch (error) {
Logger.log('移除原父資料夾失敗,可忽略:' + error.message);
}
}
}
return file;
}
function getFolderFromActivityUrl_(url) {
const id = extractIdFromUrl_(url);
if (!id) return null;
try {
return DriveApp.getFolderById(id);
} catch (error) {
Logger.log('無法由活動資料夾連結取得資料夾:' + error.message);
return null;
}
}
function getActivitySubfolderMap_(folder) {
const map = {};
CAMP_SUBFOLDER_NAMES.forEach(function(name) {
map[name] = getOrCreateSubFolder_(folder, name);
});
return map;
}
檔案 06
SessionService.gs|多日場次、簽到與 Dashboard 統計
/**
* 營隊/活動管理中樞:多日活動、場次與簽到服務。
*/
function generateSessionsForSelectedActivity() {
const context = getSelectedActivityRowContext_();
generateSessionsForActivity_(context.activity);
updateActivitySessionFields_(context);
updateSessionStats_();
updateDashboard_();
alert_('場次設定已產生或更新完成。');
}
function updateSessionStats() {
updateSessionStats_();
updateDashboard_();
alert_('場次統計已更新。');
}
function generateAttendanceRowsForSelectedActivity() {
const context = getSelectedActivityRowContext_();
generateAttendanceRowsForActivity_(context.activity.id);
updateSessionStats_();
updateDashboard_();
alert_('場次簽到預備資料已產生。');
}
function generateSessionsForActivity_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
if (!activity.id) return [];
const existing = getSessionsByActivityId_(activity.id);
if (existing.length > 0) return existing;
const startDate = effectiveActivityStartDate_(activity);
const endDate = effectiveActivityEndDate_(activity) || startDate;
if (!startDate) return [];
const rows = [];
let index = 1;
for (let d = new Date(startDate.getTime()); d <= endDate; d.setDate(d.getDate() + 1)) {
const sessionId = 'S' + ('0' + index).slice(-2);
rows.push(buildSessionSheetRow_({
'活動ID': activity.id,
'場次ID': sessionId,
'場次日期': new Date(d.getTime()),
'開始時間': activity.defaultStartTime || '09:00',
'結束時間': activity.defaultEndTime || '12:00',
'場次名稱': '第 ' + index + ' 日',
'講師': activity.owner || '',
'地點或線上連結': activity.location || '',
'報名人數': 0,
'簽到人數': 0,
'出席率': '0%',
'課前任務完成率': '0%',
'課後回饋數': 0,
'成果整理狀態': '未整理',
'風險等級': '低',
'目前狀態': '未開始',
'備註': '',
'任務完成率': '0%',
'任務完成狀態': '未開始'
}));
index++;
}
if (rows.length) {
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
}
return getSessionsByActivityId_(activity.id);
}
function buildSessionSheetRow_(sessionData) {
return CAMP_SESSION_HEADERS.map(function(header) {
return Object.prototype.hasOwnProperty.call(sessionData, header) ? sessionData[header] : '';
});
}
function getSessionsByActivityId_(activityId) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
const rows = buildRowsByHeader_(sheet).filter(function(row) {
return String(row['活動ID'] || '') === String(activityId || '');
});
return rows.map(function(row) {
return {
activityId: row['活動ID'],
sessionId: row['場次ID'],
date: row['場次日期'],
startTime: row['開始時間'],
endTime: row['結束時間'],
title: row['場次名稱'],
topic: row['場次主題'],
instructor: row['講師'],
location: row['地點或線上連結'],
registrationCount: row['報名人數'] || 0,
checkinCount: row['簽到人數'] || 0,
attendanceRate: row['出席率'] || '0%',
preTaskRate: row['課前任務完成率'] || '0%',
outcomeStatus: row['成果整理狀態'] || '',
riskLevel: row['風險等級'] || '',
status: row['目前狀態'] || ''
};
});
}
function formatSessionsText_(activityId) {
if (!activityId) return '尚未建立場次。';
try {
const sessions = getSessionsByActivityId_(activityId);
if (!sessions.length) return '尚未建立場次。';
return sessions.map(function(session) {
return [
session.sessionId,
formatDateValue_(session.date),
valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
valueOrPending_(session.title),
valueOrPending_(session.topic),
valueOrPending_(session.location),
valueOrPending_(session.status)
].join('|');
}).join('\n');
} catch (error) {
Logger.log('場次摘要讀取失敗:' + error.message);
return '場次摘要讀取失敗。';
}
}
function appendSessionsTable_(body, activityId) {
const sessions = getSessionsByActivityId_(activityId);
if (!sessions.length) {
body.appendParagraph('尚未建立場次。');
return;
}
const rows = [['場次ID', '日期', '時間', '場次名稱', '主題', '地點']];
sessions.forEach(function(session) {
rows.push([
session.sessionId,
formatDateValue_(session.date),
valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
valueOrPending_(session.title),
valueOrPending_(session.topic),
valueOrPending_(session.location)
]);
});
appendDocTable_(body, rows);
}
function updateSessionStats_() {
const ss = getActiveSpreadsheet_();
const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
const sessionMap = buildHeaderMap_(sessionSheet);
const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));
for (let row = 2; row <= sessionSheet.getLastRow(); row++) {
const activityId = readCellByHeader_(sessionSheet, row, sessionMap, '活動ID');
const sessionId = readCellByHeader_(sessionSheet, row, sessionMap, '場次ID');
if (!activityId || !sessionId) continue;
const registered = registrationRows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId)
&& selectedSessionsContain_(item['選擇參加場次'], sessionId);
}).length;
const checkedIn = attendanceRows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId)
&& String(item['場次ID'] || '') === String(sessionId)
&& String(item['簽到狀態'] || '') === '已簽到';
}).length;
const preTasks = taskRows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId)
&& String(item['場次ID'] || '') === String(sessionId)
&& String(item['階段'] || '').indexOf('課前') >= 0;
});
const donePreTasks = preTasks.filter(function(item) {
return String(item['狀態'] || '') === '已完成';
}).length;
writeCellByHeader_(sessionSheet, row, sessionMap, '報名人數', registered);
writeCellByHeader_(sessionSheet, row, sessionMap, '簽到人數', checkedIn);
writeCellByHeader_(sessionSheet, row, sessionMap, '出席率', formatPercent_(checkedIn, registered));
writeCellByHeader_(sessionSheet, row, sessionMap, '課前任務完成率', formatPercent_(donePreTasks, preTasks.length));
}
updateActivitySessionCounts_();
}
function updateActivitySessionCounts_() {
const ss = getActiveSpreadsheet_();
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
for (let row = 2; row <= activitySheet.getLastRow(); row++) {
const activity = readActivityFromRow_(activitySheet, row, activityMap);
if (!activity.id) continue;
const sessionCount = getSessionsByActivityId_(activity.id).length;
const days = calculateActivityDays_(activity);
writeCellByHeader_(activitySheet, row, activityMap, '活動天數', days);
writeCellByHeader_(activitySheet, row, activityMap, '場次數', sessionCount);
if (!activity.isMultiDay || activity.isMultiDay === '待確認') {
writeCellByHeader_(activitySheet, row, activityMap, '是否多日活動', Number(days || 0) > 1 ? '是' : '否');
}
}
}
function updateActivitySessionFields_(context) {
const sessions = getSessionsByActivityId_(context.activity.id);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動天數', calculateActivityDays_(context.activity));
writeCellByHeader_(context.sheet, context.row, context.headerMap, '場次數', sessions.length);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '是否多日活動', sessions.length > 1 ? '是' : '否');
writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
}
function selectedSessionsContain_(selectedText, sessionId) {
return parseSelectedSessionIds_(selectedText).indexOf(String(sessionId || '')) >= 0;
}
function parseSelectedSessionIds_(text) {
const raw = String(text || '');
const direct = raw
.split(/[,,\n]/)
.map(function(part) {
const match = String(part).match(/S\d{2}/);
return match ? match[0] : '';
})
.filter(Boolean);
const inferred = [];
if (raw.indexOf('6月15日') >= 0 || raw.indexOf('6/15') >= 0) inferred.push('S01');
if (raw.indexOf('6月18日') >= 0 || raw.indexOf('6/18') >= 0) inferred.push('S02');
if (raw.indexOf('6月22日') >= 0 || raw.indexOf('6/22') >= 0) inferred.push('S03');
if (raw.indexOf('6月25日') >= 0 || raw.indexOf('6/25') >= 0) inferred.push('S04');
return direct.concat(inferred).filter(function(value, index, list) {
return value && list.indexOf(value) === index;
});
}
function buildRegistrationKey_(registration, sessionId) {
return [
registration.activityId || '',
registration.email || '',
registration.name || '',
sessionId || ''
].join('|');
}
function ensureAttendanceRowsForRegistration_(registration) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
const sessions = getRegistrationSessions_(registration);
if (!sessions.length) return 0;
const existing = buildExistingAttendanceKeys_(sheet);
const rows = [];
sessions.forEach(function(session) {
const key = buildRegistrationKey_(registration, session.sessionId);
if (existing[key]) return;
rows.push([
registration.activityId,
session.sessionId,
session.title,
session.date,
registration.name,
registration.unit,
registration.email,
key,
'是',
'未簽到',
'',
'未簽退',
'',
'',
''
]);
existing[key] = true;
});
if (rows.length) {
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_ATTENDANCE_HEADERS.length).setValues(rows);
}
return rows.length;
}
function getRegistrationSessions_(registration) {
const sessions = getSessionsByActivityId_(registration.activityId);
if (!sessions.length) return [];
const fullText = String(registration.fullParticipation || '');
if (fullText === '是' || fullText.indexOf('全程') >= 0) return sessions;
const selectedIds = parseSelectedSessionIds_(registration.selectedSessions);
if (!selectedIds.length) return sessions.length === 1 ? sessions : [];
return sessions.filter(function(session) {
return selectedIds.indexOf(session.sessionId) >= 0;
});
}
function buildExistingAttendanceKeys_(sheet) {
const rows = buildRowsByHeader_(sheet);
const keys = {};
rows.forEach(function(row) {
const key = row['報名資料Key'];
if (key) keys[String(key)] = true;
});
return keys;
}
function generateAttendanceRowsForActivity_(activityId) {
const ss = getActiveSpreadsheet_();
const registrations = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS)).filter(function(row) {
return String(row['活動ID'] || '') === String(activityId || '');
});
let added = 0;
registrations.forEach(function(row) {
added += ensureAttendanceRowsForRegistration_({
activityId: row['活動ID'],
name: row['學生姓名'] || row['學生或學員姓名'],
unit: row['班級'] || row['學校或單位'],
email: row['家長Email'] || row['Email'],
fullParticipation: row['是否全程參加'],
selectedSessions: row['選擇參加場次']
});
});
return added;
}
function updateDashboardKpis_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
const activityRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES));
const sessionRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS));
const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));
const taskOverviewRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW));
const documentRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS));
const checkedIn = attendanceRows.filter(function(row) { return row['簽到狀態'] === '已簽到'; }).length;
const multiDay = activityRows.filter(function(row) {
return row['是否多日活動'] === '是' || Number(row['活動天數'] || 0) > 1;
}).length;
const todayDueTasks = taskRows.filter(function(row) {
return isSameDate_(row['建議完成日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
}).length;
const overdueTasks = taskRows.filter(function(row) {
return calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
}).length;
const incompleteTasks = taskRows.filter(function(row) {
return row['狀態'] && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
}).length;
const highRiskSessions = sessionRows.filter(function(row) {
return row['風險等級'] === '高' || row['風險等級'] === '極高';
}).length;
const pendingDocuments = documentRows.filter(function(row) {
return row['承辦人確認'] !== '已確認' && row['承辦人確認'] !== '不適用';
}).length;
const notebookCount = activityRows.filter(function(row) { return row['NotebookLM資料包連結']; }).length;
const importantTasks = taskOverviewRows.filter(function(row) { return row['重要行事'] === '是'; });
const syncedCalendarTasks = importantTasks.filter(function(row) {
const status = String(row['日曆同步狀態'] || '');
return status === '已同步' || status === '已更新' || status === '已重新建立';
}).length;
const calendarReadSuccess = importantTasks.filter(function(row) {
return String(row['日曆回寫狀態'] || '').indexOf('已回寫') === 0;
}).length;
const calendarMissing = importantTasks.filter(function(row) {
return String(row['日曆回寫狀態'] || '') === '日曆事件遺失';
}).length;
const calendarFailed = importantTasks.filter(function(row) {
return String(row['日曆同步狀態'] || '').indexOf('同步失敗') === 0
|| String(row['日曆回寫狀態'] || '').indexOf('回寫失敗') === 0;
}).length;
const todayImportant = importantTasks.filter(function(row) {
return isSameDate_(row['到期日'] || row['日曆日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
}).length;
const delayedImportant = importantTasks.filter(function(row) {
return row['狀態'] === '延後'
|| calculateDateStatus_(row['到期日'] || row['日曆日期'], row['狀態'] === '已完成') === '逾期';
}).length;
dashboard.getRange('B4:B23').setValues([
[activityRows.length],
[multiDay],
[sessionRows.length],
[registrationRows.length],
[attendanceRows.length],
[formatPercent_(checkedIn, attendanceRows.length)],
[incompleteTasks],
[todayDueTasks],
[overdueTasks],
[highRiskSessions],
[pendingDocuments],
[notebookCount],
[nowText_()],
[importantTasks.length],
[syncedCalendarTasks],
[calendarReadSuccess],
[calendarMissing],
[calendarFailed],
[todayImportant],
[delayedImportant]
]);
}
function updateDashboardStatusLights_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
ensureDashboardVisualLayout_(dashboard);
const metricRows = dashboard.getRange('A4:B23').getValues();
const attendanceCount = Number(dashboard.getRange('B8').getValue() || 0);
const statusRows = [];
const statusBackgrounds = [];
metricRows.forEach(function(row) {
const metric = String(row[0] || '');
const value = row[1];
const result = buildDashboardStatusLight_(metric, value, attendanceCount);
statusRows.push([result.status, result.hint]);
statusBackgrounds.push([result.color, result.color]);
});
dashboard.getRange('C4:D23').setValues(statusRows).setBackgrounds(statusBackgrounds);
dashboard.getRange('B4:B23').setHorizontalAlignment('right');
dashboard.getRange('C4:C23').setHorizontalAlignment('center');
dashboard.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}
function buildDashboardStatusLight_(metric, value, attendanceCount) {
const number = Number(value || 0);
const percent = parseDashboardPercent_(value);
if (metric === '活動總數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有活動資料')
: dashboardLight_('gray', '⚪ 尚無資料', '請先新增活動');
}
if (metric === '多日活動數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有多日活動')
: dashboardLight_('gray', '⚪ 尚無資料', '目前沒有多日活動');
}
if (metric === '場次總數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有場次設定')
: dashboardLight_('yellow', '🟡 注意', '請產生或更新場次設定');
}
if (metric === '報名總人數') {
return number > 0
? dashboardLight_('green', '🟢 已有資料', '已有報名資料')
: dashboardLight_('yellow', '🟡 注意', '尚未同步報名資料');
}
if (metric === '場次簽到總筆數') {
return number > 0
? dashboardLight_('green', '🟢 已有資料', '已有簽到資料')
: dashboardLight_('yellow', '🟡 注意', '尚未產生或同步簽到資料');
}
if (metric === '平均出席率') {
if (!attendanceCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚未建立簽到統計');
if (percent >= 80) return dashboardLight_('green', '🟢 正常', '出席狀況良好');
if (percent >= 50) return dashboardLight_('yellow', '🟡 注意', '出席率偏低,建議檢查簽到狀態');
return dashboardLight_('red', '🔴 異常', '請更新場次簽到狀態');
}
if (metric === '未完成任務數') {
if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有未完成任務');
if (number <= 5) return dashboardLight_('yellow', '🟡 注意', '仍有少量任務待處理');
return dashboardLight_('red', '🔴 待處理', '未完成任務偏多,請查看任務總覽');
}
if (metric === '今日到期任務') {
return number === 0
? dashboardLight_('green', '🟢 正常', '今日沒有到期任務')
: dashboardLight_('yellow', '🟡 今日到期', '今日有任務需要完成');
}
if (metric === '延宕任務數') {
if (number === 0) return dashboardLight_('green', '🟢 正常', '沒有延宕任務');
if (number <= 3) return dashboardLight_('yellow', '🟡 注意', '有少量延宕任務');
return dashboardLight_('red', '🔴 逾期', '延宕任務偏多,請優先處理');
}
if (metric === '高風險場次數') {
return number === 0
? dashboardLight_('green', '🟢 無風險', '目前沒有高風險場次')
: dashboardLight_('red', '🔴 風險偏高', '請檢查場次人數、簽到率或資料缺漏');
}
if (metric === '待確認文件數') {
if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有待確認文件');
if (number <= 5) return dashboardLight_('yellow', '🟡 待確認', '仍有文件需要確認');
return dashboardLight_('red', '🔴 待處理', '待確認文件偏多,請整理行政文件');
}
if (metric === 'NotebookLM 匯出數') {
return number > 0
? dashboardLight_('green', '🟢 已匯出', '已有 NotebookLM 資料包')
: dashboardLight_('yellow', '🟡 尚未匯出', '活動結案前請匯出資料包');
}
if (metric === '最後更新時間') {
return value
? dashboardLight_('green', '🟢 已更新', '中控台已完成更新')
: dashboardLight_('gray', '⚪ 尚無資料', '尚未更新中控台');
}
if (metric === '重要行事總數') {
return number > 0
? dashboardLight_('green', '🟢 已建立', '已有重要行事')
: dashboardLight_('gray', '⚪ 尚無資料', '尚未建立重要行事');
}
if (metric === '已同步日曆數') {
const importantCount = Number(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.DASHBOARD).getRange('B17').getValue() || 0);
if (!importantCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚無重要行事');
return number >= importantCount
? dashboardLight_('green', '🟢 正常', '重要行事已同步')
: dashboardLight_('yellow', '🟡 注意', '仍有重要行事未同步');
}
if (metric === '日曆回寫成功數') {
return number > 0
? dashboardLight_('green', '🟢 正常', '已有日曆回寫紀錄')
: dashboardLight_('gray', '⚪ 尚無資料', '尚未從日曆回寫');
}
if (metric === '日曆事件遺失數') {
return number > 0
? dashboardLight_('red', '🔴 待處理', '有日曆事件遺失')
: dashboardLight_('green', '🟢 正常', '沒有日曆事件遺失');
}
if (metric === '日曆同步失敗數') {
return number > 0
? dashboardLight_('red', '🔴 待處理', '有日曆同步或回寫失敗')
: dashboardLight_('green', '🟢 正常', '沒有日曆同步失敗');
}
if (metric === '今日到期重要行事') {
return number > 0
? dashboardLight_('yellow', '🟡 今日到期', '今日有重要行事')
: dashboardLight_('green', '🟢 正常', '今日沒有到期重要行事');
}
if (metric === '延宕重要行事') {
return number > 0
? dashboardLight_('red', '🔴 逾期', '有延宕重要行事')
: dashboardLight_('green', '🟢 正常', '沒有延宕重要行事');
}
return dashboardLight_('gray', '⚪ 不適用', '此指標尚未定義燈號');
}
function dashboardLight_(level, status, hint) {
const colors = {
green: '#e8f5e9',
yellow: '#fff8e1',
red: '#ffebee',
gray: '#f5f5f5'
};
return {
status: status,
hint: hint,
color: colors[level] || colors.gray
};
}
function parseDashboardPercent_(value) {
if (typeof value === 'number') {
return value <= 1 ? value * 100 : value;
}
const text = String(value || '').trim();
if (!text) return 0;
const numeric = Number(text.replace('%', ''));
return isNaN(numeric) ? 0 : numeric;
}
function updateDashboardSessionOverview_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
dashboard.getRange('A27:K36').clearContent();
const activityNames = buildActivityNameMap_();
const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).slice(0, 10);
const rows = sessions.map(function(row) {
return [
row['活動ID'],
activityNames[row['活動ID']] || '',
row['場次ID'],
row['場次日期'],
row['場次名稱'],
row['報名人數'],
row['簽到人數'],
row['出席率'],
row['課前任務完成率'],
row['目前狀態'],
row['風險等級']
];
});
if (rows.length) dashboard.getRange(27, 1, rows.length, 11).setValues(rows);
}
function updateDashboardRedTasks_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
dashboard.getRange('A40:G51').clearContent();
const tasks = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
return row['狀態'] === '延後'
|| row['優先程度'] === '高' && row['狀態'] !== '已完成'
|| calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
}).slice(0, 12);
const rows = tasks.map(function(row) {
return [row['活動ID'], row['場次ID'], row['任務名稱'], row['負責人'], row['建議完成日期'], row['狀態'], row['卡關原因']];
});
if (rows.length) dashboard.getRange(40, 1, rows.length, 7).setValues(rows);
}
function updateDashboardTodaySessions_() {
const ss = getActiveSpreadsheet_();
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
dashboard.getRange('A55:G66').clearContent();
const activityNames = buildActivityNameMap_();
const today = new Date();
const tomorrow = addDays_(today, 1);
const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).filter(function(row) {
return isSameDate_(row['場次日期'], today) || isSameDate_(row['場次日期'], tomorrow);
}).slice(0, 12);
const rows = sessions.map(function(row) {
return [
row['場次日期'],
activityNames[row['活動ID']] || '',
row['場次ID'],
row['場次名稱'],
valueOrPending_(row['開始時間']) + '-' + valueOrPending_(row['結束時間']),
row['地點或線上連結'],
row['目前狀態']
];
});
if (rows.length) dashboard.getRange(55, 1, rows.length, 7).setValues(rows);
}
function buildActivityNameMap_() {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
const map = {};
rows.forEach(function(row) {
if (row['活動ID']) map[row['活動ID']] = row['活動名稱'];
});
return map;
}
function buildSessionNameMap_() {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
const map = {};
rows.forEach(function(row) {
const activityId = row['活動ID'];
const sessionId = row['場次ID'];
if (activityId && sessionId) {
map[activityId + '::' + sessionId] = row['場次名稱'] || '';
}
});
return map;
}
檔案 07
AttendanceFormService.gs|場次簽到表單與簽到狀態同步
/**
* 營隊/活動管理中樞:場次簽到表單與簽到回應同步。
*/
function createAttendanceFormsForSelectedActivity() {
const context = getSelectedActivityRowContext_();
const result = createAttendanceFormsForActivityContext_(context);
updateDashboard_();
alert_([
'場次簽到表單處理完成。',
'新建立:' + result.created + ' 張',
'已存在略過:' + result.skipped + ' 張',
'',
'簽到表單連結已回填到 02_場次設定。'
].join('\n'));
}
function createAttendanceFormsForActivityContext_(context) {
let activity = context.activity;
if (!activity.id) {
throw new Error('此活動缺少活動ID,請先建立活動資料包。');
}
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
activity = context.activity;
const attendanceFolder = packageContext.subfolders['01_報名表單與回覆'] || getCampRootFolder_();
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) {
sessions = generateSessionsForActivity_(activity);
}
if (!sessions.length) {
throw new Error('此活動尚未建立場次,無法產生簽到表單。');
}
const ss = getActiveSpreadsheet_();
const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
const sessionMap = ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
let created = 0;
let skipped = 0;
sessions.forEach(function(session) {
const row = findSessionRow_(sessionSheet, sessionMap, activity.id, session.sessionId);
if (!row) return;
const existingUrl = String(readCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結') || '').trim();
if (existingUrl) {
skipped++;
return;
}
const result = createAttendanceFormForSession_(ss, activity, session, attendanceFolder);
writeCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結', result.prefilledUrl);
writeCellByHeader_(sessionSheet, row, sessionMap, '目前狀態', '準備中');
appendDocumentListRow_(
activity,
'簽到表單',
result.title,
'本場研習或活動簽到使用',
result.prefilledUrl,
'Google Form',
'待使用',
'待確認',
'編輯連結:' + result.editUrl + '\nraw 回應分頁:' + result.responseSheetName
);
created++;
});
return { created: created, skipped: skipped };
}
function createAttendanceFormForSession_(ss, activity, session, folder) {
const title = [
'簽到表單',
activity.id,
session.sessionId,
sanitizeFileName_(activity.name)
].join('_');
const beforeSheetIds = getSheetIds_(ss);
const form = FormApp.create(title);
form.setDescription([
'活動名稱:' + activity.name,
'場次:' + session.sessionId + '|' + valueOrPending_(session.title),
'日期:' + formatDateValue_(session.date),
'時間:' + valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
'地點或連結:' + valueOrPending_(session.location),
'',
'本表單已自動帶入活動ID、場次ID與場次名稱。',
'請直接填寫姓名、單位與 Email 完成簽到。'
].join('\n'));
form.setCollectEmail(false);
form.setAllowResponseEdits(false);
form.addTextItem()
.setTitle('姓名')
.setRequired(true);
form.addTextItem()
.setTitle('單位')
.setRequired(true)
.setHelpText('請填寫學校、服務單位或所屬機關。');
form.addTextItem()
.setTitle('Email')
.setRequired(false)
.setHelpText('選填。若需研習時數或後續通知,建議填寫。');
form.addMultipleChoiceItem()
.setTitle('是否本人簽到')
.setChoiceValues(['本人簽到', '工作人員協助登記'])
.setRequired(true);
form.addParagraphTextItem().setTitle('備註').setRequired(false);
const activityIdItem = form.addTextItem()
.setTitle('活動ID')
.setRequired(true)
.setHelpText('系統已自動帶入,請勿修改。');
const sessionIdItem = form.addTextItem()
.setTitle('場次ID')
.setRequired(true)
.setHelpText('系統已自動帶入,請勿修改。');
const sessionNameItem = form.addTextItem()
.setTitle('場次名稱')
.setRequired(true)
.setHelpText('系統已自動帶入,請勿修改。');
form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
Utilities.sleep(1200);
const responseSheet = findNewSheetAfterFormDestination_(ss, beforeSheetIds);
const responseSheetName = renameSheetSafely_(
responseSheet,
'raw_簽到回應_' + activity.id + '_' + session.sessionId
);
if (responseSheet) {
try {
responseSheet.hideSheet();
} catch (error) {
Logger.log('隱藏簽到 raw 分頁失敗:' + error.message);
}
}
const prefilledUrl = form.createResponse()
.withItemResponse(activityIdItem.createResponse(activity.id))
.withItemResponse(sessionIdItem.createResponse(session.sessionId))
.withItemResponse(sessionNameItem.createResponse(session.title || session.sessionId))
.toPrefilledUrl();
moveFileToFolder_(form.getId(), folder);
return {
form: form,
title: title,
prefilledUrl: prefilledUrl,
editUrl: form.getEditUrl(),
responseSheetName: responseSheetName
};
}
function syncAttendanceFormResponses() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const attendanceSheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
const attendanceMap = ensureHeaders_(attendanceSheet, CAMP_ATTENDANCE_HEADERS);
let updated = 0;
let added = 0;
let skipped = 0;
ss.getSheets().forEach(function(sheet) {
if (sheet.getName().indexOf('raw_簽到回應_') !== 0) return;
if (sheet.getLastRow() < 2) return;
buildRowsByHeader_(sheet).forEach(function(row) {
const result = syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, row, sheet.getName());
if (result === 'updated') updated++;
else if (result === 'added') added++;
else skipped++;
});
});
updateSessionStats_();
updateDashboard_();
alert_([
'簽到狀態更新完成。',
'已更新既有名冊:' + updated + ' 筆',
'新增未在報名名單者:' + added + ' 筆',
'略過:' + skipped + ' 筆'
].join('\n'));
}
function syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, response, sourceSheetName) {
const activityId = String(response['活動ID'] || '').trim();
const sessionId = String(response['場次ID'] || '').trim();
const sessionName = String(response['場次名稱'] || '').trim();
const name = String(response['姓名'] || '').trim();
const unit = String(response['單位'] || '').trim();
const email = String(response['Email'] || '').trim();
const timestamp = response['時間戳記'] || response['Timestamp'] || new Date();
const note = String(response['備註'] || '').trim();
if (!activityId || !sessionId || !name) return 'skipped';
const existingRow = findAttendanceRowForResponse_(
attendanceSheet,
attendanceMap,
activityId,
sessionId,
name,
unit,
email
);
if (existingRow) {
const currentStatus = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態') || '');
if (currentStatus === '已簽到') return 'skipped';
writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態', '已簽到');
writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到時間', timestamp);
const oldNote = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註') || '').trim();
const newNote = [
oldNote,
note ? '簽到備註:' + note : '',
'簽到來源:' + sourceSheetName
].filter(Boolean).join('\n');
writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註', newNote);
return 'updated';
}
attendanceSheet.appendRow([
activityId,
sessionId,
sessionName,
'',
name,
unit,
email,
buildRegistrationKey_({
activityId: activityId,
name: name,
unit: unit,
email: email
}, sessionId),
'否',
'已簽到',
timestamp,
'未簽退',
'',
'',
[
'未在報名名單中,請人工確認。',
note ? '簽到備註:' + note : '',
'簽到來源:' + sourceSheetName
].filter(Boolean).join('\n')
]);
return 'added';
}
function findAttendanceRowForResponse_(sheet, map, activityId, sessionId, name, unit, email) {
let fallbackByName = 0;
for (let row = 2; row <= sheet.getLastRow(); row++) {
const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
if (rowActivityId !== String(activityId) || rowSessionId !== String(sessionId)) continue;
const rowName = String(readCellByHeader_(sheet, row, map, '學生或學員姓名') || '').trim();
const rowUnit = String(readCellByHeader_(sheet, row, map, '學校或單位') || '').trim();
const rowEmail = String(readCellByHeader_(sheet, row, map, 'Email') || '').trim();
if (email && rowEmail && rowEmail === email) return row;
if (rowName === name && rowUnit === unit) return row;
if (!fallbackByName && rowName === name) fallbackByName = row;
}
return fallbackByName;
}
function findSessionRow_(sheet, headerMap, activityId, sessionId) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
const rowActivityId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
const rowSessionId = String(readCellByHeader_(sheet, row, headerMap, '場次ID') || '').trim();
if (rowActivityId === String(activityId) && rowSessionId === String(sessionId)) return row;
}
return 0;
}
function getSheetIds_(ss) {
const ids = {};
ss.getSheets().forEach(function(sheet) {
ids[sheet.getSheetId()] = true;
});
return ids;
}
function findNewSheetAfterFormDestination_(ss, beforeSheetIds) {
const sheets = ss.getSheets();
for (let i = 0; i < sheets.length; i++) {
if (!beforeSheetIds[sheets[i].getSheetId()]) return sheets[i];
}
return null;
}
function renameSheetSafely_(sheet, desiredName) {
if (!sheet) return '';
const ss = sheet.getParent();
let finalName = desiredName;
let index = 2;
while (ss.getSheetByName(finalName)) {
finalName = desiredName + '_' + index;
index++;
}
sheet.setName(finalName);
return finalName;
}
檔案 08
TaskOverviewService.gs|任務總覽整理與視覺標記
/**
* 營隊/活動管理中樞:任務總覽。
*
* 將 05_任務追蹤 的 WBS 明細整理成承辦人每日檢視用的 05_任務總覽。
*/
function refreshTaskOverview() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const overviewSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
const existingOverviewState = buildExistingTaskOverviewState_(overviewSheet);
overviewSheet.clear();
setupTaskOverviewSheet_(overviewSheet);
const activityNameMap = buildActivityNameMap_();
const sessionNameMap = buildSessionNameMap_();
const taskSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
ensureHeaders_(taskSheet, CAMP_TASK_HEADERS);
const overviewRows = buildRowsByHeader_(taskSheet)
.filter(shouldIncludeTaskInOverview_)
.map(function(row) {
const activityId = row['活動ID'];
const sessionId = row['場次ID'] || '';
const taskId = buildTaskOverviewId_(row);
const preserved = existingOverviewState[taskId] || {};
const deadline = row['建議完成日期'] || '';
const completion = row['實際完成日期'] || '';
const task = {
'任務ID': taskId,
'活動ID': activityId,
'活動名稱': activityNameMap[activityId] || '',
'場次ID': sessionId,
'場次名稱': sessionNameMap[activityId + '::' + sessionId] || '',
'階段': row['階段'] || '',
'任務名稱': row['任務名稱'] || '',
'任務類型': row['階段'] || '',
'負責人': row['負責人'] || '',
'截止日': deadline,
'到期日': deadline,
'狀態': row['狀態'] || '未開始',
'優先程度': row['優先程度'] || '中',
'卡關原因/下一步': buildNextStepText_(row),
'備註': row['備註'] || row['卡關原因'] || '',
'重要行事': preserved['重要行事'] || (row['是否關鍵任務'] === '是' ? '是' : ''),
'日曆標題': preserved['日曆標題'] || '',
'日曆日期': preserved['日曆日期'] || deadline,
'開始時間': preserved['開始時間'] || '',
'結束時間': preserved['結束時間'] || '',
'日曆事件ID': preserved['日曆事件ID'] || '',
'日曆同步狀態': preserved['日曆同步狀態'] || '未同步',
'最後同步時間': preserved['最後同步時間'] || '',
'日曆回寫狀態': preserved['日曆回寫狀態'] || '未讀取',
'日曆最後讀取時間': preserved['日曆最後讀取時間'] || '',
'完成時間': completion,
'完成日': completion,
'最後更新時間': row['最後更新時間'] || ''
};
return CAMP_TASK_OVERVIEW_HEADERS.map(function(header) {
return task[header] || '';
});
});
if (overviewRows.length > 0) {
overviewSheet.getRange(2, 1, overviewRows.length, CAMP_TASK_OVERVIEW_HEADERS.length)
.setValues(overviewRows);
}
applyTaskOverviewFormatting_(overviewSheet);
syncImportantTasksToCalendar_();
moveTaskOverviewNearTaskSheet_();
updateDashboard_();
alert_('任務總覽已更新,共 ' + overviewRows.length + ' 筆。');
}
function buildExistingTaskOverviewState_(sheet) {
if (!sheet || sheet.getLastRow() <= 1) return {};
const map = buildHeaderMap_(sheet);
const stateHeaders = [
'重要行事',
'日曆標題',
'日曆日期',
'開始時間',
'結束時間',
'日曆事件ID',
'日曆同步狀態',
'最後同步時間',
'日曆回寫狀態',
'日曆最後讀取時間'
];
const state = {};
for (let row = 2; row <= sheet.getLastRow(); row++) {
const taskId = String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim();
if (!taskId) continue;
state[taskId] = {};
stateHeaders.forEach(function(header) {
state[taskId][header] = readCellByHeader_(sheet, row, map, header);
});
}
return state;
}
function buildTaskOverviewId_(row) {
const parts = [
row['活動ID'],
row['場次ID'] || 'ALL',
row['WBS編碼'] || row['階段'] || '',
row['任務名稱'] || ''
];
return 'TASK-' + parts.join('-').replace(/[^\w\u4e00-\u9fa5-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function shouldIncludeTaskInOverview_(row) {
const status = String(row['狀態'] || '');
const priority = String(row['優先程度'] || '');
const isKey = String(row['是否關鍵任務'] || '');
const taskName = String(row['任務名稱'] || '');
const phase = String(row['階段'] || '');
if (status === '不適用') {
return false;
}
if (status === '已完成') return true;
if (priority === '高') return true;
if (isKey === '是') return true;
if (status === '進行中' || status === '待確認' || status === '延後') return true;
const keywords = ['提醒', '簽到', '回饋', '成果', '講義', '連結', '通知', '名冊'];
return keywords.some(function(keyword) {
return taskName.indexOf(keyword) >= 0 || phase.indexOf(keyword) >= 0;
});
}
function buildNextStepText_(row) {
const stuck = String(row['卡關原因'] || '').trim();
if (stuck) return stuck;
const status = String(row['狀態'] || '');
const taskName = String(row['任務名稱'] || '');
const documentNeed = String(row['需要文件或表單'] || '');
if (status === '待確認') {
return '請承辦人或權責單位確認。';
}
if (status === '延後') {
return '請確認延後原因與新的完成日期。';
}
if (documentNeed) {
return '請確認或補齊:' + documentNeed;
}
if (taskName.indexOf('提醒') >= 0) {
return '請確認提醒內容與發送對象。';
}
if (taskName.indexOf('簽到') >= 0) {
return '請確認簽到方式、名單與時數需求。';
}
if (taskName.indexOf('成果') >= 0 || taskName.indexOf('回饋') >= 0) {
return '請整理回饋、照片、講義或成果摘要。';
}
return '請依任務內容處理。';
}
function formatTaskOverviewHeader_(sheet) {
sheet.getRange(1, 1, 1, CAMP_TASK_OVERVIEW_HEADERS.length)
.setFontWeight('bold')
.setBackground('#1e3a8a')
.setFontColor('#ffffff')
.setHorizontalAlignment('center')
.setVerticalAlignment('middle')
.setWrap(true);
sheet.setFrozenRows(1);
sheet.setFrozenColumns(5);
}
function applyTaskOverviewFormatting_(sheet) {
const lastRow = sheet.getLastRow();
const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
const existingFilter = sheet.getFilter();
if (existingFilter) existingFilter.remove();
if (lastRow < 2) {
sheet.autoResizeColumns(1, lastColumn);
return;
}
sheet.getRange(2, 1, lastRow - 1, lastColumn)
.setVerticalAlignment('middle')
.setWrap(true);
const statusCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1;
const priorityCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1;
const deadlineCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('到期日') + 1;
const calendarSyncCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆同步狀態') + 1;
const calendarReadCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆回寫狀態') + 1;
for (let row = 2; row <= lastRow; row++) {
const status = String(sheet.getRange(row, statusCol).getValue() || '');
const priority = String(sheet.getRange(row, priorityCol).getValue() || '');
const deadline = sheet.getRange(row, deadlineCol).getValue();
const isDone = status === '已完成' || status === '不適用';
const dateStatus = calculateDateStatus_(deadline, isDone);
if (status === '已完成') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
} else if (dateStatus === '逾期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
} else if (dateStatus === '即將到期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
} else {
sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
}
if (CAMP_TASK_STATUS_COLORS[status]) {
sheet.getRange(row, statusCol).setBackground(CAMP_TASK_STATUS_COLORS[status]);
}
if (CAMP_PRIORITY_COLORS[priority]) {
sheet.getRange(row, priorityCol).setBackground(CAMP_PRIORITY_COLORS[priority]);
}
if (calendarSyncCol > 0) {
sheet.getRange(row, calendarSyncCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarSyncCol).getValue()));
}
if (calendarReadCol > 0) {
sheet.getRange(row, calendarReadCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarReadCol).getValue()));
}
}
sheet.autoResizeColumns(1, lastColumn);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('活動名稱') + 1, 280);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('任務名稱') + 1, 220);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('卡關原因/下一步') + 1, 320);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('備註') + 1, 260);
sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆標題') + 1, 280);
sheet.getRange(1, 1, lastRow, lastColumn).createFilter();
}
function moveTaskOverviewNearTaskSheet_() {
const ss = getActiveSpreadsheet_();
const sheet = ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW);
if (!sheet) return;
ss.setActiveSheet(sheet);
ss.moveActiveSheet(CAMP_SHEET_ORDER.indexOf(CAMP_SHEETS.TASK_OVERVIEW) + 1);
}
function getCalendarStatusColor_(value) {
const text = String(value || '');
if (!text || text === '未讀取' || text === '尚無資料') return '#f5f5f5';
if (text.indexOf('失敗') >= 0 || text.indexOf('遺失') >= 0) return '#ffebee';
if (text.indexOf('需人工確認') >= 0 || text === '未同步' || text === '需重新同步') return '#fff8e1';
if (text.indexOf('已同步') >= 0 || text.indexOf('已更新') >= 0 || text.indexOf('已回寫') >= 0 || text.indexOf('已重新建立') >= 0) return '#e8f5e9';
return '#f5f5f5';
}
function handleTaskOverviewStatusEdit_(e) {
if (!e || !e.range) return;
const sheet = e.range.getSheet();
if (sheet.getName() !== CAMP_SHEETS.TASK_OVERVIEW) return;
if (e.range.getRow() <= 1 || e.range.getNumRows() !== 1 || e.range.getNumColumns() !== 1) return;
const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
const statusCol = map['狀態'];
if (!statusCol || e.range.getColumn() !== statusCol) return;
const row = e.range.getRow();
const newStatus = String(e.value || e.range.getValue() || '').trim();
const oldStatus = String(e.oldValue || '').trim();
const completionCol = map['完成時間'];
const completionDayCol = map['完成日'];
const updatedCol = map['最後更新時間'];
const timestamp = nowMinuteText_();
if (newStatus === '已完成') {
const currentCompletion = completionCol ? String(sheet.getRange(row, completionCol).getValue() || '').trim() : '';
if (completionCol && oldStatus !== '已完成' && !currentCompletion) {
sheet.getRange(row, completionCol).setValue(timestamp);
}
const currentCompletionDay = completionDayCol ? String(sheet.getRange(row, completionDayCol).getValue() || '').trim() : '';
if (completionDayCol && oldStatus !== '已完成' && !currentCompletionDay) {
sheet.getRange(row, completionDayCol).setValue(timestamp);
}
}
if (updatedCol) {
sheet.getRange(row, updatedCol).setValue(timestamp);
}
applyTaskOverviewRowFormatting_(sheet, row);
const task = readTaskOverviewRow_(sheet, row, map);
syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp);
if (task.activityId) {
refreshTaskProgress_(task.activityId, task.sessionId);
}
updateDashboard_();
}
function readTaskOverviewRow_(sheet, row, map) {
return {
activityId: String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim(),
activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || '').trim(),
sessionId: String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim(),
phase: String(readCellByHeader_(sheet, row, map, '階段') || '').trim(),
taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
lastUpdated: readCellByHeader_(sheet, row, map, '最後更新時間')
};
}
function applyTaskOverviewRowFormatting_(sheet, row) {
const map = buildHeaderMap_(sheet);
const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
const status = String(readCellByHeader_(sheet, row, map, '狀態') || '');
const priority = String(readCellByHeader_(sheet, row, map, '優先程度') || '');
const deadline = readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日');
const dateStatus = calculateDateStatus_(deadline, status === '已完成' || status === '不適用');
if (status === '已完成') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
} else if (dateStatus === '逾期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
} else if (dateStatus === '即將到期') {
sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
} else {
sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
}
if (CAMP_TASK_STATUS_COLORS[status] && map['狀態']) {
sheet.getRange(row, map['狀態']).setBackground(CAMP_TASK_STATUS_COLORS[status]);
}
if (CAMP_PRIORITY_COLORS[priority] && map['優先程度']) {
sheet.getRange(row, map['優先程度']).setBackground(CAMP_PRIORITY_COLORS[priority]);
}
if (map['日曆同步狀態']) {
sheet.getRange(row, map['日曆同步狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆同步狀態')));
}
if (map['日曆回寫狀態']) {
sheet.getRange(row, map['日曆回寫狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆回寫狀態')));
}
}
function syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp) {
if (!task.activityId || !task.taskName) return;
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
const map = ensureHeaders_(sheet, CAMP_TASK_HEADERS);
const row = findTaskRowForOverview_(sheet, map, task);
if (!row) return;
writeCellByHeader_(sheet, row, map, '狀態', newStatus);
writeCellByHeader_(sheet, row, map, '最後更新時間', timestamp);
if (newStatus === '已完成') {
const currentDoneAt = String(readCellByHeader_(sheet, row, map, '實際完成日期') || '').trim();
if (!currentDoneAt) {
writeCellByHeader_(sheet, row, map, '實際完成日期', task.completionTime || timestamp);
}
}
}
function findTaskRowForOverview_(sheet, map, task) {
let bestRow = 0;
let bestScore = -1;
const targetDeadline = normalizeTaskDateForCompare_(task.deadline);
for (let row = 2; row <= sheet.getLastRow(); row++) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
const phase = String(readCellByHeader_(sheet, row, map, '階段') || '').trim();
const taskName = String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim();
if (activityId !== task.activityId || sessionId !== task.sessionId || phase !== task.phase || taskName !== task.taskName) {
continue;
}
let score = 4;
const owner = String(readCellByHeader_(sheet, row, map, '負責人') || '').trim();
const deadline = normalizeTaskDateForCompare_(readCellByHeader_(sheet, row, map, '建議完成日期'));
if (task.owner && owner === task.owner) score++;
if (targetDeadline && deadline === targetDeadline) score++;
if (score > bestScore) {
bestScore = score;
bestRow = row;
}
}
return bestRow;
}
function normalizeTaskDateForCompare_(value) {
const date = parseDate_(value);
return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd') : String(value || '').trim();
}
function refreshTaskProgress_(activityId, sessionId) {
if (!activityId) return;
const ss = getActiveSpreadsheet_();
const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
return String(row['活動ID'] || '') === String(activityId || '')
&& String(row['任務名稱'] || '').trim()
&& String(row['狀態'] || '') !== '不適用';
});
const total = taskRows.length;
const done = taskRows.filter(function(row) {
return String(row['狀態'] || '') === '已完成';
}).length;
const activityRate = formatPercent_(done, total);
refreshActivityTaskProgress_(activityId, activityRate);
if (sessionId && sessionId !== 'ALL' && sessionId !== 'POST') {
const sessionRows = taskRows.filter(function(row) {
return String(row['場次ID'] || '') === String(sessionId || '');
});
refreshSessionTaskProgress_(activityId, sessionId, sessionRows);
}
}
function refreshActivityTaskProgress_(activityId, activityRate) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (String(readCellByHeader_(sheet, row, map, '活動ID') || '') !== String(activityId || '')) continue;
writeCellByHeader_(sheet, row, map, '任務完成率', activityRate);
writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
return;
}
}
function refreshSessionTaskProgress_(activityId, sessionId, sessionTaskRows) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
const map = ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
const total = sessionTaskRows.length;
const done = sessionTaskRows.filter(function(row) {
return String(row['狀態'] || '') === '已完成';
}).length;
const rate = formatPercent_(done, total);
const status = total === 0 ? '無任務' : (done === total ? '已完成' : (done === 0 ? '未開始' : '進行中'));
for (let row = 2; row <= sheet.getLastRow(); row++) {
const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '');
if (rowActivityId !== String(activityId || '') || rowSessionId !== String(sessionId || '')) continue;
writeCellByHeader_(sheet, row, map, '任務完成率', rate);
writeCellByHeader_(sheet, row, map, '任務完成狀態', status);
return;
}
}
檔案 09
CalendarTaskService.gs|關鍵任務登記 Google Calendar
/**
* 營隊/活動管理中樞:關鍵任務與 Google Calendar 同步。
*/
function syncImportantTasksToCalendar() {
const result = syncImportantTasksToCalendar_();
updateDashboard_();
alert_('重要行事同步完成。建立:' + result.created + ',更新:' + result.updated + ',略過:' + result.skipped + ',失敗:' + result.failed + '。');
}
function syncCalendarBackToTaskOverview() {
const result = syncTaskStatusFromCalendar_();
updateDashboard_();
alert_('已從 Google Calendar 同步任務狀態。回寫:' + result.updated + ',遺失:' + result.missing + ',失敗:' + result.failed + '。');
}
function createCalendarSyncTrigger() {
createCalendarSyncTrigger_();
alert_('已建立日曆同步觸發器:每 30 分鐘從 Google Calendar 回寫任務狀態。');
}
function removeCalendarSyncTrigger() {
const removed = removeCalendarSyncTrigger_();
alert_('已移除日曆同步觸發器:' + removed + ' 個。');
}
function syncImportantTasksToCalendar_() {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
const calendar = getTaskCalendar_();
const result = { created: 0, updated: 0, skipped: 0, failed: 0 };
if (!calendar) {
writeCalendarSyncStatusForAll_(sheet, map, '同步失敗:找不到日曆');
result.failed = Math.max(0, sheet.getLastRow() - 1);
return result;
}
for (let row = 2; row <= sheet.getLastRow(); row++) {
const task = readCalendarTaskFromRow_(sheet, row, map);
if (!task.taskName) continue;
const important = isImportantCalendarTask_(task);
writeCellByHeader_(sheet, row, map, '重要行事', important ? '是' : '否');
if (!important) {
result.skipped++;
continue;
}
const date = getTaskCalendarDate_(task);
if (!date) {
writeCellByHeader_(sheet, row, map, '日曆同步狀態', '同步失敗:缺少日期');
result.failed++;
continue;
}
try {
const title = buildCalendarTitle_(task);
const description = buildCalendarDescription_(task);
const eventInfo = upsertTaskCalendarEvent_(calendar, task, title, description, date);
writeCellByHeader_(sheet, row, map, '日曆標題', title);
writeCellByHeader_(sheet, row, map, '日曆日期', date);
writeCellByHeader_(sheet, row, map, '日曆事件ID', eventInfo.event.getId());
writeCellByHeader_(sheet, row, map, '日曆同步狀態', eventInfo.status);
writeCellByHeader_(sheet, row, map, '最後同步時間', nowText_());
if (eventInfo.status === '已同步' || eventInfo.status === '已重新建立') result.created++;
if (eventInfo.status === '已更新') result.updated++;
} catch (error) {
writeCellByHeader_(sheet, row, map, '日曆同步狀態', classifyCalendarError_(error, '同步失敗'));
result.failed++;
}
}
applyTaskOverviewFormatting_(sheet);
return result;
}
function syncTaskStatusFromCalendar_() {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
const calendar = getTaskCalendar_();
const result = { updated: 0, missing: 0, failed: 0 };
if (!calendar) {
writeCalendarReadStatusForAll_(sheet, map, '回寫失敗:其他錯誤');
result.failed = Math.max(0, sheet.getLastRow() - 1);
return result;
}
for (let row = 2; row <= sheet.getLastRow(); row++) {
const task = readCalendarTaskFromRow_(sheet, row, map);
if (!task.eventId) continue;
try {
const event = calendar.getEventById(task.eventId);
if (!event) {
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', '日曆事件遺失');
writeCellByHeader_(sheet, row, map, '日曆同步狀態', '需重新同步');
writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
result.missing++;
continue;
}
const changes = [];
const parsedStatus = parseTaskStatusFromCalendarEvent_(event);
if (parsedStatus && parsedStatus !== task.status) {
writeCellByHeader_(sheet, row, map, '狀態', parsedStatus);
changes.push('狀態更新');
if (parsedStatus === '已完成' && task.status !== '已完成') {
if (!task.completionTime) writeCellByHeader_(sheet, row, map, '完成時間', nowMinuteText_());
if (!task.completionDay) writeCellByHeader_(sheet, row, map, '完成日', nowMinuteText_());
}
}
const eventStart = event.getStartTime();
const eventEnd = event.getEndTime();
if (eventStart) {
writeCellByHeader_(sheet, row, map, '日曆日期', eventStart);
writeCellByHeader_(sheet, row, map, '到期日', eventStart);
changes.push('日期更新');
}
if (!event.isAllDayEvent()) {
writeCellByHeader_(sheet, row, map, '開始時間', formatTaskTime_(eventStart));
writeCellByHeader_(sheet, row, map, '結束時間', formatTaskTime_(eventEnd));
changes.push('時間更新');
}
const note = parseCalendarField_(event.getDescription(), '任務備註');
if (note) {
writeCellByHeader_(sheet, row, map, '備註', note);
changes.push('備註更新');
}
writeCellByHeader_(sheet, row, map, '日曆標題', event.getTitle());
writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', changes.length ? '已回寫:' + changes.join('、') : '已回寫');
applyTaskOverviewRowFormatting_(sheet, row);
result.updated++;
} catch (error) {
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', classifyCalendarError_(error, '回寫失敗'));
result.failed++;
}
}
return result;
}
function isImportantCalendarTask_(task) {
if (!getTaskCalendarDate_(task)) return false;
const text = [task.type, task.taskName, task.note].join(' ');
const exclude = ['同步報名資料與統計', '更新場次統計', '更新任務總覽', '更新期限狀態', '一鍵更新場次簽到狀態', '產生 50 筆假資料', '系統初始化', '建立示範資料'];
if (exclude.some(function(keyword) { return text.indexOf(keyword) >= 0; })) return false;
const include = ['活動日期', '活動開始', '活動結束', '正式活動', '營隊日期', '研習日期', '報名開始', '報名截止', '錄取公告', '候補通知', '場次日期', '梯次日期', '課程日期', '研習場次', '活動場次', '行前通知', '寄送通知', '提醒信', '錄取通知', '簽到表單建立', '簽到準備', '簽到 QR Code', '簽到測試', '活動計畫', '行政文件', '成果報告', '成果檢查', '結案', '核銷', 'NotebookLM 匯出', '提醒', '通知', '簽到', '成果', '報名'];
return task.isKey === '是' || include.some(function(keyword) { return text.indexOf(keyword) >= 0; });
}
function buildCalendarTitle_(task) {
const text = [task.type, task.taskName].join(' ');
let prefix = '【任務】';
if (/活動|營隊|研習場次|正式/.test(text)) prefix = '【活動】';
if (/報名截止|報名開始|錄取|候補/.test(text)) prefix = '【報名截止】';
if (/通知|提醒信|行前/.test(text)) prefix = '【通知】';
if (/簽到/.test(text)) prefix = '【簽到準備】';
if (/文件|計畫|成果報告/.test(text)) prefix = '【文件】';
if (/結案|核銷|NotebookLM/.test(text)) prefix = '【結案】';
return prefix + valueOrPending_(task.activityName) + (task.sessionName ? '|' + task.sessionName : '|' + task.taskName);
}
function buildCalendarDescription_(task) {
return [
'此事件由營隊活動管理中樞自動建立。',
'',
'【系統資料】',
'任務ID:' + task.taskId,
'活動ID:' + task.activityId,
task.sessionId ? '場次ID:' + task.sessionId : '',
'',
'【任務資料】',
task.activityName ? '活動名稱:' + task.activityName : '',
task.sessionName ? '場次名稱:' + task.sessionName : '',
'任務名稱:' + task.taskName,
task.type ? '任務類型:' + task.type : '',
task.owner ? '負責人:' + task.owner : '',
'任務狀態:' + valueOrPending_(task.status),
task.deadline ? '到期日:' + formatDateValue_(task.deadline) : '',
'',
'【可由日曆修改】',
'任務狀態:' + valueOrPending_(task.status),
'任務備註:' + (task.note || ''),
'',
'【相關連結】',
task.folderUrl ? '資料夾連結:' + task.folderUrl : '',
task.registrationUrl ? '報名表連結:' + task.registrationUrl : '',
task.attendanceUrl ? '簽到表連結:' + task.attendanceUrl : ''
].filter(function(line) { return line !== ''; }).join('\n');
}
function getTaskCalendarDate_(task) {
return parseDate_(task.calendarDate) || parseDate_(task.deadline) || parseDate_(task.fallbackDate);
}
function parseTaskStatusFromCalendarEvent_(event) {
const description = event.getDescription() || '';
const fromDescription = parseCalendarField_(description, '任務狀態');
return normalizeCalendarTaskStatus_(fromDescription) || parseCalendarStatusFromTitle_(event.getTitle());
}
function createCalendarSyncTrigger_() {
removeCalendarSyncTrigger_();
ScriptApp.newTrigger('syncCalendarBackToTaskOverview')
.timeBased()
.everyMinutes(30)
.create();
}
function removeCalendarSyncTrigger_() {
let removed = 0;
ScriptApp.getProjectTriggers().forEach(function(trigger) {
if (trigger.getHandlerFunction() === 'syncCalendarBackToTaskOverview') {
ScriptApp.deleteTrigger(trigger);
removed++;
}
});
return removed;
}
function getTaskCalendar_() {
try {
if (CAMP_CONFIG.CALENDAR_ID) {
return CalendarApp.getCalendarById(CAMP_CONFIG.CALENDAR_ID);
}
return CalendarApp.getDefaultCalendar();
} catch (error) {
return null;
}
}
function upsertTaskCalendarEvent_(calendar, task, title, description, date) {
let event = task.eventId ? calendar.getEventById(task.eventId) : null;
let status = '已更新';
if (!event) {
event = createTaskCalendarEvent_(calendar, task, title, description, date);
return { event: event, status: task.eventId ? '已重新建立' : '已同步' };
}
event.setTitle(title);
event.setDescription(description);
applyTaskCalendarEventTime_(event, task, date);
return { event: event, status: status };
}
function createTaskCalendarEvent_(calendar, task, title, description, date) {
const start = buildTaskDateTime_(date, task.startTime);
const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
if (task.startTime) {
return calendar.createEvent(title, start, end, { description: description });
}
return calendar.createAllDayEvent(title, date, { description: description });
}
function applyTaskCalendarEventTime_(event, task, date) {
if (task.startTime) {
const start = buildTaskDateTime_(date, task.startTime);
const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
event.setTime(start, end);
} else {
event.setAllDayDate(date);
}
}
function readCalendarTaskFromRow_(sheet, row, map) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
const activity = getActivityCalendarContext_(activityId);
const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
const session = getSessionCalendarContext_(activityId, sessionId);
return {
row: row,
taskId: String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim(),
activityId: activityId,
activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || activity.name || '').trim(),
sessionId: sessionId,
sessionName: String(readCellByHeader_(sheet, row, map, '場次名稱') || session.name || '').trim(),
type: String(readCellByHeader_(sheet, row, map, '任務類型') || readCellByHeader_(sheet, row, map, '階段') || '').trim(),
taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
completionDay: readCellByHeader_(sheet, row, map, '完成日'),
note: String(readCellByHeader_(sheet, row, map, '備註') || readCellByHeader_(sheet, row, map, '卡關原因/下一步') || '').trim(),
isKey: String(readCellByHeader_(sheet, row, map, '重要行事') || '').trim(),
calendarTitle: String(readCellByHeader_(sheet, row, map, '日曆標題') || '').trim(),
calendarDate: readCellByHeader_(sheet, row, map, '日曆日期'),
startTime: String(readCellByHeader_(sheet, row, map, '開始時間') || '').trim(),
endTime: String(readCellByHeader_(sheet, row, map, '結束時間') || '').trim(),
eventId: String(readCellByHeader_(sheet, row, map, '日曆事件ID') || '').trim(),
fallbackDate: session.date || activity.date,
folderUrl: activity.folderUrl,
registrationUrl: activity.registrationUrl,
attendanceUrl: session.attendanceUrl
};
}
function getActivityCalendarContext_(activityId) {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
const row = rows.filter(function(item) { return String(item['活動ID'] || '') === String(activityId || ''); })[0] || {};
return {
name: row['活動名稱'] || '',
date: row['活動日期'] || row['活動開始日'],
folderUrl: row['專案資料夾連結'] || '',
registrationUrl: row['報名表單連結'] || ''
};
}
function getSessionCalendarContext_(activityId, sessionId) {
const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
const row = rows.filter(function(item) {
return String(item['活動ID'] || '') === String(activityId || '') && String(item['場次ID'] || '') === String(sessionId || '');
})[0] || {};
return {
name: row['場次名稱'] || '',
date: row['場次日期'],
attendanceUrl: row['簽到表單連結'] || ''
};
}
function buildTaskDateTime_(date, timeText) {
const base = parseDate_(date);
const parts = String(timeText || '09:00').split(':');
return new Date(base.getFullYear(), base.getMonth(), base.getDate(), Number(parts[0] || 9), Number(parts[1] || 0), 0);
}
function addHours_(date, hours) {
return new Date(date.getTime() + Number(hours || 1) * 60 * 60 * 1000);
}
function formatTaskTime_(date) {
return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'HH:mm') : '';
}
function parseCalendarField_(description, label) {
const lines = String(description || '').split(/\r?\n/);
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.indexOf(label + ':') === 0) return line.replace(label + ':', '').trim();
}
return '';
}
function parseCalendarStatusFromTitle_(title) {
const match = String(title || '').match(/^\[(完成|已完成|進行中|待確認|延宕|延後|取消|不適用)\]/);
return match ? normalizeCalendarTaskStatus_(match[1]) : '';
}
function normalizeCalendarTaskStatus_(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return '';
if (text === '完成' || text === '已完成' || text === 'done' || text === 'ok') return '已完成';
if (text === '進行中') return '進行中';
if (text === '待確認') return '待確認';
if (text === '延宕' || text === '延後' || text === 'delay' || text === 'delayed') return '延後';
if (text === '取消' || text === 'cancel') return '取消';
if (text === '不適用') return '不適用';
if (CAMP_TASK_STATUS_VALUES.indexOf(value) >= 0) return value;
return '';
}
function classifyCalendarError_(error, prefix) {
const message = String(error && error.message || '');
if (message.indexOf('Authorization') >= 0 || message.indexOf('permission') >= 0 || message.indexOf('權限') >= 0) {
return prefix + ':權限不足';
}
return prefix + ':其他錯誤';
}
function writeCalendarSyncStatusForAll_(sheet, map, status) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
writeCellByHeader_(sheet, row, map, '日曆同步狀態', status);
}
}
function writeCalendarReadStatusForAll_(sheet, map, status) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
writeCellByHeader_(sheet, row, map, '日曆回寫狀態', status);
}
}
檔案 10
ActivityIntakeForm.gs|活動新增表單與活動清單同步
/**
* 營隊/活動管理中樞:活動新增表單。
*
* Google Form 的回應會寫入同一份 Spreadsheet,再由同步函式整理到
* 「01_活動清單」。這樣承辦人可以用表單新增活動,後續仍以活動清單
* 作為單一管理入口。
*/
function createActivityIntakeForm() {
const result = createActivityIntakeForm_();
alert_('活動新增表單已建立或確認存在:\n' + result.formUrl + '\n\n表單回應後,請執行「使用表單新增活動時才按:匯入活動回應」。若已使用新北示範資料,則不需要再匯入活動表單回應。');
return result.formUrl;
}
function createActivityIntakeForm_() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const props = PropertiesService.getScriptProperties();
const existingFormId = props.getProperty('CAMP_ACTIVITY_INTAKE_FORM_ID');
if (existingFormId) {
try {
const form = FormApp.openById(existingFormId);
ensureActivityIntakeFormItems_(form);
form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
writeActivityIntakeFormLinks_(ss, form);
ensureActivityIntakeSubmitTrigger_(ss);
return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
} catch (error) {
Logger.log('既有活動新增表單無法開啟,將重新建立:' + error.message);
}
}
const beforeSheetIds = ss.getSheets().map(function(sheet) {
return sheet.getSheetId();
});
const form = FormApp.create('營隊/活動新增表單');
form.setDescription([
'請填寫一筆營隊或活動資料。',
'表單回應會寫入本中控 Spreadsheet,並可同步到「01_活動清單」。',
'',
CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
].join('\n'));
form.addTextItem().setTitle('活動名稱').setRequired(true);
form.addListItem().setTitle('活動類型').setChoiceValues(['營隊', '校外教學', '校內活動', '研習', '競賽', '成果發表', '其他']).setRequired(true);
form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
form.addDateItem().setTitle('活動日期').setHelpText('若是單日活動可填此欄;若是多日活動,請改填活動開始日與活動結束日。').setRequired(false);
form.addDateItem().setTitle('活動開始日').setRequired(false);
form.addDateItem().setTitle('活動結束日').setRequired(false);
form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
form.addDateItem().setTitle('報名截止日').setRequired(false);
form.addDateItem().setTitle('同意書回收日').setRequired(false);
form.addTextItem().setTitle('活動地點').setRequired(false);
form.addTextItem().setTitle('參加對象').setRequired(false);
form.addTextItem().setTitle('預估人數').setRequired(false);
form.addTextItem().setTitle('承辦處室').setRequired(false);
form.addTextItem().setTitle('承辦人').setRequired(false);
form.addTextItem().setTitle('承辦人Email').setRequired(false);
form.addMultipleChoiceItem().setTitle('是否收費').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('費用說明').setRequired(false);
form.addListItem().setTitle('交通方式').setChoiceValues(['校內活動無交通', '步行', '遊覽車', '大眾運輸', '家長接送', '其他或待確認']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要保險').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addMultipleChoiceItem().setTitle('是否需要家長同意書').setChoiceValues(['是', '否', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('備註').setRequired(false);
form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
Utilities.sleep(1000);
const responseSheet = resolveActivityIntakeResponseSheet_(ss, beforeSheetIds);
props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_ID', form.getId());
props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_URL', form.getPublishedUrl());
props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_EDIT_URL', form.getEditUrl());
props.setProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME', responseSheet.getName());
moveFileToFolder_(form.getId(), getCampRootFolder_());
writeActivityIntakeFormLinks_(ss, form);
ensureActivityIntakeSubmitTrigger_(ss);
return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
}
function syncActivityIntakeResponsesToActivityList() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const responseSheet = getActivityIntakeResponseSheet_(ss);
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
const responseMap = ensureActivityIntakeSyncColumns_(responseSheet);
const existingKeys = buildExistingActivityIntakeKeys_(activitySheet, activityMap);
let added = 0;
let skipped = 0;
for (let row = 2; row <= responseSheet.getLastRow(); row++) {
const currentStatus = String(readCellByHeader_(responseSheet, row, responseMap, '同步狀態') || '').trim();
if (currentStatus && currentStatus !== '同步失敗') {
skipped++;
continue;
}
const timestamp = readCellByHeader_(responseSheet, row, responseMap, '時間戳記') || readCellByHeader_(responseSheet, row, responseMap, 'Timestamp');
const name = String(readCellByHeader_(responseSheet, row, responseMap, '活動名稱') || '').trim();
if (!name) {
writeActivityIntakeSyncNote_(responseSheet, row, responseMap, '已略過', '缺少活動名稱,未同步。');
skipped++;
continue;
}
const key = buildActivityIntakeKey_(name, timestamp);
if (existingKeys[key]) {
writeActivityIntakeSyncNote_(
responseSheet,
row,
responseMap,
'已略過:同一表單回應已同步',
'活動名稱與表單時間戳記已存在於 01_活動清單備註欄。'
);
skipped++;
continue;
}
const existingActivity = findExistingActivityByName_(activitySheet, activityMap, name);
if (existingActivity) {
writeActivityIntakeSyncNote_(
responseSheet,
row,
responseMap,
'已略過:同名活動已存在',
'已存在活動ID:' + existingActivity.id + ';活動名稱:' + existingActivity.name + '。未新增第二筆。'
);
appendNoteToExistingActivity_(activitySheet, activityMap, existingActivity.row, [
'活動新增表單有同名資料,系統已略過避免重複。',
'表單時間戳記:' + formatDateIfNeeded_(timestamp),
'略過時間:' + nowText_()
].join('\n'));
skipped++;
continue;
}
const activityId = generateActivityId_();
const startDate = readCellByHeader_(responseSheet, row, responseMap, '活動開始日') || readCellByHeader_(responseSheet, row, responseMap, '活動日期');
const endDate = readCellByHeader_(responseSheet, row, responseMap, '活動結束日') || startDate;
const activityDraft = {
id: activityId,
activityDate: readCellByHeader_(responseSheet, row, responseMap, '活動日期'),
startDate: startDate,
endDate: endDate
};
const activityDays = calculateActivityDays_(activityDraft);
const object = {};
object['活動ID'] = activityId;
object['活動名稱'] = name;
object['活動類型'] = readCellByHeader_(responseSheet, row, responseMap, '活動類型');
object['報名表模板類型'] = readCellByHeader_(responseSheet, row, responseMap, '報名表模板類型') || '自動判斷';
object['活動日期'] = readCellByHeader_(responseSheet, row, responseMap, '活動日期');
object['活動開始日'] = startDate;
object['活動結束日'] = endDate;
object['每日預設開始時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設開始時間') || '09:00';
object['每日預設結束時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設結束時間') || '12:00';
object['活動天數'] = activityDays;
object['場次數'] = 0;
object['是否多日活動'] = readCellByHeader_(responseSheet, row, responseMap, '是否多日活動') || (Number(activityDays || 0) > 1 ? '是' : '否');
object['是否開放部分場次報名'] = readCellByHeader_(responseSheet, row, responseMap, '是否開放部分場次報名') || '否';
object['是否需要每日簽到'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要每日簽到') || '是';
object['是否需要課後回饋'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要課後回饋') || '是';
object['是否需要成果繳交'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要成果繳交') || '是';
object['報名截止日'] = readCellByHeader_(responseSheet, row, responseMap, '報名截止日');
object['同意書回收日'] = readCellByHeader_(responseSheet, row, responseMap, '同意書回收日');
object['活動地點'] = readCellByHeader_(responseSheet, row, responseMap, '活動地點');
object['參加對象'] = readCellByHeader_(responseSheet, row, responseMap, '參加對象');
object['預估人數'] = readCellByHeader_(responseSheet, row, responseMap, '預估人數');
object['承辦處室'] = readCellByHeader_(responseSheet, row, responseMap, '承辦處室');
object['承辦人'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人');
object['承辦人Email'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人Email');
object['是否收費'] = readCellByHeader_(responseSheet, row, responseMap, '是否收費');
object['費用說明'] = readCellByHeader_(responseSheet, row, responseMap, '費用說明');
object['交通方式'] = readCellByHeader_(responseSheet, row, responseMap, '交通方式');
object['是否需要保險'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要保險');
object['是否需要家長同意書'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要家長同意書');
object['報名人數'] = 0;
object['同意參加'] = 0;
object['不參加'] = 0;
object['特殊需求數'] = 0;
object['目前狀態'] = '表單新增';
object['最近更新時間'] = nowText_();
object['備註'] = '由活動新增表單同步;表單時間戳記:' + formatDateIfNeeded_(timestamp);
activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) { return object[header] || ''; }));
writeActivityIntakeSyncNote_(
responseSheet,
row,
responseMap,
'已新增',
'已新增到 01_活動清單,活動ID:' + activityId
);
const newRow = activitySheet.getLastRow();
const activity = readActivityFromRow_(activitySheet, newRow, activityMap);
generateSessionsForActivity_(activity);
updateActivitySessionFields_({ sheet: activitySheet, row: newRow, headerMap: activityMap, activity: activity });
existingKeys[key] = true;
added++;
}
updateDashboard_();
alert_('活動表單同步完成。新增到 01_活動清單:' + added + ' 筆;略過:' + skipped + ' 筆。');
return added;
}
function writeActivityIntakeFormLinks_(ss, form) {
const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
ensureDashboardVisualLayout_(dashboard);
dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
dashboard.getRange('G4').setValue(form.getPublishedUrl()).setWrap(true);
dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
dashboard.getRange('G5').setValue(form.getEditUrl()).setWrap(true);
dashboard.getRange('F6').setValue('操作提醒').setFontWeight('bold');
dashboard.getRange('G6').setValue('如果已使用「新增示範資料:新北AI研習」,不需要再執行「匯入活動新增表單回應到活動清單」。').setWrap(true);
const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
ensureHeaders_(settings, CAMP_SETTINGS_HEADERS);
upsertSettingRow_(settings, '活動新增表單', form.getPublishedUrl(), '承辦人填寫後可同步到 01_活動清單');
upsertSettingRow_(settings, '活動新增表單編輯', form.getEditUrl(), '講師或管理者調整表單欄位使用');
}
function getActivityIntakeResponseSheet_(ss) {
const savedName = PropertiesService.getScriptProperties().getProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME');
if (savedName) {
const sheet = ss.getSheetByName(savedName);
if (sheet && looksLikeActivityIntakeResponseSheet_(sheet)) return sheet;
}
const sheets = ss.getSheets();
for (let i = 0; i < sheets.length; i++) {
if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
}
throw new Error('找不到活動新增表單回應分頁。請先執行「建立/開啟活動新增表單」,並送出至少一筆表單或確認表單已連結此試算表。');
}
function resolveActivityIntakeResponseSheet_(ss, beforeSheetIds) {
const before = {};
beforeSheetIds.forEach(function(id) {
before[id] = true;
});
const sheets = ss.getSheets();
for (let i = 0; i < sheets.length; i++) {
if (!before[sheets[i].getSheetId()] && looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
}
for (let i = 0; i < sheets.length; i++) {
if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
}
return sheets[sheets.length - 1];
}
function looksLikeActivityIntakeResponseSheet_(sheet) {
if (sheet.getLastColumn() < 6) return false;
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
return headers.indexOf('活動名稱') >= 0
&& headers.indexOf('活動類型') >= 0
&& headers.indexOf('承辦人Email') >= 0
&& headers.indexOf('是否需要家長同意書') >= 0;
}
function ensureActivityIntakeFormItems_(form) {
const titles = {};
form.getItems().forEach(function(item) {
titles[item.getTitle()] = true;
});
if (!titles['活動開始日']) form.addDateItem().setTitle('活動開始日').setRequired(false);
if (!titles['報名表模板類型']) form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
if (!titles['活動結束日']) form.addDateItem().setTitle('活動結束日').setRequired(false);
if (!titles['每日預設開始時間']) form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
if (!titles['每日預設結束時間']) form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
if (!titles['是否多日活動']) form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
if (!titles['是否開放部分場次報名']) form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
if (!titles['是否需要每日簽到']) form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
if (!titles['是否需要課後回饋']) form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
if (!titles['是否需要成果繳交']) form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
}
function buildExistingActivityIntakeKeys_(activitySheet, activityMap) {
const keys = {};
for (let row = 2; row <= activitySheet.getLastRow(); row++) {
const name = readCellByHeader_(activitySheet, row, activityMap, '活動名稱');
const note = String(readCellByHeader_(activitySheet, row, activityMap, '備註') || '');
const match = note.match(/表單時間戳記:([^\n]+)/);
const timestamp = match ? match[1] : '';
if (name && timestamp) {
keys[buildActivityIntakeKey_(name, timestamp)] = true;
}
}
return keys;
}
function buildActivityIntakeKey_(name, timestamp) {
return String(name || '').trim() + '|' + String(timestamp || '').trim();
}
function upsertSettingRow_(sheet, key, value, note) {
const map = ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (readCellByHeader_(sheet, row, map, '設定項目') === key) {
writeCellByHeader_(sheet, row, map, '設定值', value);
writeCellByHeader_(sheet, row, map, '說明', note);
return;
}
}
sheet.appendRow([key, value, note]);
}
function ensureActivityIntakeSubmitTrigger_(ss) {
const functionName = 'syncActivityIntakeResponsesToActivityList';
const exists = ScriptApp.getProjectTriggers().some(function(trigger) {
return trigger.getHandlerFunction() === functionName
&& trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT;
});
if (!exists) {
ScriptApp.newTrigger(functionName).forSpreadsheet(ss).onFormSubmit().create();
}
}
function normalizeActivityName_(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/[\s ]/g, '')
.replace(/[||\--—–_::,,。.、//()()「」『』【】\[\]]/g, '');
}
function findExistingActivityByName_(sheet, headerMap, activityName) {
const targetName = String(activityName || '').trim();
const normalizedTarget = normalizeActivityName_(targetName);
if (!targetName) return null;
for (let row = 2; row <= sheet.getLastRow(); row++) {
const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
const existingStatus = String(readCellByHeader_(sheet, row, headerMap, '目前狀態') || '').trim();
const existingNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();
if (!existingName) continue;
if (existingName === targetName || normalizeActivityName_(existingName) === normalizedTarget) {
return {
row: row,
id: existingId,
name: existingName,
status: existingStatus,
note: existingNote
};
}
}
return null;
}
function ensureActivityIntakeSyncColumns_(sheet) {
const requiredHeaders = ['同步狀態', '同步時間', '同步備註'];
let headers = sheet.getRange(1, 1, 1, Math.max(sheet.getLastColumn(), 1)).getValues()[0].map(String);
requiredHeaders.forEach(function(header) {
if (headers.indexOf(header) < 0) {
sheet.getRange(1, sheet.getLastColumn() + 1).setValue(header);
headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
}
});
return buildHeaderMap_(sheet);
}
function writeActivityIntakeSyncNote_(sheet, row, responseMap, status, note) {
writeCellByHeader_(sheet, row, responseMap, '同步狀態', status);
writeCellByHeader_(sheet, row, responseMap, '同步時間', nowText_());
writeCellByHeader_(sheet, row, responseMap, '同步備註', note);
}
function appendNoteToExistingActivity_(sheet, headerMap, row, note) {
const currentNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();
if (currentNote.indexOf(note) >= 0) return;
const newNote = [
currentNote,
'',
'【同步防呆紀錄】',
note
].filter(Boolean).join('\n');
writeCellByHeader_(sheet, row, headerMap, '備註', newNote);
writeCellByHeader_(sheet, row, headerMap, '最近更新時間', nowText_());
}
檔案 11
ActivityService.gs|活動資料包建立
/**
* 營隊/活動管理中樞:活動資料包建立與回填。
*/
function createActivityPackageFromSelectedRow() {
const context = getSelectedActivityRowContext_();
const result = ensureActivityPackageForContext_(context);
updateDashboard_();
alert_([
'活動資料包已建立或確認存在。',
'活動:' + result.activity.name,
'資料夾:' + result.folder.getUrl(),
'',
'下一步可建立報名表單或行政文件模板。'
].join('\n'));
return result.folder.getUrl();
}
function ensureActivityPackageForContext_(context) {
initializeSheets_(context.ss);
let activity = context.activity;
if (!activity.id) {
activity.id = generateActivityId_();
writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動ID', activity.id);
}
let folder = getFolderFromActivityUrl_(activity.folderUrl);
if (!folder) {
folder = createActivityFolder_(activity);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '專案資料夾連結', folder.getUrl());
activity.folderUrl = folder.getUrl();
}
const subfolders = createActivitySubfolders_(folder);
generateSessionsForActivity_(activity);
updateActivitySessionFields_(context);
if (!activity.summaryDocUrl) {
const summaryDoc = createActivitySummaryDoc_(activity, subfolders['00_活動基本資料']);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動摘要連結', summaryDoc.getUrl());
appendDocumentListRow_(activity, '活動摘要', summaryDoc.getName(), '集中呈現活動基本資料與承辦人確認事項', summaryDoc.getUrl(), '系統模板', '草稿', '待確認', '建立活動資料包時產生');
}
appendDefaultTaskRows_(activity);
appendDefaultNotificationRows_(activity);
appendDefaultOutcomeRows_(activity);
appendNotebookIndexRows_(activity);
writeCellByHeader_(context.sheet, context.row, context.headerMap, '目前狀態', '資料包已建立');
writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
return { activity: activity, folder: folder, subfolders: subfolders };
}
function ensureActivityPackageForSelectedActivity_() {
const context = getSelectedActivityRowContext_();
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
packageContext.activity = context.activity;
packageContext.rowContext = context;
return packageContext;
}
function createActivitySummaryDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_活動摘要_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('活動摘要').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('一、活動基本資料').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph(activityToText_(activity));
body.appendParagraph('二、承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
[
'活動日期、地點與時間是否正確。',
'費用、收費方式、保險與交通是否符合校內程序。',
'家長通知書與同意書正式發出前是否已核定。',
'報名資料與 NotebookLM 匯出資料是否已排除不必要個資。'
].forEach(function(item) {
body.appendParagraph('□ ' + item);
});
body.appendParagraph('三、AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph(CAMP_PROMPTS.activityDiagnosis.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function appendDefaultTaskRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
ensureHeaders_(sheet, CAMP_TASK_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) sessions = generateSessionsForActivity_(activity);
const startDate = effectiveActivityStartDate_(activity);
const endDate = effectiveActivityEndDate_(activity) || startDate;
const rows = [];
[
['ALL', 'A01', '啟動', '建立活動資料包', '建立活動主資料夾、場次設定與中控台索引', '活動資料包', '資料夾與活動摘要已建立', '承辦人', activity.owner, '', addDays_(startDate, -30), '', '', '是', '活動摘要', '未開始', '高', '', ''],
['ALL', 'A02', '報名', '建立報名表單', '確認全程與部分場次報名欄位', '報名表單', '表單連結已回填活動清單', '承辦人', activity.owner, '', addDays_(startDate, -21), '', 'A01', '是', '報名表單', '未開始', '高', '', ''],
['ALL', 'A03', '通知', '發布活動通知', '家長或學員通知與處室通知完成確認', '通知文件', '正式通知已由承辦人確認', '承辦人', activity.owner, '', addDays_(startDate, -14), '', 'A02', '是', '家長通知書/處室通知', '未開始', '高', '', ''],
['ALL', 'A04', '名冊', '彙整總報名名單', '整理全程與部分場次報名統計', '報名統計', '場次報名人數已更新', '承辦人', activity.owner, '', activity.registrationDeadline, '', 'A02', '是', '報名資料', '未開始', '高', '', ''],
['ALL', 'A05', '行政確認', '確認收費/保險/交通', '確認活動行政風險與校內程序', '行政確認紀錄', '正式內容已確認', '承辦人', activity.owner, '', addDays_(startDate, -10), '', 'A03', '是', '標案需求表', '未開始', '高', '', ''],
['POST', 'P01', '結案', '建立 NotebookLM 資料包', '去識別化後整理多日活動資料包', 'NotebookLM資料包', '匯出文件已建立', '承辦人', activity.owner, '', addDays_(endDate, 7), '', '', '否', 'NotebookLM資料包', '未開始', '中', '', ''],
['POST', 'P02', '結案', '結案成果報告', '彙整成果、回饋、照片說明與下次建議', '成果報告素材', '成果檢查表已完成', '承辦人', activity.owner, '', addDays_(endDate, 14), '', 'P01', '否', '成果檢查表', '未開始', '中', '', '']
].forEach(function(item) {
rows.push(buildTaskSheetRow_({
'活動ID': activity.id,
'場次ID': item[0],
'WBS編碼': item[1],
'階段': item[2],
'任務名稱': item[3],
'具體任務': item[4],
'交付物': item[5],
'完成判準': item[6],
'負責角色': item[7],
'負責人': item[8],
'建議開始日期': item[9],
'建議完成日期': item[10],
'實際完成日期': item[11],
'前置任務ID': item[12],
'是否關鍵任務': item[13],
'需要文件或表單': item[14],
'狀態': item[15],
'優先程度': item[16],
'卡關原因': item[17],
'備註': item[18],
'最後更新時間': ''
}));
});
sessions.forEach(function(session) {
[
['課前', '確認本場講義', '確認本場講義、材料或簡報', '本場講義', '講義連結已放入場次設定', addDays_(parseDate_(session.date), -3), '高'],
['課前', '確認本場場地或線上連結', '確認場地、設備、線上會議連結與助教分工', '場地或線上連結', '場次地點與設備已確認', addDays_(parseDate_(session.date), -2), '高'],
['課前', '課前提醒學員', '提醒日期、時間、地點、攜帶物品與簽到方式', '課前提醒', '提醒已發出或公告', addDays_(parseDate_(session.date), -1), '中'],
['課前', '確認簽到表單', '確認逐場簽到資料與簽到流程', '簽到資料', '簽到預備列已建立', addDays_(parseDate_(session.date), -1), '高'],
['課後', '課後回收回饋', '回收本場回饋與問題紀錄', '回饋摘要', '回饋數已更新', parseDate_(session.date), '中'],
['課後', '整理本場成果', '整理照片、講義、錄影、回饋與成果說明', '本場成果資料', '成果整理狀態已更新', addDays_(parseDate_(session.date), 2), '中']
].forEach(function(item, index) {
rows.push(buildTaskSheetRow_({
'活動ID': activity.id,
'場次ID': session.sessionId,
'WBS編碼': session.sessionId + '-T' + ('0' + (index + 1)).slice(-2),
'階段': item[0],
'任務名稱': item[1],
'具體任務': item[2],
'交付物': item[3],
'完成判準': item[4],
'負責角色': '承辦人/協作人員',
'負責人': activity.owner,
'建議開始日期': '',
'建議完成日期': item[5],
'實際完成日期': '',
'前置任務ID': '',
'是否關鍵任務': item[6] === '高' ? '是' : '否',
'需要文件或表單': item[3],
'狀態': '未開始',
'優先程度': item[6],
'卡關原因': '',
'備註': '',
'最後更新時間': ''
}));
});
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_TASK_HEADERS.length).setValues(rows);
}
function buildTaskSheetRow_(taskData) {
return CAMP_TASK_HEADERS.map(function(header) {
return Object.prototype.hasOwnProperty.call(taskData, header) ? taskData[header] : '';
});
}
function appendDefaultNotificationRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
const activityDate = parseDate_(activity.activityDate);
const rows = [
['家長活動通知', '家長', '紙本/Email/班級群組', addDays_(activityDate, -14), '', activity.parentNoticeUrl || '', '草稿', '待確認', '正式發出前需確認日期、費用、交通與保險'],
['家長同意書回收提醒', '家長', '紙本/班級群組', activity.consentDeadline || activity.registrationDeadline, '', activity.parentConsentUrl || '', '未通知', '待確認', '追蹤未回覆名單'],
['導師行前提醒', '導師', '校內群組/Email', addDays_(activityDate, -7), '', activity.teacherReminderUrl || '', '草稿', '待確認', '確認點名與回報方式'],
['相關處室協調', '總務/學務/健康中心/會計', 'Email/會議', addDays_(activityDate, -14), '', activity.officeEmailUrl || '', '草稿', '待確認', '採購、保險、交通、安全與核銷'],
['成果資料收件提醒', '導師/協辦人', '校內群組/Email', addDays_(activityDate, 3), '', '', '未通知', '待確認', '活動後整理成果與照片說明']
].map(function(row) {
return [activity.id].concat(row);
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTIFICATION_HEADERS.length).setValues(rows);
}
function appendDefaultOutcomeRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.OUTCOMES);
ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) sessions = generateSessionsForActivity_(activity);
const rows = [
['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '需補照片說明'],
['簽到或報名統計', '待確認', '', '是', '否', activity.owner, '含學生資料,不建議直接匯入 NotebookLM'],
['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可作為成果報告素材'],
['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
['經費或核銷附件', '不適用', '', '待確認', '否', activity.owner, '依校內規定處理'],
['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM 資料包']
].map(function(row) {
return [activity.id].concat(row);
});
sessions.forEach(function(session) {
[
[session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
[session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
[session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
[session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
].forEach(function(row) {
rows.push([activity.id].concat(row));
});
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
}
function appendNotebookIndexRows_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTEBOOK);
ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
if (getRowsByActivityId_(sheet, activity.id).length) return;
const rows = [
['活動基本資料', activityToText_(activity), '01_活動清單', '是', '不含敏感個資時可匯出'],
['報名統計', '報名人數、參加人數、不參加人數、特殊需求摘要', '02_報名資料', '是', '只放統計與摘要,不放電話與健康細節'],
['任務狀態', '任務追蹤與完成狀態', '03_任務追蹤', '是', ''],
['通知紀錄', '家長、導師、處室通知狀態', '05_通知管理', '是', '正式通知內容需承辦人確認'],
['成果檢查', '成果資料、照片說明、下次建議', '06_成果檢查', '是', ''],
['敏感資料', '學生姓名、電話、健康、身分資料、收費明細', '各表單原始回覆', '否', '不放入 NotebookLM 資料包']
].map(function(row) {
return [activity.id].concat(row);
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTEBOOK_HEADERS.length).setValues(rows);
}
檔案 12
RegistrationTemplateService.gs|報名表模板判斷
/**
* 營隊/活動管理中樞:報名表模板與條件欄位判斷。
*/
function isYes_(value) {
return String(value || '').trim() === '是';
}
function getRegistrationTemplateType_(activity) {
const configured = String(activity.registrationTemplateType || '').trim();
if (configured && configured !== '自動判斷') return configured;
if (isWorkshopActivity_(activity) && isOnlineActivity_(activity)) return '線上研習';
if (isWorkshopActivity_(activity)) return '教師研習';
if (isStudentActivity_(activity)) return '學生營隊';
return '自動判斷';
}
function isWorkshopActivity_(activity) {
const template = String(activity.registrationTemplateType || '');
const type = String(activity.type || '');
const name = String(activity.name || '');
if (template.indexOf('教師研習') >= 0 || template.indexOf('線上研習') >= 0) return true;
return type.indexOf('研習') >= 0
|| type.indexOf('工作坊') >= 0
|| name.indexOf('研習') >= 0
|| name.indexOf('工作坊') >= 0;
}
function isStudentActivity_(activity) {
const template = String(activity.registrationTemplateType || '');
const type = String(activity.type || '');
if (template.indexOf('學生營隊') >= 0 || template.indexOf('校外教學') >= 0 || template.indexOf('親子活動') >= 0 || template.indexOf('競賽活動') >= 0) return true;
return type.indexOf('營隊') >= 0
|| type.indexOf('校外教學') >= 0
|| type.indexOf('校內活動') >= 0
|| type.indexOf('競賽') >= 0;
}
function isOnlineActivity_(activity) {
const template = String(activity.registrationTemplateType || '');
const location = String(activity.location || '');
const transport = String(activity.transport || '');
if (template.indexOf('線上研習') >= 0) return true;
return location.indexOf('Google Meet') >= 0
|| location.indexOf('線上') >= 0
|| transport.indexOf('線上') >= 0;
}
function needsDietSurvey_(activity) {
if (isOnlineActivity_(activity)) return false;
const type = String(activity.type || '');
const feeNote = String(activity.feeNote || '');
const note = String(activity.note || '');
return type.indexOf('營隊') >= 0
|| type.indexOf('校外教學') >= 0
|| feeNote.indexOf('餐') >= 0
|| note.indexOf('餐') >= 0
|| note.indexOf('便當') >= 0;
}
function needsInsuranceFields_(activity) {
return isYes_(activity.insuranceRequired);
}
function needsConsentFields_(activity) {
return isYes_(activity.consentRequired);
}
function needsPaymentFields_(activity) {
return isYes_(activity.chargeRequired);
}
function needsPartialSessionFields_(activity) {
return isYes_(activity.allowPartialSessions);
}
function buildWorkshopSessionChoiceValues_(activity, sessions) {
if (String(activity.id || '') === 'ACT-NTPC-AI-11506') {
return [
'S01|6月15日(一) 課程主題|從公文啟動專案 主案例:教育局來文「AI 融入教學教師研習計畫」',
'S02|6月18日(四) 課程主題|活動型專案管理 主案例:校外教學/親子活動辦理',
'S03|6月22日(一) 課程主題|成果型專案與 AI 文件整理 主案例:教育優先區/補助計畫成果報告',
'S04|6月25日(四) 課程主題|大型專案整合與制度化 主案例:運動會/校慶大型行政專案'
];
}
return buildSessionChoiceValues_(sessions);
}
檔案 13
RegistrationForm.gs|報名表單與回應同步
/**
* 營隊/活動管理中樞:報名表單與回應同步。
*/
function createRegistrationFormForSelectedActivity() {
const packageContext = getRegistrationPackageContextFromSelectedActivity_();
if (!packageContext) return '';
return createRegistrationFormForPackageContext_(packageContext);
}
function getRegistrationPackageContextFromSelectedActivity_() {
const context = getSelectedActivityRowContext_();
if (warnIfPossibleDuplicateNewTaipeiWorkshop_(context.activity)) {
return null;
}
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
packageContext.activity = context.activity;
packageContext.rowContext = context;
return packageContext;
}
function warnIfPossibleDuplicateNewTaipeiWorkshop_(activity) {
const name = String(activity.name || '');
const id = String(activity.id || '');
if (name.indexOf('新北AI共創大聯盟') >= 0 && id !== 'ACT-NTPC-AI-11506') {
alert_([
'你目前選取的活動名稱像是新北AI研習,但活動ID不是預設示範資料 ID。',
'',
'目前活動ID:' + valueOrPending_(id),
'建議示範資料ID:ACT-NTPC-AI-11506',
'',
'如果這是重複同步產生的資料,請改選 ACT-NTPC-AI-11506 那一列。'
].join('\n'));
return true;
}
return false;
}
function createRegistrationFormForPackageContext_(packageContext) {
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
if (activity.registrationFormUrl && activity.registrationFormEditUrl) {
alert_('此活動已經有報名表單:\n' + activity.registrationFormUrl);
return activity.registrationFormUrl;
}
generateSessionsForActivity_(activity);
const sessions = getSessionsByActivityId_(activity.id);
const form = FormApp.create('報名表單_' + activity.name);
form.setDescription([
'活動名稱:' + activity.name,
'活動日期:' + formatDateValue_(activity.activityDate),
'活動地點:' + valueOrPending_(activity.location),
'報名截止日:' + formatDateValue_(activity.registrationDeadline),
'',
'個資使用提醒:本表單資料僅供本次活動報名、通知、保險、聯繫與行政確認使用。正式保存、分享與刪除仍依校內規範辦理。'
].join('\n'));
form.setAllowResponseEdits(true);
form.setDestination(FormApp.DestinationType.SPREADSHEET, rowContext.ss.getId());
const activityIdItem = form.addTextItem().setTitle('活動ID').setRequired(true).setHelpText('系統欄位,請勿修改。');
const activityNameItem = form.addTextItem().setTitle('活動名稱').setRequired(true).setHelpText('系統欄位,請勿修改。');
if (isWorkshopActivity_(activity)) {
form.addTextItem().setTitle('單位').setRequired(true);
form.addTextItem().setTitle('科別/職稱').setRequired(true);
form.addTextItem().setTitle('姓名').setRequired(true);
form.addTextItem().setTitle('Email').setRequired(false);
form.addCheckboxItem()
.setTitle('參加場次')
.setChoiceValues(buildWorkshopSessionChoiceValues_(activity, sessions))
.setRequired(true);
form.addMultipleChoiceItem()
.setTitle('需要研習時數')
.setChoiceValues(['需要', '不需要', '待確認'])
.setRequired(false);
form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);
form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
} else {
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);
if (needsPartialSessionFields_(activity)) {
form.addMultipleChoiceItem().setTitle('是否全程參加').setChoiceValues(['是,我會全程參加', '否,我只參加部分場次']).setRequired(true);
form.addCheckboxItem().setTitle('選擇參加場次').setChoiceValues(buildSessionChoiceValues_(sessions)).setRequired(false);
}
form.addMultipleChoiceItem().setTitle('需要研習時數').setChoiceValues(['需要', '不需要', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);
if (needsDietSurvey_(activity)) {
form.addMultipleChoiceItem().setTitle('飲食需求').setChoiceValues(['無特殊需求', '葷食', '素食', '其他']).setRequired(false);
}
if (needsInsuranceFields_(activity)) {
form.addTextItem().setTitle('出生日期').setRequired(false);
form.addTextItem().setTitle('身分證字號').setRequired(false);
form.addMultipleChoiceItem().setTitle('是否同意提供保險所需資料').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
}
if (needsConsentFields_(activity)) {
form.addMultipleChoiceItem().setTitle('家長是否同意參加').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
form.addMultipleChoiceItem().setTitle('同意書狀態').setChoiceValues(['已繳交', '尚未繳交', '不適用']).setRequired(false);
}
if (needsPaymentFields_(activity)) {
form.addMultipleChoiceItem().setTitle('繳費方式').setChoiceValues(['現金', '轉帳', '學校代收', '其他', '待確認']).setRequired(false);
form.addParagraphTextItem().setTitle('繳費備註').setRequired(false);
}
form.addCheckboxItem().setTitle('特殊需求').setChoiceValues(['無', '暈車', '醫療需求', '過敏', '交通接送', '其他']).setRequired(false);
}
form.addMultipleChoiceItem().setTitle('是否同意通知').setChoiceValues(['同意', '不同意']).setRequired(true);
form.addParagraphTextItem().setTitle('備註').setRequired(false);
const prefilledUrl = form.createResponse()
.withItemResponse(activityIdItem.createResponse(activity.id))
.withItemResponse(activityNameItem.createResponse(activity.name))
.toPrefilledUrl();
moveFileToFolder_(form.getId(), packageContext.subfolders['01_報名表單與回覆']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單連結', prefilledUrl);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單編輯連結', form.getEditUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名回應表連結', rowContext.ss.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, '報名表單', form.getTitle(), '收集活動報名、場次選擇與行政需求資料', prefilledUrl, 'Google Form', '待確認', '待確認', '發出前需確認欄位與個資告知文字;模板:' + getRegistrationTemplateType_(activity));
updateDashboard_();
alert_('報名表單已建立:\n' + prefilledUrl);
return prefilledUrl;
}
function syncRegistrationResponses() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const targetSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
ensureHeaders_(targetSheet, CAMP_REGISTRATION_HEADERS);
const existingKeys = buildExistingRegistrationKeys_(targetSheet);
const systemSheetNames = {};
CAMP_SHEET_ORDER.forEach(function(name) {
systemSheetNames[name] = true;
});
let added = 0;
ss.getSheets().forEach(function(sheet) {
if (systemSheetNames[sheet.getName()]) return;
if (!looksLikeRegistrationResponseSheet_(sheet)) return;
const values = sheet.getDataRange().getValues();
const headers = values[0];
const index = buildArrayHeaderIndex_(headers);
for (let i = 1; i < values.length; i++) {
const row = values[i];
const activityId = readArrayCell_(row, index, '活動ID');
const studentName = readArrayCell_(row, index, '學生姓名')
|| readArrayCell_(row, index, '姓名');
const unit = readArrayCell_(row, index, '班級')
|| readArrayCell_(row, index, '單位');
const title = readArrayCell_(row, index, '座號')
|| readArrayCell_(row, index, '科別/職稱')
|| readArrayCell_(row, index, '科別/職稱')
|| readArrayCell_(row, index, '科別/ 職稱');
const activityName = readArrayCell_(row, index, '活動名稱');
const timestamp = readArrayCell_(row, index, '時間戳記') || readArrayCell_(row, index, 'Timestamp');
if (!activityId || !studentName) continue;
const key = [activityId, studentName, timestamp].join('|');
if (existingKeys[key]) continue;
const selectedText = readArrayCell_(row, index, '選擇參加場次') || readArrayCell_(row, index, '參加場次');
const fullParticipation = readArrayCell_(row, index, '是否全程參加')
|| inferFullParticipationFromSelectedSessions_(selectedText);
const selectedSessions = normalizeSelectedSessions_(activityId, fullParticipation, selectedText);
const email = readArrayCell_(row, index, '家長Email') || readArrayCell_(row, index, 'Email');
targetSheet.appendRow([
activityId,
activityName,
timestamp,
studentName,
unit,
title,
readArrayCell_(row, index, '家長姓名'),
email,
readArrayCell_(row, index, '家長電話'),
readArrayCell_(row, index, '是否參加') || '參加',
fullParticipation,
selectedSessions,
readArrayCell_(row, index, '需要研習時數'),
readArrayCell_(row, index, '學習期待'),
readArrayCell_(row, index, '目前行政痛點'),
readArrayCell_(row, index, '是否同意通知'),
readArrayCell_(row, index, '飲食需求'),
readArrayCell_(row, index, '特殊需求'),
readArrayCell_(row, index, '同意書狀態'),
'否',
sheet.getName(),
buildRegistrationNote_(row, index, title)
]);
ensureAttendanceRowsForRegistration_({
activityId: activityId,
name: studentName,
unit: unit,
email: email,
fullParticipation: fullParticipation,
selectedSessions: selectedSessions
});
existingKeys[key] = true;
added++;
}
});
updateRegistrationStats_();
updateSessionStats_();
updateDashboard_();
alert_('報名資料同步完成。新增 ' + added + ' 筆。');
return added;
}
function buildSessionChoiceValues_(sessions) {
if (!sessions.length) return ['S01|第1場'];
return sessions.map(function(session) {
return [
session.sessionId,
formatDateValue_(session.date),
session.title || ('第' + session.sessionId.replace('S', '') + '場'),
session.topic || ''
].filter(String).join('|');
});
}
function normalizeSelectedSessions_(activityId, fullParticipation, selectedText) {
const sessions = getSessionsByActivityId_(activityId);
if (String(fullParticipation || '').indexOf('全程') >= 0 || String(fullParticipation || '') === '是') {
return sessions.map(function(session) { return session.sessionId; }).join(',');
}
const selected = parseSelectedSessionIds_(selectedText);
if (selected.length) return selected.join(',');
return sessions.length === 1 ? sessions[0].sessionId : '';
}
function inferFullParticipationFromSelectedSessions_(selectedText) {
const count = parseSelectedSessionIds_(selectedText).length;
return count >= 4 ? '是,我會全程參加' : '否,我只參加部分場次';
}
function buildRegistrationNote_(row, index, title) {
const note = String(readArrayCell_(row, index, '備註') || '').trim();
const workshopTitle = String(title || '').trim();
if (!workshopTitle) return note;
return [note, '科別/職稱:' + workshopTitle].filter(String).join('\n');
}
function updateRegistrationStats_() {
const ss = getActiveSpreadsheet_();
const registrationSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const registrationRows = getRegistrationStatsByActivity_(registrationSheet);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
for (let row = 2; row <= activitySheet.getLastRow(); row++) {
const activityId = readCellByHeader_(activitySheet, row, activityMap, '活動ID');
if (!activityId) continue;
const stats = registrationRows[String(activityId)] || { total: 0, join: 0, notJoin: 0, special: 0 };
writeCellByHeader_(activitySheet, row, activityMap, '報名人數', stats.total);
writeCellByHeader_(activitySheet, row, activityMap, '同意參加', stats.join);
writeCellByHeader_(activitySheet, row, activityMap, '不參加', stats.notJoin);
writeCellByHeader_(activitySheet, row, activityMap, '特殊需求數', stats.special);
writeCellByHeader_(activitySheet, row, activityMap, '最近更新時間', nowText_());
}
}
function getRegistrationStatsByActivity_(sheet) {
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
const map = buildHeaderMap_(sheet);
const stats = {};
for (let row = 2; row <= sheet.getLastRow(); row++) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
if (!activityId) continue;
if (!stats[activityId]) stats[activityId] = { total: 0, join: 0, notJoin: 0, special: 0 };
stats[activityId].total++;
const participate = String(readCellByHeader_(sheet, row, map, '是否參加') || '');
if (participate === '參加') stats[activityId].join++;
if (participate === '不參加') stats[activityId].notJoin++;
const special = String(readCellByHeader_(sheet, row, map, '特殊需求') || '').trim();
if (special && special !== '無') stats[activityId].special++;
}
return stats;
}
function buildExistingRegistrationKeys_(sheet) {
const map = buildHeaderMap_(sheet);
const keys = {};
for (let row = 2; row <= sheet.getLastRow(); row++) {
const key = [
readCellByHeader_(sheet, row, map, '活動ID'),
readCellByHeader_(sheet, row, map, '學生姓名'),
readCellByHeader_(sheet, row, map, '時間戳記')
].join('|');
keys[key] = true;
}
return keys;
}
function looksLikeRegistrationResponseSheet_(sheet) {
if (sheet.getLastRow() < 1 || sheet.getLastColumn() < 3) return false;
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
const hasName = headers.indexOf('學生姓名') >= 0 || headers.indexOf('姓名') >= 0;
const hasParticipation = headers.indexOf('是否參加') >= 0
|| headers.indexOf('選擇參加場次') >= 0
|| headers.indexOf('參加場次') >= 0;
return headers.indexOf('活動ID') >= 0 && hasName && hasParticipation;
}
function buildArrayHeaderIndex_(headers) {
const index = {};
headers.forEach(function(header, i) {
if (header) index[String(header).trim()] = i;
});
return index;
}
function readArrayCell_(row, index, header) {
return index[header] === undefined ? '' : row[index[header]];
}
檔案 14
SeedRegistrationData.gs|教學用50筆假報名資料
/**
* 營隊/活動管理中樞:教學用報名假資料。
*/
function createRegistrationFormAndSeedSampleData() {
const packageContext = getRegistrationPackageContextFromSelectedActivity_();
if (!packageContext) return '';
const formUrl = createRegistrationFormForPackageContext_(packageContext);
const seeded = seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
alert_([
'報名表單已建立或確認存在。',
'',
'本次新增假報名資料:' + seeded + ' 筆。',
'已更新報名統計、場次統計、簽到預備資料與中控台。',
'',
'報名表單:',
formUrl
].join('\n'));
return formUrl;
}
function seedSampleWorkshopRegistrationsForSelectedActivity() {
const packageContext = getRegistrationPackageContextFromSelectedActivity_();
if (!packageContext) return 0;
return seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
}
function seedSampleWorkshopRegistrationsForPackageContext_(packageContext) {
const activity = packageContext.activity;
if (!activity.id) {
throw new Error('此活動缺少活動ID,請先建立活動資料包或補上活動ID。');
}
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
const existingRows = getRowsByActivityId_(sheet, activity.id);
const hasOfficialRows = existingRows.some(function(row) {
return String(row['來源分頁'] || '') !== '系統假資料';
});
if (hasOfficialRows) {
alert_('此活動已有正式報名資料,為避免混入教學假資料,本次不自動產生 50 筆假資料。');
return 0;
}
if (existingRows.length >= 50) {
alert_('此活動已有 ' + existingRows.length + ' 筆報名資料,不重複 seed 50 筆。');
return 0;
}
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) {
generateSessionsForActivity_(activity);
sessions = getSessionsByActivityId_(activity.id);
}
const remaining = Math.max(0, 50 - existingRows.length);
const sampleRows = buildSampleWorkshopRegistrationRows_(activity, remaining, existingRows.length);
if (!sampleRows.length) return 0;
sheet.getRange(sheet.getLastRow() + 1, 1, sampleRows.length, CAMP_REGISTRATION_HEADERS.length)
.setValues(sampleRows);
sampleRows.forEach(function(row) {
ensureAttendanceRowsForRegistration_({
activityId: row[CAMP_REGISTRATION_HEADERS.indexOf('活動ID')],
name: row[CAMP_REGISTRATION_HEADERS.indexOf('學生姓名')],
unit: row[CAMP_REGISTRATION_HEADERS.indexOf('班級')],
email: row[CAMP_REGISTRATION_HEADERS.indexOf('家長Email')],
fullParticipation: row[CAMP_REGISTRATION_HEADERS.indexOf('是否全程參加')],
selectedSessions: row[CAMP_REGISTRATION_HEADERS.indexOf('選擇參加場次')]
});
});
updateRegistrationStats_();
updateSessionStats_();
updateDashboard_();
return sampleRows.length;
}
function buildSampleWorkshopRegistrationRows_(activity, count, offset) {
const units = [
'新北市政府教育局', '八里國小', '三和國中', '三芝國中', '三義高中',
'竹南國小', '新莊國小', '板橋國小', '泰山高中', '淡水國中',
'一般企業', '一般民眾', '人力學院', '教育科技中心', '資訊教育輔導團'
];
const titles = [
'國小教育科', '主任', '教師', '科任教師', '資訊組長',
'教務主任', '學務主任', '行政人員', '採購', '上班族',
'綜合規劃組', '輔導員', '研究教師', '自然科教師', '總務主任'
];
const names = [
'王品安', '林書宇', '陳郁庭', '張雅筑', '李承恩',
'黃子晴', '劉冠廷', '吳怡萱', '蔡孟潔', '楊皓宇',
'許庭瑄', '鄭宇翔', '謝佳穎', '洪柏翰', '郭采蓉',
'曾昱廷', '邱郁涵', '廖品妤', '賴冠宇', '周宜蓁',
'徐子軒', '蘇映潔', '葉哲維', '江佩珊', '何宥辰',
'蕭雅雯', '羅品萱', '高承翰', '潘怡君', '游宗翰',
'程心瑜', '方柏宇', '石婉婷', '沈昱翔', '戴佳蓉',
'彭宇恩', '詹雅婷', '卓冠霖', '簡若涵', '藍柏翰',
'梁芷晴', '康家豪', '姜宜庭', '白承祐', '任品妍',
'歐陽子晴', '范庭瑋', '宋宜蓁', '古承恩', '杜欣妤'
];
const painPoints = [
'資料分散在不同雲端資料夾,常常找不到最新版。',
'活動報名、通知、名冊整理需要重複複製貼上。',
'公文轉成待辦任務時,容易漏掉期限。',
'想把表單、試算表、文件和提醒整合起來。',
'希望學會用 AI 協助整理成果報告草稿。',
'行政流程常需要跨處室協作,希望能更清楚追蹤。',
'想知道 NotebookLM 可以如何用在行政資料整理。',
'希望了解 Apps Script 在校務行政的實際應用。'
];
const expectations = [
'希望能完成一套可複製的行政專案流程。',
'想學會從公文建立專案追蹤表。',
'希望能降低人工整理資料的時間。',
'想把活動報名與成果整理變得更有系統。',
'希望能做出適合學校同仁使用的範本。',
'想理解 AI 在行政流程中的合理位置。',
'希望回校後能實際套用在研習或活動辦理。',
'想學會建立 NotebookLM 資料包。'
];
const rows = [];
for (let i = 0; i < count; i++) {
const serial = offset + i;
const name = names[serial % names.length];
const unit = units[serial % units.length];
const title = titles[serial % titles.length];
const email = 'sample' + ('000' + (serial + 1)).slice(-3) + '@example.com';
const selectedSessions = buildSeedSelectedSessions_(serial);
const fullParticipation = selectedSessions === 'S01,S02,S03,S04' ? '是,我會全程參加' : '否,我只參加部分場次';
const timestamp = new Date(2026, 5, 1 + (serial % 5), 9 + (serial % 10), (serial * 7) % 60, (serial * 11) % 60);
const object = {};
object['活動ID'] = activity.id;
object['活動名稱'] = activity.name;
object['時間戳記'] = timestamp;
object['學生姓名'] = name;
object['班級'] = unit;
object['座號'] = title;
object['家長姓名'] = '';
object['家長Email'] = email;
object['家長電話'] = '';
object['是否參加'] = '參加';
object['是否全程參加'] = fullParticipation;
object['選擇參加場次'] = selectedSessions;
object['需要研習時數'] = serial % 4 === 0 ? '不需要' : '需要';
object['學習期待'] = expectations[serial % expectations.length];
object['目前行政痛點'] = painPoints[serial % painPoints.length];
object['是否同意通知'] = '同意';
object['飲食需求'] = '';
object['特殊需求'] = '';
object['同意書狀態'] = '不適用';
object['是否可提供 NotebookLM'] = '是';
object['來源分頁'] = '系統假資料';
object['備註'] = '教師研習示範資料,非真實個資。單位:' + unit + ';職稱:' + title;
rows.push(CAMP_REGISTRATION_HEADERS.map(function(header) {
return object[header] || '';
}));
}
return rows;
}
function buildSeedSelectedSessions_(index) {
const patterns = [
'S01,S02,S03,S04',
'S01,S02,S03,S04',
'S01,S02,S03,S04',
'S01,S02,S03,S04',
'S01',
'S02',
'S03',
'S04',
'S01,S02',
'S02,S03',
'S03,S04',
'S01,S04',
'S01,S03',
'S02,S04',
'S01,S02,S03',
'S02,S03,S04'
];
return patterns[index % patterns.length];
}
function clearSeedRegistrationDataForSelectedActivity() {
const context = getSelectedActivityRowContext_();
const activity = context.activity;
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
const map = buildHeaderMap_(sheet);
for (let row = sheet.getLastRow(); row >= 2; row--) {
const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
const source = String(readCellByHeader_(sheet, row, map, '來源分頁') || '');
if (activityId === activity.id && source === '系統假資料') {
sheet.deleteRow(row);
}
}
updateRegistrationStats_();
updateSessionStats_();
updateDashboard_();
alert_('已清除本活動的系統假報名資料。');
}
檔案 15
DocumentTemplates.gs|行政文件模板
/**
* 營隊/活動管理中樞:行政文件模板。
*/
function createDocumentTemplatesForSelectedActivity() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const subfolders = packageContext.subfolders;
const parentNotice = createParentNoticeDoc_(activity, subfolders['02_家長通知與同意書']);
const parentConsent = createParentConsentDoc_(activity, subfolders['02_家長通知與同意書']);
const procurement = createProcurementRequirementDoc_(activity, subfolders['03_處室聯繫與採購']);
const officeEmail = createOfficeEmailDraftDoc_(activity, subfolders['03_處室聯繫與採購']);
const teacherReminder = createTeacherReminderDoc_(activity, subfolders['03_處室聯繫與採購']);
const outcomeSheet = createOutcomeChecklistSheet_(activity, subfolders['06_成果檢查與結案']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長通知書連結', parentNotice.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長同意書連結', parentConsent.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '標案需求表連結', procurement.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '處室通知草稿連結', officeEmail.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '導師行前提醒連結', teacherReminder.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '目前狀態', '文件模板已建立');
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
activity.parentNoticeUrl = parentNotice.getUrl();
activity.parentConsentUrl = parentConsent.getUrl();
activity.procurementUrl = procurement.getUrl();
activity.officeEmailUrl = officeEmail.getUrl();
activity.teacherReminderUrl = teacherReminder.getUrl();
activity.outcomeUrl = outcomeSheet.getUrl();
appendDocumentListRow_(activity, '家長通知書', parentNotice.getName(), '提供家長活動資訊與回覆提醒', parentNotice.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '家長同意書', parentConsent.getName(), '提供家長簽署或確認參加意願', parentConsent.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '標案需求表', procurement.getName(), '整理採購、交通、保險或物資需求', procurement.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '處室通知草稿', officeEmail.getName(), '提供總務、學務、健康中心、會計或協辦處室通知草稿', officeEmail.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '導師行前提醒', teacherReminder.getName(), '提供導師活動前與活動當日提醒事項', teacherReminder.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '檢查活動後成果、照片、附件與 NotebookLM 可用資料', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
refreshNotificationDocumentLinks_(activity);
updateDashboard_();
alert_('行政文件模板已建立完成。請逐份確認後再正式公告、寄送或送簽。');
}
function createParentNoticeDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_家長通知書_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('家長通知書').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('親愛的家長您好:');
body.appendParagraph('本校預計辦理「' + activity.name + '」,以下為活動資訊草稿,正式內容仍以承辦人確認後公告為準。');
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['活動開始日', formatDateValue_(activity.startDate)],
['活動結束日', formatDateValue_(activity.endDate)],
['活動地點', valueOrPending_(activity.location)],
['參加對象', valueOrPending_(activity.participants)],
['報名截止日', formatDateValue_(activity.registrationDeadline)],
['費用說明', valueOrPending_(activity.feeNote)],
['交通方式', valueOrPending_(activity.transport)],
['保險需求', valueOrPending_(activity.insuranceRequired)]
]);
body.appendParagraph('場次表').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendSessionsTable_(body, activity.id);
body.appendParagraph('家長需協助事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph('請於期限內完成報名或同意書回覆,並留意導師或學校後續通知。');
body.appendParagraph('AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph(CAMP_PROMPTS.parentNotice.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['日期、時間、地點正確', '費用與繳交方式正確', '交通、保險與安全資訊已確認', '正式發出前已依校內程序核定']);
body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createParentConsentDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_家長同意書_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('家長同意書').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('本人已詳閱「' + activity.name + '」活動說明,並同意學生依學校規劃參與本次活動。');
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['活動地點', valueOrPending_(activity.location)],
['同意書回收日', formatDateValue_(activity.consentDeadline)],
['是否收費', valueOrPending_(activity.chargeRequired)],
['是否需要保險', valueOrPending_(activity.insuranceRequired)]
]);
body.appendParagraph('學生姓名:____________________ 班級座號:____________________');
body.appendParagraph('家長簽名:____________________ 聯絡電話:____________________');
body.appendParagraph('特殊需求或注意事項:');
body.appendParagraph('\n\n');
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['同意書欄位符合校內格式', '未收集不必要個資', '回收期限與保管方式已確認']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createProcurementRequirementDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_標案採購需求表_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('標案/採購需求表').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('本文件用於整理活動所需之交通、場地、保險、餐點、材料、設備或其他採購需求草稿。正式採購仍依校內與法規程序辦理。');
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['預估人數', valueOrPending_(activity.estimatedCount)],
['活動地點', valueOrPending_(activity.location)],
['交通方式', valueOrPending_(activity.transport)],
['費用說明', valueOrPending_(activity.feeNote)],
['承辦處室', valueOrPending_(activity.department)],
['承辦人', valueOrPending_(activity.owner)]
]);
body.appendParagraph('需求項目草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendDocTable_(body, [
['需求類別', '需求內容', '數量', '規格', '預估日期', '備註'],
['交通', activity.transport || '待確認', activity.estimatedCount || '', '', formatDateValue_(activity.activityDate), ''],
['保險', activity.insuranceRequired || '待確認', activity.estimatedCount || '', '', '', ''],
['餐點或材料', '待確認', '', '', '', '']
]);
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['是否需要總務或會計確認', '採購方式是否符合規定', '經費來源與核銷附件是否明確']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createOfficeEmailDraftDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_處室通知Email草稿_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('處室通知 Email 草稿').setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendParagraph('主旨:【活動協作通知】' + activity.name);
body.appendParagraph('收件對象:總務處、學務處、健康中心、會計室、導師或協辦同仁(請承辦人依實際情況調整)');
body.appendParagraph('Email 內容草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
body.appendParagraph([
'各位同仁您好:',
'',
'本校預計辦理「' + activity.name + '」,活動日期為 ' + formatDateValue_(activity.activityDate) + ',地點為 ' + valueOrPending_(activity.location) + '。',
'敬請協助確認相關行政事項:',
'1. 交通、場地或物資需求。',
'2. 保險、健康或特殊需求學生提醒。',
'3. 經費、採購、核銷或成果附件需求。',
'4. 活動當日安全、集合、點名與回報方式。',
'',
'相關資料夾或文件連結請見中控台。',
'',
'承辦人:' + valueOrPending_(activity.owner),
'承辦處室:' + valueOrPending_(activity.department)
].join('\n'));
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['收件對象是否正確', '不包含不應外寄的學生個資', '採購、保險、交通與安全內容已確認']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createTeacherReminderDoc_(activity, folder) {
const doc = DocumentApp.create(activity.id + '_導師行前提醒_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
body.appendParagraph('導師/工作人員行前提醒').setHeading(DocumentApp.ParagraphHeading.TITLE);
appendDocTable_(body, [
['活動名稱', activity.name],
['活動日期', formatDateValue_(activity.activityDate)],
['活動地點', valueOrPending_(activity.location)],
['參加對象', valueOrPending_(activity.participants)]
]);
body.appendParagraph('活動前一週').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['確認參加名冊', '提醒學生攜帶物品', '確認特殊需求或醫療提醒']);
const sessions = getSessionsByActivityId_(activity.id);
if (sessions.length > 1) {
body.appendParagraph('逐場提醒').setHeading(DocumentApp.ParagraphHeading.HEADING1);
sessions.forEach(function(session) {
body.appendParagraph(session.sessionId + '|' + formatDateValue_(session.date) + '|' + valueOrPending_(session.title)).setHeading(DocumentApp.ParagraphHeading.HEADING2);
appendChecklistParagraphs_(body, [
'確認本場地點或線上連結:' + valueOrPending_(session.location),
'確認本場簽到方式與名冊',
'確認本場講義、設備與回報方式'
]);
});
}
body.appendParagraph('活動前一天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['再次提醒集合時間與地點', '確認未回覆或特殊需求學生', '確認緊急聯絡方式']);
body.appendParagraph('活動當天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['集合點名', '活動中定時確認人數', '返校或結束後回報']);
body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
appendChecklistParagraphs_(body, ['提醒內容是否符合實際活動', '不公開不必要個資', '安全與回報方式已確認']);
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function createOutcomeChecklistSheet_(activity, folder) {
const ss = SpreadsheetApp.create(activity.id + '_成果檢查表_' + sanitizeFileName_(activity.name));
const sheet = ss.getSheets()[0];
sheet.setName('成果檢查');
sheet.getRange(1, 1, 1, CAMP_OUTCOME_HEADERS.length).setValues([CAMP_OUTCOME_HEADERS]);
const rows = [
['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '請補照片說明'],
['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可用於成果報告'],
['參與統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放學生個資'],
['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM']
].map(function(row) {
return [activity.id].concat(row);
});
let sessions = getSessionsByActivityId_(activity.id);
if (!sessions.length) sessions = generateSessionsForActivity_(activity);
sessions.forEach(function(session) {
[
[session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
[session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
[session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
[session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
].forEach(function(row) {
rows.push([activity.id].concat(row));
});
});
sheet.getRange(2, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
formatHeaderRow_(sheet, CAMP_OUTCOME_HEADERS.length);
moveFileToFolder_(ss.getId(), folder);
return ss;
}
function appendDocTable_(body, rows) {
const table = body.appendTable(rows);
table.setBorderWidth(1);
return table;
}
function appendChecklistParagraphs_(body, items) {
items.forEach(function(item) {
body.appendParagraph('□ ' + item);
});
}
檔案 16
NotificationService.gs|通知測試與紀錄
/**
* 營隊/活動管理中樞:通知草稿與測試信。
*/
function sendTestEmailToOwner() {
const context = getSelectedActivityRowContext_();
const activity = context.activity;
if (!activity.ownerEmail) throw new Error('此活動缺少「承辦人Email」,無法寄送測試信。');
const subject = '【測試信】' + activity.name + ' 活動通知草稿檢查';
const body = [
'這是營隊活動管理中樞寄出的測試信,尚未寄送給家長或其他處室。',
'',
'活動名稱:' + activity.name,
'活動日期:' + formatDateValue_(activity.activityDate),
'活動地點:' + valueOrPending_(activity.location),
'',
'重要文件連結:',
'專案資料夾:' + valueOrPending_(activity.folderUrl),
'家長通知書:' + valueOrPending_(activity.parentNoticeUrl),
'家長同意書:' + valueOrPending_(activity.parentConsentUrl),
'處室通知草稿:' + valueOrPending_(activity.officeEmailUrl),
'導師行前提醒:' + valueOrPending_(activity.teacherReminderUrl),
'',
CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
].join('\n');
MailApp.sendEmail(activity.ownerEmail, subject, body);
recordNotificationStatus_(activity, '承辦人測試信', '承辦人', 'Email', new Date(), activity.ownerEmail, '已寄送', '已確認', '測試信只寄給承辦人');
updateDashboard_();
alert_('測試信已寄給承辦人:' + activity.ownerEmail);
}
function recordNotificationStatus_(activity, name, target, channel, date, fileLink, status, confirmation, note) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
sheet.appendRow([
activity.id,
name,
target,
channel,
date,
nowText_(),
fileLink || '',
status || '已記錄',
confirmation || '待確認',
note || ''
]);
}
function refreshNotificationDocumentLinks_(activity) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
const map = buildHeaderMap_(sheet);
for (let row = 2; row <= sheet.getLastRow(); row++) {
if (String(readCellByHeader_(sheet, row, map, '活動ID')) !== String(activity.id)) continue;
const name = String(readCellByHeader_(sheet, row, map, '通知名稱') || '');
if (name.indexOf('家長活動通知') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentNoticeUrl);
if (name.indexOf('家長同意書') >= 0 || name.indexOf('回收提醒') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentConsentUrl);
if (name.indexOf('導師') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.teacherReminderUrl);
if (name.indexOf('處室') >= 0 || name.indexOf('協調') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.officeEmailUrl);
}
}
檔案 17
DeadlineService.gs|期限狀態標記
/**
* 營隊/活動管理中樞:期限狀態與自動標記。
*/
function updateDeadlineStatuses() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
let updated = 0;
for (let row = 2; row <= sheet.getLastRow(); row++) {
const activity = readActivityFromRow_(sheet, row, map);
if (!activity.name) continue;
const registrationStatus = calculateDateStatus_(activity.registrationDeadline, false);
const consentStatus = calculateDateStatus_(activity.consentDeadline, false);
const activityStatus = calculateDateStatus_(activity.activityDate, false);
const status = buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus);
writeCellByHeader_(sheet, row, map, '目前狀態', status);
writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
updated++;
}
updateDashboard_();
alert_('期限狀態已更新:' + updated + ' 筆活動。');
}
function buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus) {
const urgent = [];
if (registrationStatus === '逾期' || registrationStatus === '即將到期') urgent.push('報名' + registrationStatus);
if (consentStatus === '逾期' || consentStatus === '即將到期') urgent.push('同意書' + consentStatus);
if (activityStatus === '逾期') urgent.push('活動日期已過');
if (activityStatus === '即將到期') urgent.push('活動即將到期');
return urgent.length ? urgent.join(';') : '期限正常';
}
function buildDeadlineRows_(activity) {
const activityDate = parseDate_(activity.activityDate);
return [
['報名截止日', activity.registrationDeadline, '確認報名人數、未回覆名單與特殊需求'],
['同意書回收日', activity.consentDeadline, '確認同意書、保險與名冊'],
['活動前提醒日', addDays_(activityDate, -3), '確認導師、處室、家長與學生行前提醒'],
['成果整理日', addDays_(activityDate, 3), '整理照片、流程紀錄、回饋與附件'],
['NotebookLM 匯出日', addDays_(activityDate, 7), '去識別化後匯出資料包']
];
}
檔案 18
NotebookExport.gs|NotebookLM 資料包匯出
/**
* 營隊/活動管理中樞:NotebookLM 資料包匯出。
*/
function exportNotebookLMPackageForSelectedActivity() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const content = buildNotebookMarkdown_(activity);
const doc = createNotebookDoc_(activity, content, packageContext.subfolders['07_NotebookLM資料包']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, 'NotebookLM資料包連結', doc.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, 'NotebookLM資料包', doc.getName(), '匯入 NotebookLM 的去識別化專案摘要', doc.getUrl(), '系統匯出', '草稿', '待確認', '匯入前請再次檢查個資');
updateDashboard_();
alert_('NotebookLM 資料包已匯出:\n' + doc.getUrl());
return doc.getUrl();
}
function buildNotebookMarkdown_(activity) {
const ss = getActiveSpreadsheet_();
const taskRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.TASKS), activity.id);
const documentRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS), activity.id);
const notificationRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS), activity.id);
const outcomeRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES), activity.id);
const sessionRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.SESSIONS), activity.id);
const registrationStats = getRegistrationStatsByActivity_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS))[activity.id] || { total: 0, join: 0, notJoin: 0, special: 0 };
return [
'# NotebookLM匯入用|活動行政專案資料包',
'',
'## 一、活動基本資料',
activityToText_(activity),
'',
'## 二、報名統計',
'- 報名總人數:' + registrationStats.total,
'- 參加人數:' + registrationStats.join,
'- 不參加人數:' + registrationStats.notJoin,
'- 特殊需求筆數:' + registrationStats.special,
'- 注意:此處只放統計,不放學生電話、健康細節或身分資料。',
'',
'## 三、場次總覽',
formatRowsForNotebook_(sessionRows, CAMP_SESSION_HEADERS, ['場次ID', '場次日期', '開始時間', '結束時間', '場次名稱', '場次主題', '講師', '地點或線上連結', '目前狀態', '報名人數', '簽到人數', '出席率']),
'',
'## 四、逐場任務狀態',
formatSessionTaskSummary_(taskRows),
'',
'## 五、逐場成果整理',
formatSessionOutcomeSummary_(outcomeRows),
'',
'## 六、多日活動行政觀察',
'- 請檢查哪些場次報名人數偏低、簽到率偏低或課前任務未完成。',
'- 請確認高風險場次是否已補上場地、講義、簽到、回饋與成果整理。',
'- 若後續場次會受前一場成果影響,請在任務追蹤表補上前置任務。',
'',
'## 七、重要期限',
buildDeadlineRows_(activity).map(function(row) {
return '- ' + row[0] + ':' + formatDateValue_(row[1]) + ',' + row[2];
}).join('\n'),
'',
'## 八、任務執行情形',
formatRowsForNotebook_(taskRows, CAMP_TASK_HEADERS, ['階段', '任務名稱', '負責人', '建議完成日期', '狀態', '優先程度']),
'',
'## 九、通知紀錄',
formatRowsForNotebook_(notificationRows, CAMP_NOTIFICATION_HEADERS, ['通知名稱', '通知對象', '通知管道', '建議通知日期', '寄送或通知狀態', '承辦人確認']),
'',
'## 十、文件清單',
formatRowsForNotebook_(documentRows, CAMP_DOCUMENT_HEADERS, ['文件類型', '文件名稱', '用途', '連結', '狀態', '承辦人確認']),
'',
'## 十一、成果與檢查',
formatRowsForNotebook_(outcomeRows, CAMP_OUTCOME_HEADERS, ['成果項目', '是否完成', '是否含個資', '是否可提供 NotebookLM', '負責人', '備註']),
'',
'## 十二、個資與匯出提醒',
'- 不匯出學生電話、家長電話、健康細節、身分資料、完整名冊。',
'- 不匯出完整簽到名冊、個別缺席原因或個別健康需求。',
'- 需要使用學生回饋時,請先去識別化。',
'- 正式公告、收費、保險、交通、安全與核銷仍由承辦人確認。',
'',
'## 十三、可問 NotebookLM 的問題範例',
'1. 這個活動目前最需要先確認哪些事項?',
'2. 哪些任務還沒有完成?',
'3. 家長通知或導師提醒有哪些缺漏?',
'4. 成果報告還缺哪些素材?',
'5. 下次辦理有哪些可沿用與需調整事項?',
'',
'## 十四、AI 提示詞',
CAMP_PROMPTS.notebook.replace('{{WORKFLOW_DATA}}', activityToText_(activity))
].join('\n');
}
function formatSessionTaskSummary_(taskRows) {
if (!taskRows.length) return '目前尚無逐場任務。';
const grouped = {};
taskRows.forEach(function(row) {
const sessionId = row[CAMP_TASK_HEADERS.indexOf('場次ID')] || 'ALL';
if (!grouped[sessionId]) grouped[sessionId] = [];
grouped[sessionId].push(row);
});
return Object.keys(grouped).sort().map(function(sessionId) {
const rows = grouped[sessionId];
const done = rows.filter(function(row) {
return row[CAMP_TASK_HEADERS.indexOf('狀態')] === '已完成';
}).length;
return '- ' + sessionId + ':任務 ' + rows.length + ' 筆,已完成 ' + done + ' 筆,完成率 ' + formatPercent_(done, rows.length);
}).join('\n');
}
function formatSessionOutcomeSummary_(outcomeRows) {
if (!outcomeRows.length) return '目前尚無逐場成果資料。';
const itemIndex = CAMP_OUTCOME_HEADERS.indexOf('成果項目');
const statusIndex = CAMP_OUTCOME_HEADERS.indexOf('是否完成');
return outcomeRows.filter(function(row) {
return /^S\d{2}/.test(String(row[itemIndex] || ''));
}).map(function(row) {
return '- ' + row[itemIndex] + ':' + valueOrPending_(row[statusIndex]);
}).join('\n') || '目前尚無逐場成果資料。';
}
function createNotebookDoc_(activity, content, folder) {
const doc = DocumentApp.create(activity.id + '_NotebookLM資料包_' + sanitizeFileName_(activity.name));
const body = doc.getBody();
content.split('\n').forEach(function(line) {
if (line.indexOf('# ') === 0) {
body.appendParagraph(line.replace(/^# /, '')).setHeading(DocumentApp.ParagraphHeading.TITLE);
} else if (line.indexOf('## ') === 0) {
body.appendParagraph(line.replace(/^## /, '')).setHeading(DocumentApp.ParagraphHeading.HEADING1);
} else {
body.appendParagraph(line);
}
});
doc.saveAndClose();
moveFileToFolder_(doc.getId(), folder);
return doc;
}
function formatRowsForNotebook_(rows, headers, selectedHeaders) {
if (!rows.length) return '目前尚無資料。';
return rows.map(function(row) {
return '- ' + selectedHeaders.map(function(header) {
const index = headers.indexOf(header);
const value = index >= 0 ? row[index] : '';
return header + ':' + valueOrPending_(formatDateIfNeeded_(value));
}).join(';');
}).join('\n');
}
function formatDateIfNeeded_(value) {
if (value instanceof Date) return formatDateValue_(value);
return value;
}
檔案 19
AiPrompts.gs|AI 提示詞模板
/**
* 營隊/活動管理中樞:提示詞集中管理。
*
* MVP 先採用「提示詞模板」。Gemini API 可於進階階段再接上。
*/
const CAMP_PROMPTS = {
activityDiagnosis: [
'你是一位熟悉台灣國小行政流程的 AI 行政專案顧問。',
'請根據以下活動建案資料,協助判斷這個活動是否適合用活動型行政專案方式管理。',
'請用表格回答:',
'1. 目前資訊已清楚的部分',
'2. 目前資訊不足的部分',
'3. 最優先要確認的 5 件事',
'4. 如果直接執行,可能出現的行政風險',
'5. 建議建立哪些 Google Workspace 文件、表單、資料夾或提醒',
'',
'活動資料:',
'{{ACTIVITY_DATA}}'
].join('\n'),
parentNotice: [
'你是一位熟悉台灣國小行政語氣的學校行政助理。',
'請根據以下活動資料,產出一份給家長的活動通知書草稿。',
'請注意:',
'1. 語氣正式、清楚、親切。',
'2. 日期、費用、集合時間若資料不足,請用【待確認】標示。',
'3. 涉及收費、保險、交通、安全者,請提醒承辦人正式確認。',
'4. 不要自行編造不存在的資料。',
'5. 使用繁體中文。',
'6. 最後加上「承辦人確認事項」。',
'',
'活動資料:',
'{{ACTIVITY_DATA}}'
].join('\n'),
riskCheck: [
'你是一位協助學校行政人員進行活動風險盤點的 AI 助理。',
'請根據活動資料、任務、通知與成果檢查,找出可能風險。',
'請分成時程、人員、學生安全、交通、家長溝通、經費核銷、成果結案、個資與資料安全。',
'每一項請列出風險內容、可能造成的問題、預防方式、是否需要提醒、承辦人需確認事項。',
'',
'活動資料:',
'{{ACTIVITY_DATA}}'
].join('\n'),
notebook: [
'你是一位協助學校行政人員整理 NotebookLM 專案資料包的助理。',
'請根據以下活動資料與工作流摘要,整理成可匯入 NotebookLM 的乾淨資料包。',
'請不要放入學生電話、健康細節、身分資料或不必要個資。',
'請包含:活動基本資料、重要時程、任務狀態、通知紀錄、成果檢查、下次辦理建議、可問 NotebookLM 的問題。',
'',
'工作流資料:',
'{{WORKFLOW_DATA}}'
].join('\n')
};
檔案 20
SampleData.gs|新北AI研習示範資料
/**
* 營隊/活動管理中樞:研習示範資料。
*/
function addNewTaipeiAiWorkshopSample() {
const ss = getActiveSpreadsheet_();
initializeSheets_(ss);
const activityId = 'ACT-NTPC-AI-11506';
const activityName = '新北AI共創大聯盟|Google Workspace 行政專案工作流自動化實作';
const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
const existingRow = findActivityRowByIdOrName_(activitySheet, activityMap, activityId, activityName);
if (existingRow) {
const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
if (getRowsByActivityId_(sessionSheet, activityId).length === 0) {
appendNewTaipeiAiWorkshopSessions_(activityId);
updateDashboard_();
alert_('示範活動已存在,已補上缺漏的 4 筆場次設定。\n\n活動ID:' + activityId);
return;
}
alert_('示範資料已存在,不重複新增。\n\n活動ID:' + activityId);
return;
}
const startDate = new Date(2026, 5, 15);
const endDate = new Date(2026, 5, 25);
const registrationDeadline = new Date(2026, 5, 15);
const activity = {};
activity['活動ID'] = activityId;
activity['活動名稱'] = activityName;
activity['活動類型'] = '研習';
activity['報名表模板類型'] = '線上研習';
activity['活動日期'] = startDate;
activity['活動開始日'] = startDate;
activity['活動結束日'] = endDate;
activity['每日預設開始時間'] = '19:00';
activity['每日預設結束時間'] = '21:00';
activity['活動天數'] = 4;
activity['場次數'] = 4;
activity['是否多日活動'] = '是';
activity['是否開放部分場次報名'] = '是';
activity['是否需要每日簽到'] = '是';
activity['是否需要課後回饋'] = '是';
activity['是否需要成果繳交'] = '否';
activity['報名截止日'] = registrationDeadline;
activity['同意書回收日'] = '';
activity['活動地點'] = 'Google Meet 線上研習:https://meet.google.com/usb-mjie-ett';
activity['參加對象'] = '新北市各校教師及行政人員';
activity['預估人數'] = '';
activity['承辦處室'] = '新北市政府教育局/新北AI共創大聯盟';
activity['承辦人'] = '彭智標主任';
activity['承辦人Email'] = Session.getActiveUser().getEmail() || '';
activity['是否收費'] = '否';
activity['費用說明'] = '線上研習,未載明收費';
activity['交通方式'] = '線上研習無交通';
activity['是否需要保險'] = '否';
activity['是否需要家長同意書'] = '否';
activity['報名表單連結'] = 'https://forms.gle/heMoe3KLF97RB6Hy5';
activity['報名人數'] = 0;
activity['同意參加'] = 0;
activity['不參加'] = 0;
activity['特殊需求數'] = 0;
activity['目前狀態'] = '示範資料';
activity['最近更新時間'] = nowText_();
activity['備註'] = [
'示範資料:新北AI研習。',
'本筆資料已直接寫入活動清單與場次設定。',
'若使用活動新增表單同步功能,系統應略過同名活動,避免重複建立。',
'',
'公文:新北教技字第1151036018號。',
'研習名稱:「新北AI共創大聯盟」人工智慧增能研習—Google Workspace 行政專案工作流自動化實作。',
'課程綱要:https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
'研習主軸:從公文到成果,建立半自動行政專案管理工作流。',
'正式公告、研習時數、簽到與行政作業仍依主辦單位規定辦理。'
].join('\n');
activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) {
return activity[header] || '';
}));
appendNewTaipeiAiWorkshopSessions_(activityId);
updateDashboard_();
alert_([
'示範研習資料已新增完成。',
'',
'活動:' + activityName,
'活動ID:' + activityId,
'',
'已新增 1 筆活動資料與 4 筆場次設定。',
'請到「01_活動清單」選取這筆活動,再執行「從選取列建立活動資料包」。'
].join('\n'));
}
function appendNewTaipeiAiWorkshopSessions_(activityId) {
const ss = getActiveSpreadsheet_();
const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
if (getRowsByActivityId_(sheet, activityId).length > 0) {
return;
}
const sessions = [
{
sessionId: 'S01',
date: new Date(2026, 5, 15),
title: '第一場|從公文啟動專案',
topic: '以教育局公文導入教學研習計畫為情境,學習判斷公文是否值得建立專案,並建立專案資料夾、追蹤表與成果檢核文件。'
},
{
sessionId: 'S02',
date: new Date(2026, 5, 18),
title: '第二場|活動型專案管理',
topic: '以校外教學/親子活動辦理為案例,拆解活動流程、建立階段性日期、設計報名表與回饋表。'
},
{
sessionId: 'S03',
date: new Date(2026, 5, 22),
title: '第三場|成果型專案與AI文件整理',
topic: '以補助計畫/成果報告為案例,建立 NotebookLM 專案筆記本,並運用 Gemini 輔助公告、摘要與成果報告草稿。'
},
{
sessionId: 'S04',
date: new Date(2026, 5, 25),
title: '第四場|大型專案協作與制度化',
topic: '以運動會/校慶大型活動為案例,學習跨處室分工、大量日期與任務管理、成果檢核與制度化。'
}
];
const rows = sessions.map(function(session) {
return buildSessionSheetRow_({
'活動ID': activityId,
'場次ID': session.sessionId,
'場次日期': session.date,
'開始時間': '19:00',
'結束時間': '21:00',
'場次名稱': session.title,
'場次主題': session.topic,
'場次目標': 'Google Workspace、Apps Script、NotebookLM、Gemini 行政工作流實作',
'講師': '彭智標主任',
'助教或協作人員': '新北市立泰山高中協助簽到與研習時數行政作業',
'地點或線上連結': 'Google Meet:https://meet.google.com/usb-mjie-ett',
'本場需要設備': 'Google 帳號、可操作 Google Workspace 的電腦、近期實際行政專案案例',
'講義連結': 'https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
'報名人數': 0,
'簽到人數': 0,
'出席率': '0%',
'課前任務完成率': '0%',
'課後回饋數': 0,
'成果整理狀態': '未整理',
'風險等級': '低',
'目前狀態': '準備中',
'備註': '由新北研習公文建立之示範場次',
'任務完成率': '0%',
'任務完成狀態': '未開始'
});
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
}
function findActivityRowByIdOrName_(sheet, headerMap, activityId, activityName) {
for (let row = 2; row <= sheet.getLastRow(); row++) {
const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
if (existingId === activityId || existingName === activityName) {
return row;
}
}
return 0;
}
檔案 21
MenuFlows.gs|選單分組與一鍵流程入口
/**
* 營隊/活動管理中樞:選單流程入口與相容函式。
*/
function updateDashboard() {
updateDashboard_();
alert_('中控台已重新整理。');
}
function createOfficialRegistrationFormForSelectedActivity() {
return createRegistrationFormForSelectedActivity();
}
function syncCalendarStatusBackToTasks() {
return syncCalendarBackToTaskOverview();
}
function runOfficialActivitySetupFlow() {
const context = getSelectedActivityRowContext_();
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
generateSessionsForActivity_(context.activity);
updateActivitySessionFields_(context);
appendDefaultTaskRows_(context.activity);
appendDefaultNotificationRows_(context.activity);
appendDefaultOutcomeRows_(context.activity);
appendNotebookIndexRows_(context.activity);
refreshTaskOverview();
updateDeadlineStatuses();
updateDashboard_();
alert_([
'正式活動骨架已建立或確認完成。',
'',
'活動:' + context.activity.name,
'資料夾:' + packageContext.folder.getUrl(),
'',
'已確認:資料包、場次、任務、通知、成果與 NotebookLM 索引。'
].join('\n'));
}
function runDemoSessionAttendanceFlow() {
const context = getSelectedActivityRowContext_();
const packageContext = ensureActivityPackageForContext_(context);
context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
const sessions = generateSessionsForActivity_(context.activity);
updateActivitySessionFields_(context);
const attendanceRows = generateAttendanceRowsForActivity_(context.activity.id);
const formResult = createAttendanceFormsForActivityContext_(context);
updateSessionStats_();
updateDashboard_();
alert_([
'一鍵展示流程已完成。',
'',
'活動:' + context.activity.name,
'場次數:' + sessions.length,
'新增簽到預備資料:' + attendanceRows + ' 筆',
'新建立簽到表單:' + formResult.created + ' 張',
'已存在略過:' + formResult.skipped + ' 張',
'',
'資料包:' + packageContext.folder.getUrl()
].join('\n'));
}
function createOutcomeChecklistSheetForSelectedActivity() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const outcomeSheet = createOutcomeChecklistSheet_(activity, packageContext.subfolders['06_成果檢查與結案']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '檢查活動後成果、照片、附件與 NotebookLM 可用資料', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
updateDashboard_();
alert_('成果檢查表已建立:\n' + outcomeSheet.getUrl());
return outcomeSheet.getUrl();
}
function runOutcomeCloseoutFlow() {
const packageContext = ensureActivityPackageForSelectedActivity_();
const activity = packageContext.activity;
const rowContext = packageContext.rowContext;
const outcomeSheet = createOutcomeChecklistSheet_(activity, packageContext.subfolders['06_成果檢查與結案']);
const notebookContent = buildNotebookMarkdown_(activity);
const notebookDoc = createNotebookDoc_(activity, notebookContent, packageContext.subfolders['07_NotebookLM資料包']);
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, 'NotebookLM資料包連結', notebookDoc.getUrl());
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '目前狀態', '成果結案資料已整理');
writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '活動後成果、照片、附件與 NotebookLM 可用資料檢查', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
appendDocumentListRow_(activity, 'NotebookLM資料包', notebookDoc.getName(), '匯入 NotebookLM 的去識別化專案摘要', notebookDoc.getUrl(), '系統匯出', '草稿', '待確認', '匯入前請再次檢查個資');
refreshTaskOverview();
updateDashboard_();
alert_([
'成果與結案資料已整理。',
'',
'成果檢查表:' + outcomeSheet.getUrl(),
'NotebookLM 資料包:' + notebookDoc.getUrl()
].join('\n'));
}
七、驗收清單
- 重新整理 Sheet 後出現「營隊活動管理」選單。
- 初始化後建立 00 到 08 的工作表。
- 從活動清單選取活動列後,可建立 Drive 活動資料包。
- 可建立報名表單,表單連結回填活動清單。
- 可建立家長通知書、同意書、標案需求表、處室通知草稿、導師行前提醒與成果檢查表。
- 可同步報名資料並更新統計。
- 可更新期限狀態。
- 可寄測試信給承辦人。
- 可匯出 NotebookLM 資料包,且資料包提醒不要放入不必要個資。