From c3e51c100206e3c26f70209cad68484fa6f940ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:46:37 +0000 Subject: [PATCH 1/3] feat: organize Draft.md into structured Google Apps Script project with single-ID auto-setup Agent-Logs-Url: https://github.com/HugoWong528/Homework-system/sessions/4871c814-23e1-4d5e-ba8b-32b7f83165a2 Co-authored-by: HugoWong528 <267603037+HugoWong528@users.noreply.github.com> --- 01_CollectHomework/Code.gs | 186 +++++++++++++++++++ 02_AutoReturn/Code.gs | 193 ++++++++++++++++++++ 03_AutoShare/Code.gs | 156 ++++++++++++++++ 04_SubmissionRecord/Code.gs | 266 +++++++++++++++++++++++++++ 05_WebInterface/Code.gs | 227 +++++++++++++++++++++++ 05_WebInterface/Index.html | 176 ++++++++++++++++++ 05_WebInterface/homework.html | 331 ++++++++++++++++++++++++++++++++++ 05_WebInterface/record.html | 221 +++++++++++++++++++++++ 06_OverdueAssignments/Code.gs | 156 ++++++++++++++++ README.md | 230 +++++++++++++++++++++++ setup/Setup.gs | 196 ++++++++++++++++++++ 11 files changed, 2338 insertions(+) create mode 100644 01_CollectHomework/Code.gs create mode 100644 02_AutoReturn/Code.gs create mode 100644 03_AutoShare/Code.gs create mode 100644 04_SubmissionRecord/Code.gs create mode 100644 05_WebInterface/Code.gs create mode 100644 05_WebInterface/Index.html create mode 100644 05_WebInterface/homework.html create mode 100644 05_WebInterface/record.html create mode 100644 06_OverdueAssignments/Code.gs create mode 100644 README.md create mode 100644 setup/Setup.gs diff --git a/01_CollectHomework/Code.gs b/01_CollectHomework/Code.gs new file mode 100644 index 0000000..ce060f8 --- /dev/null +++ b/01_CollectHomework/Code.gs @@ -0,0 +1,186 @@ +/** + * 「帙雲」01 - 收集功課 + * + * 功用:將「01_學生上傳區」的檔案移至「02_待批改課業」,並自動歸類。 + * 方法:提取檔案名稱中的班別(如 1C、4A)及子文件夾以「【】」括起的關鍵詞,並作配對。 + * + * 觸發器:sortStudentAssignments,每 1 分鐘觸發一次。 + * 執行 createTrigger() 可自動建立觸發器。 + */ + +// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 + * @returns {{UPLOAD_FOLDER_ID: string, PENDING_FOLDER_ID: string}} + */ +function getConfig() { + const props = PropertiesService.getScriptProperties(); + let config = props.getProperties(); + + if (!config.UPLOAD_FOLDER_ID || !config.PENDING_FOLDER_ID) { + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + config.UPLOAD_FOLDER_ID = getOrCreateFolder(root, '01_學生上傳區').getId(); + config.PENDING_FOLDER_ID = getOrCreateFolder(root, '02_待批改課業').getId(); + props.setProperties({ + UPLOAD_FOLDER_ID: config.UPLOAD_FOLDER_ID, + PENDING_FOLDER_ID: config.PENDING_FOLDER_ID + }); + Logger.log('✅ 已自動探索並儲存文件夾 ID。'); + } + + return config; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 將「01_學生上傳區」中的檔案,依班別及【關鍵詞】歸類至「02_待批改課業」。 + * 支援的檔案格式:PDF、JPEG、PNG、GIF、BMP、WEBP。 + */ +function sortStudentAssignments() { + const config = getConfig(); + const sourceFolderId = config.UPLOAD_FOLDER_ID; // 01_學生上傳區 + const targetFolderId = config.PENDING_FOLDER_ID; // 02_待批改課業 + + const supportedMimeTypes = [ + MimeType.PDF, + MimeType.JPEG, + MimeType.PNG, + MimeType.GIF, + MimeType.BMP, + MimeType.WEBP + ]; + + // 取得目標文件夾下所有班別的文件夾結構(支援多層子文件夾) + const classFolders = getClassFoldersRecursive(targetFolderId); + + const sourceFolder = DriveApp.getFolderById(sourceFolderId); + const allFiles = sourceFolder.getFiles(); + + while (allFiles.hasNext()) { + const file = allFiles.next(); + const fileName = file.getName(); + const fileMimeType = file.getMimeType(); + + // 過濾不支援的檔案格式 + if (!supportedMimeTypes.includes(fileMimeType)) { + Logger.log('跳過不支援的檔案格式: ' + fileName); + continue; + } + + // 提取班別資訊(配對如 1C、4A 等格式) + const classMatch = fileName.match(/(\d+[A-Z])/); + if (!classMatch) { + Logger.log('跳過無班別資訊的檔案: ' + fileName); + continue; + } + const className = classMatch[0]; + const classInfo = classFolders[className]; + if (!classInfo) { + Logger.log('未找到班別文件夾: ' + className); + continue; + } + + let targetSubfolderId = null; + // 查找配對【關鍵詞】的子文件夾 + for (const keyword in classInfo.keywordFolders) { + if (Object.prototype.hasOwnProperty.call(classInfo.keywordFolders, keyword) && + fileName.includes(keyword)) { + targetSubfolderId = classInfo.keywordFolders[keyword]; + break; + } + } + + // 若沒有配對的關鍵詞,使用班別根文件夾 + if (!targetSubfolderId) { + targetSubfolderId = classInfo.rootFolderId; + } + + try { + const targetFolder = DriveApp.getFolderById(targetSubfolderId); + file.moveTo(targetFolder); + Logger.log('成功移動檔案: ' + fileName + ' → ' + targetFolder.getName()); + } catch (e) { + Logger.log('移動檔案失敗: ' + fileName + ' - ' + e.message); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 遞迴取得 parentFolderId 下所有班別文件夾及其【關鍵詞】子文件夾。 + * @param {string} parentFolderId + * @returns {Object} 班別名稱 → { rootFolderId, keywordFolders } + */ +function getClassFoldersRecursive(parentFolderId) { + const parentFolder = DriveApp.getFolderById(parentFolderId); + const classFolderIter = parentFolder.getFolders(); + const result = {}; + + while (classFolderIter.hasNext()) { + const classFolder = classFolderIter.next(); + const className = classFolder.getName(); + const keywordFolders = {}; + collectKeywordsRecursive(classFolder, keywordFolders); + + result[className] = { + rootFolderId: classFolder.getId(), + keywordFolders: keywordFolders + }; + } + + return result; +} + +/** + * 遞迴收集 folder 下所有【關鍵詞】格式子文件夾的關鍵詞與 ID。 + */ +function collectKeywordsRecursive(folder, keywordFolders) { + const subfolders = folder.getFolders(); + while (subfolders.hasNext()) { + const subfolder = subfolders.next(); + const keywordMatch = subfolder.getName().match(/【(.*?)】/); + if (keywordMatch) { + keywordFolders[keywordMatch[1]] = subfolder.getId(); + } + collectKeywordsRecursive(subfolder, keywordFolders); + } +} + +/** + * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 + */ +function getOrCreateFolder(parent, name) { + const iter = parent.getFoldersByName(name); + if (iter.hasNext()) return iter.next(); + return parent.createFolder(name); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 觸發器設置 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 建立每 1 分鐘觸發一次 sortStudentAssignments 的時間觸發器。 + * 執行前會先刪除舊有的同名觸發器,避免重複。 + */ +function createTrigger() { + ScriptApp.getProjectTriggers().forEach(function(trigger) { + if (trigger.getHandlerFunction() === 'sortStudentAssignments') { + ScriptApp.deleteTrigger(trigger); + } + }); + ScriptApp.newTrigger('sortStudentAssignments') + .timeBased() + .everyMinutes(1) + .create(); + Logger.log('✅ 已建立觸發器:sortStudentAssignments,每 1 分鐘觸發一次。'); +} diff --git a/02_AutoReturn/Code.gs b/02_AutoReturn/Code.gs new file mode 100644 index 0000000..be75d09 --- /dev/null +++ b/02_AutoReturn/Code.gs @@ -0,0 +1,193 @@ +/** + * 「帙雲」02 - 自動發還課業 + * + * 功用:將老師批改完畢、放在「03_老師回饋區」的檔案,自動歸類至「04_已發還課業」。 + * 方法:提取檔案名稱中的【班別】、【姓名】及課業【關鍵詞】,並配對至對應的學生文件夾。 + * + * 觸發器:distributeHomework,每 15 分鐘觸發一次。 + * 執行 createTrigger() 可自動建立觸發器。 + */ + +// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 + */ +function getConfig() { + const props = PropertiesService.getScriptProperties(); + let config = props.getProperties(); + + if (!config.TEACHER_RETURN_FOLDER_ID || !config.RETURNED_FOLDER_ID) { + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + config.TEACHER_RETURN_FOLDER_ID = getOrCreateFolder(root, '03_老師回饋區').getId(); + config.RETURNED_FOLDER_ID = getOrCreateFolder(root, '04_已發還課業').getId(); + props.setProperties({ + TEACHER_RETURN_FOLDER_ID: config.TEACHER_RETURN_FOLDER_ID, + RETURNED_FOLDER_ID: config.RETURNED_FOLDER_ID + }); + Logger.log('✅ 已自動探索並儲存文件夾 ID。'); + } + + return config; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 將「03_老師回饋區」中的批改課業,自動歸類至「04_已發還課業」對應的學生文件夾。 + * + * 「04_已發還課業」的文件夾結構: + * 04_已發還課業/ + * 【1C】/ + * 【陳大文】/ + * 寫作(長文)/ + * 《藏在泥土的【寶物】》/ + * 閱讀/ + * 寫作(實用文)/ + */ +function distributeHomework() { + const config = getConfig(); + const uploadFolderId = config.TEACHER_RETURN_FOLDER_ID; // 03_老師回饋區 + const returnFolderId = config.RETURNED_FOLDER_ID; // 04_已發還課業 + + const uploadFolder = DriveApp.getFolderById(uploadFolderId); + const returnFolder = DriveApp.getFolderById(returnFolderId); + + // 步驟 1:獲取班別資料夾並建立映射(鍵:1C, 4A, ...) + const classFolderIter = returnFolder.getFolders(); + const classMap = {}; + while (classFolderIter.hasNext()) { + const classFolder = classFolderIter.next(); + const className = classFolder.getName(); // 例如 "【1C】" + const classKey = className.replace(/【|】/g, ''); // 提取 "1C" + classMap[classKey] = classFolder; + } + + // 步驟 2:獲取學生資料夾並建立映射(鍵:classKey_studentKey) + const studentMap = {}; + for (const classKey in classMap) { + const classFolder = classMap[classKey]; + const studentFolderIter = classFolder.getFolders(); + while (studentFolderIter.hasNext()) { + const studentFolder = studentFolderIter.next(); + const studentName = studentFolder.getName(); // 例如 "【陳大文】" + const studentKey = studentName.replace(/【|】/g, ''); // 提取 "陳大文" + if (!studentMap[classKey]) studentMap[classKey] = {}; + studentMap[classKey][studentKey] = studentFolder; + } + } + + // 步驟 3:在每位學生的「寫作(長文)」子文件夾下,提取課業關鍵詞 + const assignmentMap = {}; + for (const classKey in studentMap) { + for (const studentKey in studentMap[classKey]) { + const studentFolder = studentMap[classKey][studentKey]; + const writingFolderIter = studentFolder.getFoldersByName('寫作(長文)'); + if (writingFolderIter.hasNext()) { + const writingFolder = writingFolderIter.next(); + const assignmentFolderIter = writingFolder.getFolders(); + while (assignmentFolderIter.hasNext()) { + const assignmentFolder = assignmentFolderIter.next(); + const match = assignmentFolder.getName().match(/【(.*?)】/); + if (match) { + const keyword = match[1]; + assignmentMap[classKey + '_' + studentKey + '_' + keyword] = assignmentFolder; + } + } + } + } + } + + // 步驟 4:處理上傳資料夾中的所有檔案 + const files = uploadFolder.getFiles(); + while (files.hasNext()) { + const file = files.next(); + const fileName = file.getName(); + + // 提取班別 + let fileClassKey = null; + for (const ck in classMap) { + if (fileName.indexOf(ck) !== -1) { + fileClassKey = ck; + break; + } + } + + // 提取姓名 + let fileStudentKey = null; + if (fileClassKey && studentMap[fileClassKey]) { + for (const sk in studentMap[fileClassKey]) { + if (fileName.indexOf(sk) !== -1) { + fileStudentKey = sk; + break; + } + } + } + + // 提取課業關鍵詞 + let assignmentKeyword = null; + if (fileClassKey && fileStudentKey) { + for (const key in assignmentMap) { + const parts = key.split('_'); + if (parts[0] === fileClassKey && parts[1] === fileStudentKey) { + const keyword = parts[2]; + if (fileName.indexOf(keyword) !== -1) { + assignmentKeyword = keyword; + break; + } + } + } + } + + // 步驟 5:根據匹配情況移動檔案 + if (fileClassKey && fileStudentKey && assignmentKeyword) { + // 完整匹配:移動到課業資料夾 + const targetFolder = assignmentMap[fileClassKey + '_' + fileStudentKey + '_' + assignmentKeyword]; + file.moveTo(targetFolder); + } else if (fileClassKey && fileStudentKey) { + // 缺少課業名稱:移動到學生資料夾 + file.moveTo(studentMap[fileClassKey][fileStudentKey]); + } else if (fileClassKey) { + // 只有班別:移動到班別資料夾 + file.moveTo(classMap[fileClassKey]); + } + // 完全無法匹配,留在原地 + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 + */ +function getOrCreateFolder(parent, name) { + const iter = parent.getFoldersByName(name); + if (iter.hasNext()) return iter.next(); + return parent.createFolder(name); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 觸發器設置 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 建立每 15 分鐘觸發一次 distributeHomework 的時間觸發器。 + */ +function createTrigger() { + ScriptApp.getProjectTriggers().forEach(function(trigger) { + if (trigger.getHandlerFunction() === 'distributeHomework') { + ScriptApp.deleteTrigger(trigger); + } + }); + ScriptApp.newTrigger('distributeHomework') + .timeBased() + .everyMinutes(15) + .create(); + Logger.log('✅ 已建立觸發器:distributeHomework,每 15 分鐘觸發一次。'); +} diff --git a/03_AutoShare/Code.gs b/03_AutoShare/Code.gs new file mode 100644 index 0000000..51390dc --- /dev/null +++ b/03_AutoShare/Code.gs @@ -0,0 +1,156 @@ +/** + * 「帙雲」03 - 自動共用、收集位址 + * + * 功用:將「04_已發還課業」中每位學生的專屬文件夾共用給學生, + * 並將文件夾位址記錄至「自動共用、收集位址」試算表的 C 欄, + * 方便批量分發給學生。 + * + * 方法:在試算表中手動輸入學號(A 欄)及學生姓名(B 欄), + * 然後手動執行 shareAllClasses() 或針對特定班別執行 shareFoldersForClass()。 + * + * 觸發器:不設觸發器,手動執行。 + * + * 試算表格式: + * A1=學號, B1=姓名, C1=文件夾位址 + * A2 起:實際學號, B2 起:實際姓名, C2 起(自動填入):文件夾 URL + */ + +// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 +// 學生 Google 帳號的電郵域名(學生 Drive 帳號) +const SCHOOL_EMAIL_DOMAIN = 'ccckyc.edu.hk'; // ← 按學校實際域名修改 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 + */ +function getConfig() { + const props = PropertiesService.getScriptProperties(); + let config = props.getProperties(); + + if (!config.RETURNED_FOLDER_ID || !config.SHARE_SHEET_ID) { + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + config.RETURNED_FOLDER_ID = getOrCreateFolder(root, '04_已發還課業').getId(); + + // 搜尋試算表 + const ssIter = root.getFilesByName('自動共用、收集位址'); + if (ssIter.hasNext()) { + config.SHARE_SHEET_ID = ssIter.next().getId(); + } else { + throw new Error('找不到「自動共用、收集位址」試算表,請先執行 setup/Setup.gs 中的 setup()。'); + } + + props.setProperties({ + RETURNED_FOLDER_ID: config.RETURNED_FOLDER_ID, + SHARE_SHEET_ID: config.SHARE_SHEET_ID + }); + Logger.log('✅ 已自動探索並儲存資源 ID。'); + } + + return config; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 針對試算表中所有班別,共用學生專屬文件夾並收集位址。 + * 試算表中每個分頁(Sheet)對應一個班別,分頁名稱即班別名稱(如 1C)。 + */ +function shareAllClasses() { + const config = getConfig(); + const spreadsheet = SpreadsheetApp.openById(config.SHARE_SHEET_ID); + const returnedFolder = DriveApp.getFolderById(config.RETURNED_FOLDER_ID); + + // 取得「04_已發還課業」下所有班別文件夾 + const classFolderIter = returnedFolder.getFolders(); + while (classFolderIter.hasNext()) { + const classFolder = classFolderIter.next(); + const classKey = classFolder.getName().replace(/【|】/g, ''); // 去除【】 + Logger.log('處理班別:' + classKey); + shareFoldersForClass(classKey, classFolder, spreadsheet); + } +} + +/** + * 針對特定班別,共用學生專屬文件夾並收集位址。 + * + * @param {string} className 班別名稱,如 "1C" + * @param {GoogleAppsScript.Drive.Folder} [classFolderOverride] 可選,直接傳入班別文件夾 + * @param {GoogleAppsScript.Spreadsheet.Spreadsheet} [spreadsheetOverride] 可選,直接傳入試算表 + */ +function shareFoldersForClass(className, classFolderOverride, spreadsheetOverride) { + const config = getConfig(); + const spreadsheet = spreadsheetOverride || SpreadsheetApp.openById(config.SHARE_SHEET_ID); + const returnedFolder = DriveApp.getFolderById(config.RETURNED_FOLDER_ID); + + // 找到對應的班別文件夾(名稱格式為「【1C】」) + let classFolder = classFolderOverride; + if (!classFolder) { + const iter = returnedFolder.getFoldersByName('【' + className + '】'); + if (!iter.hasNext()) { + throw new Error('找不到班別文件夾:【' + className + '】'); + } + classFolder = iter.next(); + } + + // 取得或建立試算表分頁 + let sheet = spreadsheet.getSheetByName(className); + if (!sheet) { + sheet = spreadsheet.insertSheet(className); + sheet.getRange('A1:C1').setValues([['學號', '姓名', '文件夾位址']]); + sheet.getRange('A1:C1').setFontWeight('bold'); + } + + // 獲取學號及姓名(A2:B 往下) + const lastRow = sheet.getLastRow(); + if (lastRow < 2) { + Logger.log('班別 ' + className + ' 的試算表中尚未輸入學生資料,跳過。'); + return; + } + const values = sheet.getRange('A2:B' + lastRow).getValues(); + const students = values.filter(function(row) { return row[0] && row[1]; }); + + // 建立姓名→文件夾映射 + const folderMap = {}; + const studentFolderIter = classFolder.getFolders(); + while (studentFolderIter.hasNext()) { + const folder = studentFolderIter.next(); + const match = folder.getName().match(/【(.*?)】/); + if (match) folderMap[match[1]] = folder; + } + + // 共用並填入 URL + students.forEach(function(student, index) { + const studentId = student[0]; + const studentName = student[1]; + const email = studentId + '@' + SCHOOL_EMAIL_DOMAIN; + const folder = folderMap[studentName]; + + if (folder) { + try { + folder.addEditor(email); + sheet.getRange(index + 2, 3).setValue(folder.getUrl()); + Logger.log('已共用文件夾給 ' + studentName + ' (' + email + ')'); + } catch (e) { + Logger.log('共用失敗:' + studentName + ' - ' + e.message); + } + } else { + Logger.log('找不到學生文件夾:' + studentName); + } + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 + */ +function getOrCreateFolder(parent, name) { + const iter = parent.getFoldersByName(name); + if (iter.hasNext()) return iter.next(); + return parent.createFolder(name); +} diff --git a/04_SubmissionRecord/Code.gs b/04_SubmissionRecord/Code.gs new file mode 100644 index 0000000..cc7fb08 --- /dev/null +++ b/04_SubmissionRecord/Code.gs @@ -0,0 +1,266 @@ +/** + * 「帙雲」04 - 繳交紀錄及課業佈置 + * + * 功用: + * 1. 自動生成「02_待批改課業」及「04_已發還課業」的分層文件夾結構。 + * 2. 實時追蹤學生繳交課業的狀態(已繳交 / 未繳交 / 遲交)。 + * + * 試算表格式(每個分頁對應一個班別): + * A1 = 班別名稱(如 1C) + * A2 = "created"(文件夾建立後自動填入,請勿更改) + * B1, C1... = 課業名稱,格式:「寫作(長文)」藏在泥土的【寶物】 + * B2, C2... = 截止日期,格式:2025-04-29 23:59 + * B3, C3... = 課業文件夾 ID(自動填入) + * A4 以下 = 學生姓名 + * B4 以下 = 繳交狀態(自動更新:已繳交 / 未繳交 / 遲交) + * + * 觸發器:createFoldersAndUpdateSheet,每 5 分鐘觸發一次。 + * 執行 createTrigger() 可自動建立觸發器。 + */ + +// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 +// ───────────────────────────────────────────────────────────────────────────── + +// 全局文件夾緩存(減少 API 呼叫次數) +const folderCache = {}; + +/** + * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 + */ +function getConfig() { + const props = PropertiesService.getScriptProperties(); + let config = props.getProperties(); + + if (!config.PENDING_FOLDER_ID || !config.RETURNED_FOLDER_ID || !config.SUBMISSION_SHEET_ID) { + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + config.PENDING_FOLDER_ID = getOrCreateFolder(root, '02_待批改課業').getId(); + config.RETURNED_FOLDER_ID = getOrCreateFolder(root, '04_已發還課業').getId(); + + const ssIter = root.getFilesByName('繳交紀錄及課業佈置'); + if (ssIter.hasNext()) { + config.SUBMISSION_SHEET_ID = ssIter.next().getId(); + } else { + throw new Error('找不到「繳交紀錄及課業佈置」試算表,請先執行 setup/Setup.gs 中的 setup()。'); + } + + props.setProperties({ + PENDING_FOLDER_ID: config.PENDING_FOLDER_ID, + RETURNED_FOLDER_ID: config.RETURNED_FOLDER_ID, + SUBMISSION_SHEET_ID: config.SUBMISSION_SHEET_ID + }); + Logger.log('✅ 已自動探索並儲存資源 ID。'); + } + + return config; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 主函數:建立分層文件夾(如未建立)並更新繳交狀態。 + * 每 5 分鐘由觸發器自動執行。 + */ +function createFoldersAndUpdateSheet() { + const config = getConfig(); + const spreadsheet = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + const sheets = spreadsheet.getSheets(); + + const pendingFolderId = config.PENDING_FOLDER_ID; // 02_待批改課業 + const returnedFolderId = config.RETURNED_FOLDER_ID; // 04_已發還課業 + + sheets.forEach(function(sheet) { + const className = sheet.getRange('A1').getValue().toString().trim(); + if (!className) return; // 跳過沒有班別名稱的分頁 + + // 檢查是否已建立文件夾 + const folderCreated = sheet.getRange('A2').getValue(); + if (folderCreated !== 'created') { + // 建立「02_待批改課業」的班別及課業文件夾 + const classFolder = createFolderIfNotExists(pendingFolderId, className); + const categoryFolders = { + '閱讀': createFolderIfNotExists(classFolder.getId(), '閱讀'), + '寫作(長文)': createFolderIfNotExists(classFolder.getId(), '寫作(長文)'), + '寫作(實用文)': createFolderIfNotExists(classFolder.getId(), '寫作(實用文)') + }; + + const lastColumn = sheet.getLastColumn(); + if (lastColumn >= 2) { + const homeworkValues = sheet.getRange(1, 2, 2, lastColumn - 1).getValues(); + const homeworkNames = homeworkValues[0]; // B1, C1, ... + homeworkNames.forEach(function(name, index) { + if (!name) return; + const categoryMatch = name.match(/「(.*?)」/); + if (!categoryMatch) return; + const category = categoryMatch[1]; + const homeworkTitle = name.replace(/「.*?」/, '').trim(); + const categoryFolder = categoryFolders[category]; + if (categoryFolder) { + const homeworkFolder = createFolderIfNotExists(categoryFolder.getId(), homeworkTitle); + sheet.getRange(3, 2 + index).setValue(homeworkFolder.getId()); + } + }); + } + + // 建立「04_已發還課業」的班別→學生→課業類別文件夾 + const returnedClassFolder = createFolderIfNotExists(returnedFolderId, '【' + className + '】'); + const studentNames = sheet.getRange('A4:A' + sheet.getLastRow()).getValues() + .flat().filter(String); + studentNames.forEach(function(student) { + const studentFolder = createFolderIfNotExists(returnedClassFolder.getId(), '【' + student + '】'); + ['閱讀', '寫作(長文)', '寫作(實用文)'].forEach(function(category) { + createFolderIfNotExists(studentFolder.getId(), category); + }); + }); + + // 標記文件夾已建立 + sheet.getRange('A2').setValue('created'); + } + + // 更新繳交狀態 + const lastColumn = sheet.getLastColumn(); + if (lastColumn >= 2) { + const homeworkValues = sheet.getRange(1, 2, 2, lastColumn - 1).getValues(); + const homeworkNames = homeworkValues[0]; + const deadlines = homeworkValues[1]; + const studentNames = sheet.getRange('A4:A' + sheet.getLastRow()).getValues() + .flat().filter(String); + updateSubmissionStatus(sheet, studentNames, homeworkNames, deadlines); + } + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 在 parentId 文件夾下取得或建立名為 folderName 的子文件夾(冪等,含緩存)。 + * @param {string} parentId + * @param {string} folderName + * @returns {GoogleAppsScript.Drive.Folder} + */ +function createFolderIfNotExists(parentId, folderName) { + const key = parentId + '_' + folderName; + if (folderCache[key]) { + return DriveApp.getFolderById(folderCache[key]); + } + + const parentFolder = DriveApp.getFolderById(parentId); + const iter = parentFolder.getFoldersByName(folderName); + if (iter.hasNext()) { + const folder = iter.next(); + folderCache[key] = folder.getId(); + return folder; + } + + const newFolder = parentFolder.createFolder(folderName); + folderCache[key] = newFolder.getId(); + return newFolder; +} + +/** + * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 + */ +function getOrCreateFolder(parent, name) { + const iter = parent.getFoldersByName(name); + if (iter.hasNext()) return iter.next(); + return parent.createFolder(name); +} + +/** + * 批次更新試算表中的繳交狀態。 + * 比對「02_待批改課業」中對應課業文件夾的檔案與學生姓名: + * - 已繳交(綠色):檔案名稱包含學生姓名且在截止日期前 + * - 遲交(黃色): 檔案名稱包含學生姓名但在截止日期後 + * - 未繳交(紅色):未找到對應檔案 + */ +function updateSubmissionStatus(sheet, studentNames, homeworkNames, deadlines) { + const homeworkFolderIds = sheet.getRange(3, 2, 1, homeworkNames.length).getValues()[0]; + + // 一次性搜索所有相關文件並緩存 + const fileMap = {}; + homeworkFolderIds.forEach(function(folderId) { + if (!folderId) return; + const files = DriveApp.getFolderById(folderId).getFiles(); + fileMap[folderId] = []; + while (files.hasNext()) { + fileMap[folderId].push(files.next()); + } + }); + + const numRows = studentNames.length; + const numCols = homeworkNames.length; + const values = []; + const backgrounds = []; + + studentNames.forEach(function(student) { + const rowValues = []; + const rowBackgrounds = []; + homeworkFolderIds.forEach(function(folderId, colIndex) { + if (!folderId || !homeworkNames[colIndex]) { + rowValues.push(''); + rowBackgrounds.push('white'); + return; + } + + const files = fileMap[folderId] || []; + let submitted = false; + let late = false; + + for (let i = 0; i < files.length; i++) { + if (files[i].getName().includes(student)) { + submitted = true; + const uploadTime = files[i].getDateCreated(); + const deadline = new Date(deadlines[colIndex]); + if (uploadTime > deadline) late = true; + break; + } + } + + if (submitted) { + if (late) { + rowValues.push('遲交'); + rowBackgrounds.push('yellow'); + } else { + rowValues.push('已繳交'); + rowBackgrounds.push('green'); + } + } else { + rowValues.push('未繳交'); + rowBackgrounds.push('red'); + } + }); + values.push(rowValues); + backgrounds.push(rowBackgrounds); + }); + + // 一次性寫入值和背景色 + if (numRows > 0 && numCols > 0) { + const range = sheet.getRange(4, 2, numRows, numCols); + range.setValues(values); + range.setBackgrounds(backgrounds); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 觸發器設置 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 建立每 5 分鐘觸發一次 createFoldersAndUpdateSheet 的時間觸發器。 + */ +function createTrigger() { + ScriptApp.getProjectTriggers().forEach(function(trigger) { + if (trigger.getHandlerFunction() === 'createFoldersAndUpdateSheet') { + ScriptApp.deleteTrigger(trigger); + } + }); + ScriptApp.newTrigger('createFoldersAndUpdateSheet') + .timeBased() + .everyMinutes(5) + .create(); + Logger.log('✅ 已建立觸發器:createFoldersAndUpdateSheet,每 5 分鐘觸發一次。'); +} diff --git a/05_WebInterface/Code.gs b/05_WebInterface/Code.gs new file mode 100644 index 0000000..aa175d3 --- /dev/null +++ b/05_WebInterface/Code.gs @@ -0,0 +1,227 @@ +/** + * 「帙雲」05 - 繳交紀錄及課業佈置介面(Web App) + * + * 功用:建立網頁介面,連結至各文件夾,供老師查閱繳交紀錄及佈置課業。 + * 部署:以 Web App 方式部署(Deploy → New deployment → Web app)。 + * + * 頁面: + * / → Index.html 控制面板 + * ?page=record → record.html 繳交紀錄 + * ?page=homework → homework.html 佈置課業 + */ + +// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 + */ +function getConfig() { + const props = PropertiesService.getScriptProperties(); + let config = props.getProperties(); + + if (!config.PENDING_FOLDER_ID || !config.SUBMISSION_SHEET_ID) { + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + config.UPLOAD_FOLDER_ID = getOrCreateFolder(root, '01_學生上傳區').getId(); + config.PENDING_FOLDER_ID = getOrCreateFolder(root, '02_待批改課業').getId(); + config.TEACHER_RETURN_FOLDER_ID = getOrCreateFolder(root, '03_老師回饋區').getId(); + config.RETURNED_FOLDER_ID = getOrCreateFolder(root, '04_已發還課業').getId(); + + const shareIter = root.getFilesByName('自動共用、收集位址'); + config.SHARE_SHEET_ID = shareIter.hasNext() ? shareIter.next().getId() : ''; + + const subIter = root.getFilesByName('繳交紀錄及課業佈置'); + if (subIter.hasNext()) { + config.SUBMISSION_SHEET_ID = subIter.next().getId(); + } else { + throw new Error('找不到「繳交紀錄及課業佈置」試算表,請先執行 setup/Setup.gs 中的 setup()。'); + } + + props.setProperties(config); + Logger.log('✅ 已自動探索並儲存資源 ID。'); + } + + return config; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Web App 入口 +// ───────────────────────────────────────────────────────────────────────────── + +function doGet(e) { + const page = e.parameter.page; + + if (page === 'record') { + const template = HtmlService.createTemplateFromFile('record'); + template.classData = getClassData(); + return template.evaluate().setTitle('作業繳交紀錄查閱'); + } + + if (page === 'homework') { + const template = HtmlService.createTemplateFromFile('homework'); + return template.evaluate().setTitle('布置課業'); + } + + // 預設:控制面板 + const template = HtmlService.createTemplateFromFile('Index'); + template.classData = getClassData(); + template.folderUrls = getFolderUrls(); + return template.evaluate().setTitle('帙雲 - 控制面板'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 供 HTML 頁面呼叫的函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 獲取所有文件夾及試算表的 URL,供 Index.html 動態注入連結。 + * @returns {Object} + */ +function getFolderUrls() { + const config = getConfig(); + return { + UPLOAD_URL: 'https://drive.google.com/drive/folders/' + config.UPLOAD_FOLDER_ID, + PENDING_URL: 'https://drive.google.com/drive/folders/' + config.PENDING_FOLDER_ID, + TEACHER_RETURN_URL: 'https://drive.google.com/drive/folders/' + config.TEACHER_RETURN_FOLDER_ID, + RETURNED_URL: 'https://drive.google.com/drive/folders/' + config.RETURNED_FOLDER_ID, + SHARE_SHEET_URL: config.SHARE_SHEET_ID + ? 'https://docs.google.com/spreadsheets/d/' + config.SHARE_SHEET_ID + : '#', + SUBMISSION_SHEET_URL: 'https://docs.google.com/spreadsheets/d/' + config.SUBMISSION_SHEET_ID + }; +} + +/** + * 獲取試算表資料(用於 homework.html 的班別選單及現有課業列表)。 + * @returns {{classes: string[], homeworks: Object}} + */ +function getSpreadsheetData() { + const config = getConfig(); + const spreadsheet = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + const sheets = spreadsheet.getSheets(); + + const data = { classes: [], homeworks: {} }; + + sheets.forEach(function(sheet) { + const className = sheet.getRange('A1').getValue().toString().trim(); + if (!className) return; + + data.classes.push(className); + + const lastColumn = sheet.getLastColumn(); + let homeworkNames = []; + let deadlines = []; + + if (lastColumn >= 2) { + const values = sheet.getRange(1, 2, 2, lastColumn - 1).getValues(); + homeworkNames = values[0].filter(String); + deadlines = values[1].map(function(d) { return d.toString(); }); + } + + data.homeworks[className] = { names: homeworkNames, deadlines: deadlines }; + }); + + return data; +} + +/** + * 將新課業寫入試算表(由 homework.html 呼叫)。 + * @param {string} className 班別 + * @param {string} homeworkName 課業名稱(含類別及關鍵詞) + * @param {string} deadline 截止日期,格式:2025-04-29 23:59 + */ +function updateSpreadsheet(className, homeworkName, deadline) { + const config = getConfig(); + const spreadsheet = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + const sheets = spreadsheet.getSheets(); + + const sheet = sheets.find(function(s) { + return s.getRange('A1').getValue().toString().trim() === className; + }); + if (!sheet) throw new Error('找不到指定的班別:' + className); + + // 找到下一個可用欄位 + const row1Values = sheet.getRange(1, 1, 1, sheet.getMaxColumns()).getValues()[0]; + let nextColumn = 2; + for (let col = 1; col < row1Values.length; col++) { + if (!row1Values[col]) { + nextColumn = col + 1; + break; + } + if (col === row1Values.length - 1) { + nextColumn = row1Values.length + 1; + } + } + + sheet.getRange(1, nextColumn).setValue(homeworkName); + sheet.getRange(2, nextColumn).setValue(deadline); +} + +/** + * 獲取所有班別的繳交紀錄資料(用於 record.html)。 + * @returns {Array} + */ +function getClassData() { + const config = getConfig(); + const spreadsheet = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + const sheets = spreadsheet.getSheets(); + const classData = []; + + sheets.forEach(function(sheet) { + const className = sheet.getRange('A1').getValue().toString().trim(); + if (!className) return; + + const lastColumn = sheet.getLastColumn(); + const homeworkNames = lastColumn >= 2 + ? sheet.getRange(1, 2, 1, lastColumn - 1).getValues()[0] + : []; + const deadlines = lastColumn >= 2 + ? sheet.getRange(2, 2, 1, lastColumn - 1).getValues()[0].map(function(date) { + if (date instanceof Date) { + return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'); + } + return date.toString(); + }) + : []; + const studentNames = sheet.getRange('A4:A' + sheet.getLastRow()).getValues() + .flat().filter(String); + + const homeworkData = homeworkNames.map(function(name, index) { + return { + name: name, + deadline: deadlines[index], + folderId: sheet.getRange(3, 2 + index).getValue() + }; + }); + + const students = studentNames.map(function(student, rowIndex) { + const submissions = homeworkData.map(function(hw, colIndex) { + const cell = sheet.getRange(4 + rowIndex, 2 + colIndex); + return { homework: hw.name, color: cell.getBackground() }; + }); + return { name: student, submissions: submissions }; + }); + + classData.push({ + className: className, + homework: homeworkData, + students: students + }); + }); + + return classData; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 + */ +function getOrCreateFolder(parent, name) { + const iter = parent.getFoldersByName(name); + if (iter.hasNext()) return iter.next(); + return parent.createFolder(name); +} diff --git a/05_WebInterface/Index.html b/05_WebInterface/Index.html new file mode 100644 index 0000000..cc25102 --- /dev/null +++ b/05_WebInterface/Index.html @@ -0,0 +1,176 @@ + + + + + + 帙雲 - 控制面板 + + + + + +
+
+

帙雲

+
+
+

課業及回饋

+ +
+
+

應用操作

+ +
+
+

後臺設置

+ +
+
+ + diff --git a/05_WebInterface/homework.html b/05_WebInterface/homework.html new file mode 100644 index 0000000..b32cec3 --- /dev/null +++ b/05_WebInterface/homework.html @@ -0,0 +1,331 @@ + + + + + + 布置課業 + + + + +
+

布置課業

+
+
+
+ + + + + + + + + + + + + + +
+ + + +
+
+
+ + + + + + + diff --git a/05_WebInterface/record.html b/05_WebInterface/record.html new file mode 100644 index 0000000..affdb48 --- /dev/null +++ b/05_WebInterface/record.html @@ -0,0 +1,221 @@ + + + + + + 作業繳交紀錄查閱 + + + + +
+

作業繳交紀錄

+
+
+
+ + + +
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + +
學生姓名 +
+
+ +
+
+
+
+ +
+
+ + + diff --git a/06_OverdueAssignments/Code.gs b/06_OverdueAssignments/Code.gs new file mode 100644 index 0000000..d079961 --- /dev/null +++ b/06_OverdueAssignments/Code.gs @@ -0,0 +1,156 @@ +/** + * 「帙雲」06 - OverdueAssignments + * + * 功用:整理逾期未交課業名單,供 Microsoft Power Automate 自動追收功課使用。 + * 方法:在「繳交紀錄及課業佈置」找出逾期未交的學生, + * 對照「自動共用、收集位址」試算表取得學生的 Teams 電郵, + * 將結果寫入「OverdueAssignments」試算表的「Overdue Assignments」分頁。 + * + * 觸發器:generateOverdueAssignments,每 1 分鐘觸發一次。 + * 執行 createTrigger() 可自動建立觸發器。 + * + * ⚠️ 此腳本應綁定至「OverdueAssignments」試算表(Container-bound), + * 或以獨立腳本(Standalone)方式運行(已支援兩種方式)。 + */ + +// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 +// 學生 Microsoft Teams 電郵域名(格式:學號@域名) +const STUDENT_EMAIL_DOMAIN = 'ms.ccckyc.edu.hk'; // ← 按學校實際域名修改 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 + */ +function getConfig() { + const props = PropertiesService.getScriptProperties(); + let config = props.getProperties(); + + if (!config.SUBMISSION_SHEET_ID || !config.SHARE_SHEET_ID || !config.OVERDUE_SHEET_ID) { + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + + const subIter = root.getFilesByName('繳交紀錄及課業佈置'); + if (!subIter.hasNext()) { + throw new Error('找不到「繳交紀錄及課業佈置」試算表,請先執行 setup/Setup.gs 中的 setup()。'); + } + config.SUBMISSION_SHEET_ID = subIter.next().getId(); + + const shareIter = root.getFilesByName('自動共用、收集位址'); + if (!shareIter.hasNext()) { + throw new Error('找不到「自動共用、收集位址」試算表,請先執行 setup/Setup.gs 中的 setup()。'); + } + config.SHARE_SHEET_ID = shareIter.next().getId(); + + const overdueIter = root.getFilesByName('OverdueAssignments'); + if (!overdueIter.hasNext()) { + throw new Error('找不到「OverdueAssignments」試算表,請先執行 setup/Setup.gs 中的 setup()。'); + } + config.OVERDUE_SHEET_ID = overdueIter.next().getId(); + + props.setProperties({ + SUBMISSION_SHEET_ID: config.SUBMISSION_SHEET_ID, + SHARE_SHEET_ID: config.SHARE_SHEET_ID, + OVERDUE_SHEET_ID: config.OVERDUE_SHEET_ID + }); + Logger.log('✅ 已自動探索並儲存資源 ID。'); + } + + return config; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 生成逾期未交課業名單並寫入「OverdueAssignments」試算表。 + * 條件:狀態為「未繳交」且已超過截止日期。 + */ +function generateOverdueAssignments() { + const config = getConfig(); + + // 從「自動共用、收集位址」取得學號→電郵映射 + const studentSpreadsheet = SpreadsheetApp.openById(config.SHARE_SHEET_ID); + const studentSheet = studentSpreadsheet.getSheetByName('Sheet1') + || studentSpreadsheet.getSheets()[0]; + const studentData = studentSheet.getRange('A2:B' + studentSheet.getLastRow()).getValues(); + + const studentMap = {}; + studentData.forEach(function(row) { + const studentNumber = row[0]; + const studentName = row[1]; + if (studentNumber && studentName) { + studentMap[studentName] = studentNumber + '@' + STUDENT_EMAIL_DOMAIN; + } + }); + + // 開啟「OverdueAssignments」試算表,清空並重寫 + const overdueSpreadsheet = SpreadsheetApp.openById(config.OVERDUE_SHEET_ID); + let overdueSheet = overdueSpreadsheet.getSheetByName('Overdue Assignments'); + if (!overdueSheet) { + overdueSheet = overdueSpreadsheet.insertSheet('Overdue Assignments'); + } + overdueSheet.clear(); + overdueSheet.appendRow(['班別', '學生姓名', '學生電郵', '課業名稱', '截止日期']); + + const currentDate = new Date(); + + // 讀取「繳交紀錄及課業佈置」各班別的繳交狀態 + const submissionSpreadsheet = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + const sheets = submissionSpreadsheet.getSheets(); + + sheets.forEach(function(sheet) { + const className = sheet.getRange('A1').getValue().toString().trim(); + if (!className) return; + + const lastColumn = sheet.getLastColumn(); + if (lastColumn < 2) return; + + const assignmentNames = sheet.getRange(1, 2, 1, lastColumn - 1).getValues()[0]; + const deadlines = sheet.getRange(2, 2, 1, lastColumn - 1).getValues()[0]; + const studentNames = sheet.getRange('A4:A' + sheet.getLastRow()).getValues() + .flat().filter(String); + + if (studentNames.length === 0) return; + + const statuses = sheet.getRange(4, 2, studentNames.length, lastColumn - 1).getValues(); + + studentNames.forEach(function(student, rowIndex) { + assignmentNames.forEach(function(assignment, colIndex) { + if (!assignment) return; + const status = statuses[rowIndex][colIndex]; + const deadlineStr = deadlines[colIndex]; + const deadline = new Date(deadlineStr); + + if (status === '未繳交' && currentDate > deadline) { + const studentEmail = studentMap[student]; + if (studentEmail) { + overdueSheet.appendRow([className, student, studentEmail, assignment, deadlineStr]); + } + } + }); + }); + }); + + Logger.log('✅ 已更新逾期課業名單。'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 觸發器設置 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 建立每 1 分鐘觸發一次 generateOverdueAssignments 的時間觸發器。 + */ +function createTrigger() { + ScriptApp.getProjectTriggers().forEach(function(trigger) { + if (trigger.getHandlerFunction() === 'generateOverdueAssignments') { + ScriptApp.deleteTrigger(trigger); + } + }); + ScriptApp.newTrigger('generateOverdueAssignments') + .timeBased() + .everyMinutes(1) + .create(); + Logger.log('✅ 已建立觸發器:generateOverdueAssignments,每 1 分鐘觸發一次。'); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c6ee5f --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# 帙雲 (ZhiYun) — Homework Management System + +> A Hong Kong school homework management platform built on **Google Apps Script** + **Google Drive** + **Microsoft Power Automate**. + +--- + +## 📖 Introduction + +**帙雲** (ZhiYun) is a teacher-facing homework management system for Hong Kong secondary schools. It automates the entire homework lifecycle — from collection and sorting, to tracking submissions and chasing overdue work — so teachers can focus on teaching rather than administration. + +### ✨ Features + +1. **一鍵下載全班課業** — One-click access to download all homework submitted by the whole class. +2. **一鍵發還批改課業** — One-click return of marked homework to students' personal Google Drive folders. +3. **實時繳交狀態追蹤** — Real-time colour-coded tracking of each student's submission status (已繳交 ✅ / 未繳交 🔴 / 遲交 🟡). +4. **自動歸類文件夾** — Auto-categorise files by class → category → assignment, and by student → category → assignment. +5. **自動追收逾期功課** — Auto-chase overdue homework via Microsoft Teams (morning, noon, and evening reminders) using Power Automate. + +--- + +## 🏗️ Architecture + +``` +Google Drive (Root Folder) +├── 01_學生上傳區 ← Students upload homework here +├── 02_待批改課業 ← Auto-sorted by class/category/assignment +│ └── 1C/ +│ ├── 閱讀/ +│ ├── 寫作(長文)/ +│ │ └── 藏在泥土的【寶物】/ +│ └── 寫作(實用文)/ +├── 03_老師回饋區 ← Teacher places marked work here +└── 04_已發還課業 ← Auto-sorted by class/student/category/assignment + └── 【1C】/ + └── 【陳大文】/ + ├── 閱讀/ + ├── 寫作(長文)/ + └── 寫作(實用文)/ + +Google Sheets (inside root folder) +├── 自動共用、收集位址 ← Student ID → personal folder URL +├── 繳交紀錄及課業佈置 ← Submission records per class (multi-sheet) +└── OverdueAssignments ← Overdue list for Power Automate + +Google Apps Script Projects (6) +├── setup/ ← Run ONCE to create all folders & sheets +├── 01_CollectHomework/ ← Sorts uploaded files (1-min trigger) +├── 02_AutoReturn/ ← Returns marked files (15-min trigger) +├── 03_AutoShare/ ← Shares student folders & collects URLs (manual) +├── 04_SubmissionRecord/ ← Creates folders & tracks submission (5-min trigger) +├── 05_WebInterface/ ← Web App dashboard +└── 06_OverdueAssignments/ ← Generates overdue list (1-min trigger) +``` + +--- + +## ✅ Prerequisites + +| Requirement | Details | +|---|---| +| Google Account | With Google Drive and Google Sheets access | +| Google Apps Script | [script.google.com](https://script.google.com) | +| Microsoft Account | With Power Automate (Flow) access | +| School domains | Google email: e.g. `ccckyc.edu.hk` / Teams email: e.g. `ms.ccckyc.edu.hk` | + +--- + +## 🚀 Quick Start + +### Step 1 — Create the Root Folder + +1. Open [Google Drive](https://drive.google.com). +2. Create a new folder (e.g. `帙雲`). +3. Open the folder and copy its **ID** from the URL: + ``` + https://drive.google.com/drive/folders/<> + ``` + +### Step 2 — Run the Setup Script + +1. Go to [script.google.com](https://script.google.com) and create a new project named `帙雲_Setup`. +2. Paste the contents of `setup/Setup.gs` into `Code.gs`. +3. Replace `YOUR_ROOT_FOLDER_ID_HERE` with your root folder ID: + ```javascript + const ROOT_FOLDER_ID = 'your_actual_folder_id_here'; + ``` +4. Click **Run** → `setup`. +5. Check **View → Logs**. All created folder IDs, spreadsheet IDs, and URLs will be printed — **keep this for reference**. + +> ✅ After this step, four folders and three spreadsheets will have been automatically created inside your root folder. + +### Step 3 — Create Six Apps Script Projects + +For each script directory, create a new Apps Script project, paste the file(s), and set `ROOT_FOLDER_ID`: + +| Directory | Project Name | Files | Trigger | +|---|---|---|---| +| `01_CollectHomework/` | 帙雲_01_收集功課 | `Code.gs` | `createTrigger()` → every 1 min | +| `02_AutoReturn/` | 帙雲_02_自動發還 | `Code.gs` | `createTrigger()` → every 15 min | +| `03_AutoShare/` | 帙雲_03_自動共用 | `Code.gs` | No trigger (manual) | +| `04_SubmissionRecord/` | 帙雲_04_繳交紀錄 | `Code.gs` | `createTrigger()` → every 5 min | +| `05_WebInterface/` | 帙雲_05_介面 | `Code.gs` + `Index.html` + `record.html` + `homework.html` | Web App | +| `06_OverdueAssignments/` | 帙雲_06_逾期課業 | `Code.gs` | `createTrigger()` → every 1 min | + +**For each project:** +1. Set `ROOT_FOLDER_ID` at the top of `Code.gs`. +2. For projects `01`, `02`, `04`, `06`: run `createTrigger()` once to set up the time-based trigger. +3. For project `03`: run manually when needed. +4. For project `05`: deploy as a Web App (see Step 4 below). + +> **Note for `03_AutoShare`:** also set `SCHOOL_EMAIL_DOMAIN` to your school's Google email domain (e.g. `ccckyc.edu.hk`). + +> **Note for `06_OverdueAssignments`:** also set `STUDENT_EMAIL_DOMAIN` to your school's Microsoft Teams email domain (e.g. `ms.ccckyc.edu.hk`). + +### Step 4 — Deploy the Web Interface + +1. In the `帙雲_05_介面` Apps Script project, click **Deploy** → **New deployment**. +2. Select **Web app**. +3. Set **Execute as**: Me (your account). +4. Set **Who has access**: Anyone within your organization (or Anyone if needed). +5. Click **Deploy** and copy the **Web app URL**. + +--- + +## 📊 Spreadsheet Setup + +### 繳交紀錄及課業佈置 + +Each **sheet tab** = one class. Add tabs manually, one per class. + +| Cell | Content | +|---|---| +| A1 | Class name (e.g. `1C`) | +| A2 | Leave blank initially — auto-filled with `created` after folders are built | +| B1, C1 … | Homework name in format: `「寫作(長文)」藏在泥土的【寶物】` | +| B2, C2 … | Deadline in format: `2025-04-29 23:59` | +| B3, C3 … | Folder ID — **auto-filled**, do not edit | +| A4 onwards | Student names (one per row) | +| B4 onwards | Submission status — **auto-updated**: 已繳交 / 未繳交 / 遲交 | + +**Homework name format explained:** +- `「寫作(長文)」` — category in `「」` brackets (used to sort files into correct category subfolder) +- `【寶物】` — keyword in `【】` brackets (used to match uploaded filenames to the correct assignment folder) + +### 自動共用、收集位址 + +| Column | Content | +|---|---| +| A | Student ID (學號) | +| B | Student name (姓名) | +| C | Personal folder URL — auto-filled by `03_AutoShare` | + +After entering student IDs and names, run `shareAllClasses()` in `03_AutoShare` to share folders and populate column C. + +--- + +## 🌐 Web Interface Pages + +| URL parameter | Page | Purpose | +|---|---|---| +| (none) | `Index.html` | Control panel with links to all folders and tools | +| `?page=record` | `record.html` | View colour-coded submission records by class | +| `?page=homework` | `homework.html` | Assign new homework (updates the spreadsheet) | + +--- + +## ⚙️ Microsoft Power Automate Setup + +Set up a **Scheduled cloud flow** to send Teams messages to overdue students: + +1. **Recurrence** — Set your schedule (e.g. daily at 08:00, 12:00, 20:00). +2. **Get worksheet** — Connect to the `OverdueAssignments` Google Sheets file. + > ⚠️ The spreadsheet **file** is named `OverdueAssignments` (no space); the **sheet tab** inside it is named `Overdue Assignments` (with a space). +3. **List rows present in a table** — Select the `Overdue Assignments` sheet tab. Read all rows. +4. **Apply to each** — Loop over each row. +5. **Post message in a chat or channel** — Send a Teams message to the student's email address from the `學生電郵` column, mentioning the homework name and deadline. + +> **Tip:** In the Power Automate editor, type `/` to insert dynamic content from the spreadsheet columns. + +--- + +## 📁 File / Folder Naming Conventions + +| Format | Example | Meaning | +|---|---|---| +| Class code in filename | `1C 陳大文 寶物.pdf` | Used by scripts to identify class | +| `【keyword】` in filename | `1C 陳大文 【寶物】.pdf` | Used for assignment folder matching | +| `【ClassName】` folder | `【1C】` | Class folder inside 04_已發還課業 | +| `【StudentName】` folder | `【陳大文】` | Student folder inside class folder | +| `「Category」` in homework name | `「寫作(長文)」` | Category prefix in spreadsheet cell | + +--- + +## 🔧 Troubleshooting + +| Problem | Solution | +|---|---| +| `ROOT_FOLDER_ID` error on `setup()` | Ensure you replaced `YOUR_ROOT_FOLDER_ID_HERE` with your actual folder ID | +| Files not moving to `02_待批改課業` | Check the filename contains a valid class code (e.g. `1C`, `4A`) | +| Submission status not updating | Ensure `04_SubmissionRecord` trigger is running; check folder IDs in row 3 of the sheet | +| Student folder not shared | Ensure student names in the spreadsheet exactly match the `【name】` folder names | +| Overdue list empty | Check that `06_OverdueAssignments` trigger is running and `STUDENT_EMAIL_DOMAIN` is correct | +| Web App not loading | Re-deploy the Web App after code changes; check execution permissions | + +--- + +## 🗂️ Repository Structure + +``` +/ +├── setup/ +│ └── Setup.gs ← One-time setup script +├── 01_CollectHomework/ +│ └── Code.gs ← Collect & sort student homework +├── 02_AutoReturn/ +│ └── Code.gs ← Auto-return marked homework +├── 03_AutoShare/ +│ └── Code.gs ← Share student folders & collect URLs +├── 04_SubmissionRecord/ +│ └── Code.gs ← Submission tracking & folder creation +├── 05_WebInterface/ +│ ├── Code.gs ← Web App backend +│ ├── Index.html ← Control panel +│ ├── record.html ← Submission records viewer +│ └── homework.html ← Homework assignment form +├── 06_OverdueAssignments/ +│ └── Code.gs ← Generate overdue list for Power Automate +├── Draft.md ← Original design draft (for reference) +└── README.md ← This file +``` diff --git a/setup/Setup.gs b/setup/Setup.gs new file mode 100644 index 0000000..eb1992a --- /dev/null +++ b/setup/Setup.gs @@ -0,0 +1,196 @@ +/** + * 「帙雲」一次性設置腳本 + * + * 使用方法: + * 1. 在 Google Drive 建立一個根文件夾(例如「帙雲」),複製其 ID。 + * 2. 將下方 ROOT_FOLDER_ID 替換為你的根文件夾 ID。 + * 3. 在 Apps Script 編輯器中執行 setup()。 + * 4. 查看執行紀錄(View → Logs),複製所有 ID 以供其他腳本使用。 + */ + +// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 +// ───────────────────────────────────────────────────────────────────────────── + +/** 資源名稱常數 */ +const FOLDER_NAMES = { + UPLOAD: '01_學生上傳區', + PENDING: '02_待批改課業', + TEACHER_RETURN: '03_老師回饋區', + RETURNED: '04_已發還課業' +}; + +const SHEET_NAMES = { + SHARE: '自動共用、收集位址', + SUBMISSION: '繳交紀錄及課業佈置', + OVERDUE: 'OverdueAssignments' +}; + +/** Script Properties 鍵名(與其他腳本共用) */ +const PROP_KEYS = { + ROOT_FOLDER_ID: 'ROOT_FOLDER_ID', + UPLOAD_FOLDER_ID: 'UPLOAD_FOLDER_ID', + PENDING_FOLDER_ID: 'PENDING_FOLDER_ID', + TEACHER_RETURN_FOLDER_ID: 'TEACHER_RETURN_FOLDER_ID', + RETURNED_FOLDER_ID: 'RETURNED_FOLDER_ID', + SHARE_SHEET_ID: 'SHARE_SHEET_ID', + SUBMISSION_SHEET_ID: 'SUBMISSION_SHEET_ID', + OVERDUE_SHEET_ID: 'OVERDUE_SHEET_ID' +}; + +// ───────────────────────────────────────────────────────────────────────────── +// 主入口 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 一次性設置函數。 + * 在根文件夾下建立四個文件夾及三個試算表,並將所有 ID 儲存至 Script Properties。 + */ +function setup() { + if (ROOT_FOLDER_ID === 'YOUR_ROOT_FOLDER_ID_HERE') { + throw new Error( + '❌ 請先將 ROOT_FOLDER_ID 替換為你的 Google Drive 根文件夾 ID,然後再執行 setup()。' + ); + } + + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + Logger.log('✅ 根文件夾:' + root.getName() + ' (' + ROOT_FOLDER_ID + ')'); + + // 建立文件夾 + const uploadFolder = getOrCreateFolder(root, FOLDER_NAMES.UPLOAD); + const pendingFolder = getOrCreateFolder(root, FOLDER_NAMES.PENDING); + const teacherReturnFolder = getOrCreateFolder(root, FOLDER_NAMES.TEACHER_RETURN); + const returnedFolder = getOrCreateFolder(root, FOLDER_NAMES.RETURNED); + + // 建立試算表 + const shareSS = getOrCreateSpreadsheet(root, SHEET_NAMES.SHARE); + const submissionSS = getOrCreateSpreadsheet(root, SHEET_NAMES.SUBMISSION); + const overdueSS = getOrCreateSpreadsheet(root, SHEET_NAMES.OVERDUE); + + // 初始化試算表標頭 + setupShareSheet(shareSS); + setupOverdueSheet(overdueSS); + + // 儲存所有 ID 至 Script Properties + const props = PropertiesService.getScriptProperties(); + props.setProperties({ + [PROP_KEYS.ROOT_FOLDER_ID]: ROOT_FOLDER_ID, + [PROP_KEYS.UPLOAD_FOLDER_ID]: uploadFolder.getId(), + [PROP_KEYS.PENDING_FOLDER_ID]: pendingFolder.getId(), + [PROP_KEYS.TEACHER_RETURN_FOLDER_ID]: teacherReturnFolder.getId(), + [PROP_KEYS.RETURNED_FOLDER_ID]: returnedFolder.getId(), + [PROP_KEYS.SHARE_SHEET_ID]: shareSS.getId(), + [PROP_KEYS.SUBMISSION_SHEET_ID]: submissionSS.getId(), + [PROP_KEYS.OVERDUE_SHEET_ID]: overdueSS.getId() + }); + + Logger.log('✅ 所有 ID 已儲存至 Script Properties。'); + printSummary(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 + */ +function getOrCreateFolder(parent, name) { + const iter = parent.getFoldersByName(name); + if (iter.hasNext()) { + const existing = iter.next(); + Logger.log('📁 已存在文件夾:' + name + ' (' + existing.getId() + ')'); + return existing; + } + const newFolder = parent.createFolder(name); + Logger.log('📁 已建立文件夾:' + name + ' (' + newFolder.getId() + ')'); + return newFolder; +} + +/** + * 在 parent 文件夾下取得或建立名為 name 的試算表(冪等)。 + */ +function getOrCreateSpreadsheet(parent, name) { + const iter = parent.getFilesByName(name); + if (iter.hasNext()) { + const file = iter.next(); + Logger.log('📊 已存在試算表:' + name + ' (' + file.getId() + ')'); + return SpreadsheetApp.openById(file.getId()); + } + const ss = SpreadsheetApp.create(name); + // 將新建的試算表移至根文件夾 + const ssFile = DriveApp.getFileById(ss.getId()); + parent.addFile(ssFile); + DriveApp.getRootFolder().removeFile(ssFile); + Logger.log('📊 已建立試算表:' + name + ' (' + ss.getId() + ')'); + return ss; +} + +/** + * 初始化「自動共用、收集位址」試算表的標頭列。 + * A1=學號, B1=姓名, C1=文件夾位址 + */ +function setupShareSheet(ss) { + const sheet = ss.getSheets()[0]; + sheet.setName('Sheet1'); + if (!sheet.getRange('A1').getValue()) { + sheet.getRange('A1:C1').setValues([['學號', '姓名', '文件夾位址']]); + sheet.getRange('A1:C1').setFontWeight('bold'); + Logger.log('📝 已設置「自動共用、收集位址」標頭。'); + } +} + +/** + * 初始化「OverdueAssignments」試算表,建立「Overdue Assignments」頁面並設定標頭。 + * 欄位:班別, 學生姓名, 學生電郵, 課業名稱, 截止日期 + */ +function setupOverdueSheet(ss) { + let sheet = ss.getSheetByName('Overdue Assignments'); + if (!sheet) { + sheet = ss.insertSheet('Overdue Assignments'); + const defaultSheet = ss.getSheetByName('Sheet1'); + if (defaultSheet && ss.getSheets().length > 1) { + ss.deleteSheet(defaultSheet); + } + } + if (!sheet.getRange('A1').getValue()) { + sheet.getRange('A1:E1').setValues([['班別', '學生姓名', '學生電郵', '課業名稱', '截止日期']]); + sheet.getRange('A1:E1').setFontWeight('bold'); + Logger.log('📝 已設置「OverdueAssignments」標頭。'); + } +} + +/** + * 印出所有已建立資源的 ID 及 URL。 + */ +function printSummary() { + const props = PropertiesService.getScriptProperties().getProperties(); + Logger.log('\n══════════════════════════════════════════════'); + Logger.log(' 「帙雲」設置摘要'); + Logger.log('══════════════════════════════════════════════'); + Logger.log('📌 請將以下 ROOT_FOLDER_ID 複製到各腳本中:'); + Logger.log(' ROOT_FOLDER_ID = ' + ROOT_FOLDER_ID); + Logger.log(''); + Logger.log('📁 文件夾 ID:'); + Logger.log(' 01_學生上傳區 UPLOAD_FOLDER_ID = ' + props[PROP_KEYS.UPLOAD_FOLDER_ID]); + Logger.log(' 02_待批改課業 PENDING_FOLDER_ID = ' + props[PROP_KEYS.PENDING_FOLDER_ID]); + Logger.log(' 03_老師回饋區 TEACHER_RETURN_FOLDER_ID = ' + props[PROP_KEYS.TEACHER_RETURN_FOLDER_ID]); + Logger.log(' 04_已發還課業 RETURNED_FOLDER_ID = ' + props[PROP_KEYS.RETURNED_FOLDER_ID]); + Logger.log(''); + Logger.log('📊 試算表 ID:'); + Logger.log(' 自動共用、收集位址 SHARE_SHEET_ID = ' + props[PROP_KEYS.SHARE_SHEET_ID]); + Logger.log(' 繳交紀錄及課業佈置 SUBMISSION_SHEET_ID = ' + props[PROP_KEYS.SUBMISSION_SHEET_ID]); + Logger.log(' OverdueAssignments OVERDUE_SHEET_ID = ' + props[PROP_KEYS.OVERDUE_SHEET_ID]); + Logger.log(''); + Logger.log('🔗 文件夾連結:'); + Logger.log(' 學生上傳區:https://drive.google.com/drive/folders/' + props[PROP_KEYS.UPLOAD_FOLDER_ID]); + Logger.log(' 待批改課業:https://drive.google.com/drive/folders/' + props[PROP_KEYS.PENDING_FOLDER_ID]); + Logger.log(' 老師回饋區:https://drive.google.com/drive/folders/' + props[PROP_KEYS.TEACHER_RETURN_FOLDER_ID]); + Logger.log(' 已發還課業:https://drive.google.com/drive/folders/' + props[PROP_KEYS.RETURNED_FOLDER_ID]); + Logger.log(''); + Logger.log('🔗 試算表連結:'); + Logger.log(' 自動共用:https://docs.google.com/spreadsheets/d/' + props[PROP_KEYS.SHARE_SHEET_ID]); + Logger.log(' 繳交紀錄:https://docs.google.com/spreadsheets/d/' + props[PROP_KEYS.SUBMISSION_SHEET_ID]); + Logger.log(' 逾期課業:https://docs.google.com/spreadsheets/d/' + props[PROP_KEYS.OVERDUE_SHEET_ID]); + Logger.log('══════════════════════════════════════════════'); +} From e5d9f038b9e20a442c58d37c13580f96ed10b904 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:51:27 +0000 Subject: [PATCH 2/3] refactor: flatten all files to GAS root-level structure (no subfolders) Agent-Logs-Url: https://github.com/HugoWong528/Homework-system/sessions/e2fdb676-a6bf-4cfc-9413-0d3847a4ef7b Co-authored-by: HugoWong528 <267603037+HugoWong528@users.noreply.github.com> --- 02_AutoReturn/Code.gs => AutoReturn.gs | 0 03_AutoShare/Code.gs => AutoShare.gs | 0 .../Code.gs => CollectHomework.gs | 0 05_WebInterface/Index.html => Index.html | 0 .../Code.gs => OverdueAssignments.gs | 0 README.md | 120 ++++++++++-------- setup/Setup.gs => Setup.gs | 0 .../Code.gs => SubmissionRecord.gs | 0 05_WebInterface/Code.gs => WebInterface.gs | 0 .../homework.html => homework.html | 0 05_WebInterface/record.html => record.html | 0 11 files changed, 67 insertions(+), 53 deletions(-) rename 02_AutoReturn/Code.gs => AutoReturn.gs (100%) rename 03_AutoShare/Code.gs => AutoShare.gs (100%) rename 01_CollectHomework/Code.gs => CollectHomework.gs (100%) rename 05_WebInterface/Index.html => Index.html (100%) rename 06_OverdueAssignments/Code.gs => OverdueAssignments.gs (100%) rename setup/Setup.gs => Setup.gs (100%) rename 04_SubmissionRecord/Code.gs => SubmissionRecord.gs (100%) rename 05_WebInterface/Code.gs => WebInterface.gs (100%) rename 05_WebInterface/homework.html => homework.html (100%) rename 05_WebInterface/record.html => record.html (100%) diff --git a/02_AutoReturn/Code.gs b/AutoReturn.gs similarity index 100% rename from 02_AutoReturn/Code.gs rename to AutoReturn.gs diff --git a/03_AutoShare/Code.gs b/AutoShare.gs similarity index 100% rename from 03_AutoShare/Code.gs rename to AutoShare.gs diff --git a/01_CollectHomework/Code.gs b/CollectHomework.gs similarity index 100% rename from 01_CollectHomework/Code.gs rename to CollectHomework.gs diff --git a/05_WebInterface/Index.html b/Index.html similarity index 100% rename from 05_WebInterface/Index.html rename to Index.html diff --git a/06_OverdueAssignments/Code.gs b/OverdueAssignments.gs similarity index 100% rename from 06_OverdueAssignments/Code.gs rename to OverdueAssignments.gs diff --git a/README.md b/README.md index 6c6ee5f..095f81e 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,21 @@ Google Sheets (inside root folder) ├── 繳交紀錄及課業佈置 ← Submission records per class (multi-sheet) └── OverdueAssignments ← Overdue list for Power Automate -Google Apps Script Projects (6) -├── setup/ ← Run ONCE to create all folders & sheets -├── 01_CollectHomework/ ← Sorts uploaded files (1-min trigger) -├── 02_AutoReturn/ ← Returns marked files (15-min trigger) -├── 03_AutoShare/ ← Shares student folders & collects URLs (manual) -├── 04_SubmissionRecord/ ← Creates folders & tracks submission (5-min trigger) -├── 05_WebInterface/ ← Web App dashboard -└── 06_OverdueAssignments/ ← Generates overdue list (1-min trigger) +Google Apps Script Project (single project, all files at root) +├── Setup.gs ← Run ONCE to create all folders & sheets +├── CollectHomework.gs ← Sorts uploaded files (1-min trigger) +├── AutoReturn.gs ← Returns marked files (15-min trigger) +├── AutoShare.gs ← Shares student folders & collects URLs (manual) +├── SubmissionRecord.gs ← Creates folders & tracks submission (5-min trigger) +├── WebInterface.gs ← Web App backend +├── Index.html ← Control panel +├── record.html ← Submission records viewer +└── homework.html ← Homework assignment form +(OverdueAssignments.gs is deployed in a second project bound to the OverdueAssignments sheet) ``` +> ⚠️ **Google Apps Script does not support subfolders.** All `.gs` and `.html` files must be at the root level of the Apps Script project. + --- ## ✅ Prerequisites @@ -78,43 +83,57 @@ Google Apps Script Projects (6) ### Step 2 — Run the Setup Script -1. Go to [script.google.com](https://script.google.com) and create a new project named `帙雲_Setup`. -2. Paste the contents of `setup/Setup.gs` into `Code.gs`. -3. Replace `YOUR_ROOT_FOLDER_ID_HERE` with your root folder ID: +1. Go to [script.google.com](https://script.google.com) and create a new **standalone** project named `帙雲`. +2. In the project, create a new script file named `Setup` (`.gs` is added automatically). +3. Paste the contents of `Setup.gs` from this repository into it. +4. Replace `YOUR_ROOT_FOLDER_ID_HERE` with your root folder ID: ```javascript const ROOT_FOLDER_ID = 'your_actual_folder_id_here'; ``` -4. Click **Run** → `setup`. -5. Check **View → Logs**. All created folder IDs, spreadsheet IDs, and URLs will be printed — **keep this for reference**. +5. Click **Run** → `setup`. +6. Check **View → Logs**. All created folder IDs, spreadsheet IDs, and URLs will be printed — **keep this for reference**. > ✅ After this step, four folders and three spreadsheets will have been automatically created inside your root folder. -### Step 3 — Create Six Apps Script Projects +### Step 3 — Add All Scripts to the Apps Script Project -For each script directory, create a new Apps Script project, paste the file(s), and set `ROOT_FOLDER_ID`: +In the **same** `帙雲` Apps Script project, add the following files. Use the **+** button (Add a file) to create each one. Google Apps Script does not support subfolders — all files must be at the project root. -| Directory | Project Name | Files | Trigger | -|---|---|---|---| -| `01_CollectHomework/` | 帙雲_01_收集功課 | `Code.gs` | `createTrigger()` → every 1 min | -| `02_AutoReturn/` | 帙雲_02_自動發還 | `Code.gs` | `createTrigger()` → every 15 min | -| `03_AutoShare/` | 帙雲_03_自動共用 | `Code.gs` | No trigger (manual) | -| `04_SubmissionRecord/` | 帙雲_04_繳交紀錄 | `Code.gs` | `createTrigger()` → every 5 min | -| `05_WebInterface/` | 帙雲_05_介面 | `Code.gs` + `Index.html` + `record.html` + `homework.html` | Web App | -| `06_OverdueAssignments/` | 帙雲_06_逾期課業 | `Code.gs` | `createTrigger()` → every 1 min | +**Server-side scripts (`.gs`):** -**For each project:** -1. Set `ROOT_FOLDER_ID` at the top of `Code.gs`. -2. For projects `01`, `02`, `04`, `06`: run `createTrigger()` once to set up the time-based trigger. -3. For project `03`: run manually when needed. -4. For project `05`: deploy as a Web App (see Step 4 below). +| File | Description | Trigger | +|---|---|---| +| `Setup.gs` | One-time setup | Run once manually | +| `CollectHomework.gs` | Collect & sort student homework | `createTrigger()` → every 1 min | +| `AutoReturn.gs` | Auto-return marked homework | `createTrigger()` → every 15 min | +| `AutoShare.gs` | Share student folders & collect URLs | No trigger (manual) | +| `SubmissionRecord.gs` | Submission tracking & folder creation | `createTrigger()` → every 5 min | +| `WebInterface.gs` | Web App backend | Web App deployment | -> **Note for `03_AutoShare`:** also set `SCHOOL_EMAIL_DOMAIN` to your school's Google email domain (e.g. `ccckyc.edu.hk`). +**HTML templates (`.html`):** -> **Note for `06_OverdueAssignments`:** also set `STUDENT_EMAIL_DOMAIN` to your school's Microsoft Teams email domain (e.g. `ms.ccckyc.edu.hk`). +| File | Description | +|---|---| +| `Index.html` | Control panel | +| `record.html` | Submission records viewer | +| `homework.html` | Homework assignment form | + +**For the OverdueAssignments script:** +1. Create a **second, separate** Apps Script project named `帙雲_OverdueAssignments`. +2. Add `OverdueAssignments.gs` (rename to `Code` in the editor). +3. Set `ROOT_FOLDER_ID` and `STUDENT_EMAIL_DOMAIN`. +4. Run `createTrigger()` to set up the 1-min trigger. + +**For all scripts:** +1. Set `ROOT_FOLDER_ID` at the top of each `.gs` file. +2. For `CollectHomework`, `AutoReturn`, `SubmissionRecord`: run `createTrigger()` once. +3. For `AutoShare`: run `shareAllClasses()` manually when needed. +4. For `AutoShare`: also set `SCHOOL_EMAIL_DOMAIN` to your school's Google email domain. +5. For `OverdueAssignments`: also set `STUDENT_EMAIL_DOMAIN` to your school's Teams email domain. ### Step 4 — Deploy the Web Interface -1. In the `帙雲_05_介面` Apps Script project, click **Deploy** → **New deployment**. +1. In the `帙雲` Apps Script project, click **Deploy** → **New deployment**. 2. Select **Web app**. 3. Set **Execute as**: Me (your account). 4. Set **Who has access**: Anyone within your organization (or Anyone if needed). @@ -150,7 +169,7 @@ Each **sheet tab** = one class. Add tabs manually, one per class. | B | Student name (姓名) | | C | Personal folder URL — auto-filled by `03_AutoShare` | -After entering student IDs and names, run `shareAllClasses()` in `03_AutoShare` to share folders and populate column C. +After entering student IDs and names, run `shareAllClasses()` in `AutoShare.gs` to share folders and populate column C. --- @@ -197,34 +216,29 @@ Set up a **Scheduled cloud flow** to send Teams messages to overdue students: |---|---| | `ROOT_FOLDER_ID` error on `setup()` | Ensure you replaced `YOUR_ROOT_FOLDER_ID_HERE` with your actual folder ID | | Files not moving to `02_待批改課業` | Check the filename contains a valid class code (e.g. `1C`, `4A`) | -| Submission status not updating | Ensure `04_SubmissionRecord` trigger is running; check folder IDs in row 3 of the sheet | +| Submission status not updating | Ensure `SubmissionRecord.gs` trigger is running; check folder IDs in row 3 of the sheet | | Student folder not shared | Ensure student names in the spreadsheet exactly match the `【name】` folder names | -| Overdue list empty | Check that `06_OverdueAssignments` trigger is running and `STUDENT_EMAIL_DOMAIN` is correct | +| Overdue list empty | Check that `OverdueAssignments.gs` trigger is running and `STUDENT_EMAIL_DOMAIN` is correct | | Web App not loading | Re-deploy the Web App after code changes; check execution permissions | --- ## 🗂️ Repository Structure +> ⚠️ Google Apps Script **does not support subfolders**. All files must be at the root of the Apps Script project. This repository mirrors that flat structure exactly. + ``` / -├── setup/ -│ └── Setup.gs ← One-time setup script -├── 01_CollectHomework/ -│ └── Code.gs ← Collect & sort student homework -├── 02_AutoReturn/ -│ └── Code.gs ← Auto-return marked homework -├── 03_AutoShare/ -│ └── Code.gs ← Share student folders & collect URLs -├── 04_SubmissionRecord/ -│ └── Code.gs ← Submission tracking & folder creation -├── 05_WebInterface/ -│ ├── Code.gs ← Web App backend -│ ├── Index.html ← Control panel -│ ├── record.html ← Submission records viewer -│ └── homework.html ← Homework assignment form -├── 06_OverdueAssignments/ -│ └── Code.gs ← Generate overdue list for Power Automate -├── Draft.md ← Original design draft (for reference) -└── README.md ← This file +├── Setup.gs ← One-time setup (creates Drive folders + Sheets) +├── CollectHomework.gs ← Collect & sort student homework (1-min trigger) +├── AutoReturn.gs ← Auto-return marked homework (15-min trigger) +├── AutoShare.gs ← Share student folders & collect URLs (manual) +├── SubmissionRecord.gs ← Submission tracking & folder creation (5-min trigger) +├── WebInterface.gs ← Web App backend (doGet, data helpers) +├── OverdueAssignments.gs ← Generate overdue list for Power Automate (1-min trigger) +├── Index.html ← Control panel (Web App page) +├── record.html ← Submission records viewer (Web App page) +├── homework.html ← Homework assignment form (Web App page) +├── Draft.md ← Original design draft (for reference) +└── README.md ← This file ``` diff --git a/setup/Setup.gs b/Setup.gs similarity index 100% rename from setup/Setup.gs rename to Setup.gs diff --git a/04_SubmissionRecord/Code.gs b/SubmissionRecord.gs similarity index 100% rename from 04_SubmissionRecord/Code.gs rename to SubmissionRecord.gs diff --git a/05_WebInterface/Code.gs b/WebInterface.gs similarity index 100% rename from 05_WebInterface/Code.gs rename to WebInterface.gs diff --git a/05_WebInterface/homework.html b/homework.html similarity index 100% rename from 05_WebInterface/homework.html rename to homework.html diff --git a/05_WebInterface/record.html b/record.html similarity index 100% rename from 05_WebInterface/record.html rename to record.html From 70b9f303abde0e11d03255649f141bb90008f994 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 02:08:50 +0000 Subject: [PATCH 3/3] feat: GAS compatibility fixes + teacher setup web panel Agent-Logs-Url: https://github.com/HugoWong528/Homework-system/sessions/f61e1efd-cca8-4a57-a3c3-0b76dc57ad67 Co-authored-by: HugoWong528 <267603037+HugoWong528@users.noreply.github.com> --- AutoReturn.gs | 40 +--- AutoShare.gs | 48 +---- CollectHomework.gs | 41 +--- Index.html | 6 +- README.md | 59 +++--- Setup.gs | 52 +----- Shared.gs | 106 +++++++++++ SubmissionRecord.gs | 53 +----- WebInterface.gs | 187 +++++++++++++------ homework.html | 94 ++++++---- setup.html | 444 ++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 797 insertions(+), 333 deletions(-) create mode 100644 Shared.gs create mode 100644 setup.html diff --git a/AutoReturn.gs b/AutoReturn.gs index be75d09..8452473 100644 --- a/AutoReturn.gs +++ b/AutoReturn.gs @@ -5,33 +5,10 @@ * 方法:提取檔案名稱中的【班別】、【姓名】及課業【關鍵詞】,並配對至對應的學生文件夾。 * * 觸發器:distributeHomework,每 15 分鐘觸發一次。 - * 執行 createTrigger() 可自動建立觸發器。 - */ - -// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── -const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 -// ───────────────────────────────────────────────────────────────────────────── - -/** - * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 + * 執行 createReturnTrigger() 可自動建立觸發器。 + * + * 注意:ROOT_FOLDER_ID、getConfig() 及 getOrCreateFolder() 定義於 Shared.gs。 */ -function getConfig() { - const props = PropertiesService.getScriptProperties(); - let config = props.getProperties(); - - if (!config.TEACHER_RETURN_FOLDER_ID || !config.RETURNED_FOLDER_ID) { - const root = DriveApp.getFolderById(ROOT_FOLDER_ID); - config.TEACHER_RETURN_FOLDER_ID = getOrCreateFolder(root, '03_老師回饋區').getId(); - config.RETURNED_FOLDER_ID = getOrCreateFolder(root, '04_已發還課業').getId(); - props.setProperties({ - TEACHER_RETURN_FOLDER_ID: config.TEACHER_RETURN_FOLDER_ID, - RETURNED_FOLDER_ID: config.RETURNED_FOLDER_ID - }); - Logger.log('✅ 已自動探索並儲存文件夾 ID。'); - } - - return config; -} // ───────────────────────────────────────────────────────────────────────────── // 主函數 @@ -163,15 +140,6 @@ function distributeHomework() { // 輔助函數 // ───────────────────────────────────────────────────────────────────────────── -/** - * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 - */ -function getOrCreateFolder(parent, name) { - const iter = parent.getFoldersByName(name); - if (iter.hasNext()) return iter.next(); - return parent.createFolder(name); -} - // ───────────────────────────────────────────────────────────────────────────── // 觸發器設置 // ───────────────────────────────────────────────────────────────────────────── @@ -179,7 +147,7 @@ function getOrCreateFolder(parent, name) { /** * 建立每 15 分鐘觸發一次 distributeHomework 的時間觸發器。 */ -function createTrigger() { +function createReturnTrigger() { ScriptApp.getProjectTriggers().forEach(function(trigger) { if (trigger.getHandlerFunction() === 'distributeHomework') { ScriptApp.deleteTrigger(trigger); diff --git a/AutoShare.gs b/AutoShare.gs index 51390dc..41b23d3 100644 --- a/AutoShare.gs +++ b/AutoShare.gs @@ -10,46 +10,14 @@ * * 觸發器:不設觸發器,手動執行。 * - * 試算表格式: + * 試算表格式(每個分頁對應一個班別): * A1=學號, B1=姓名, C1=文件夾位址 * A2 起:實際學號, B2 起:實際姓名, C2 起(自動填入):文件夾 URL + * + * 注意:ROOT_FOLDER_ID、SCHOOL_EMAIL_DOMAIN、getConfig() 及 getOrCreateFolder() + * 定義於 Shared.gs,此處直接使用。 */ -// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── -const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 -// 學生 Google 帳號的電郵域名(學生 Drive 帳號) -const SCHOOL_EMAIL_DOMAIN = 'ccckyc.edu.hk'; // ← 按學校實際域名修改 -// ───────────────────────────────────────────────────────────────────────────── - -/** - * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 - */ -function getConfig() { - const props = PropertiesService.getScriptProperties(); - let config = props.getProperties(); - - if (!config.RETURNED_FOLDER_ID || !config.SHARE_SHEET_ID) { - const root = DriveApp.getFolderById(ROOT_FOLDER_ID); - config.RETURNED_FOLDER_ID = getOrCreateFolder(root, '04_已發還課業').getId(); - - // 搜尋試算表 - const ssIter = root.getFilesByName('自動共用、收集位址'); - if (ssIter.hasNext()) { - config.SHARE_SHEET_ID = ssIter.next().getId(); - } else { - throw new Error('找不到「自動共用、收集位址」試算表,請先執行 setup/Setup.gs 中的 setup()。'); - } - - props.setProperties({ - RETURNED_FOLDER_ID: config.RETURNED_FOLDER_ID, - SHARE_SHEET_ID: config.SHARE_SHEET_ID - }); - Logger.log('✅ 已自動探索並儲存資源 ID。'); - } - - return config; -} - // ───────────────────────────────────────────────────────────────────────────── // 主函數 // ───────────────────────────────────────────────────────────────────────────── @@ -146,11 +114,3 @@ function shareFoldersForClass(className, classFolderOverride, spreadsheetOverrid // 輔助函數 // ───────────────────────────────────────────────────────────────────────────── -/** - * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 - */ -function getOrCreateFolder(parent, name) { - const iter = parent.getFoldersByName(name); - if (iter.hasNext()) return iter.next(); - return parent.createFolder(name); -} diff --git a/CollectHomework.gs b/CollectHomework.gs index ce060f8..b08f0e8 100644 --- a/CollectHomework.gs +++ b/CollectHomework.gs @@ -5,35 +5,11 @@ * 方法:提取檔案名稱中的班別(如 1C、4A)及子文件夾以「【】」括起的關鍵詞,並作配對。 * * 觸發器:sortStudentAssignments,每 1 分鐘觸發一次。 - * 執行 createTrigger() 可自動建立觸發器。 + * 執行 createCollectTrigger() 可自動建立觸發器。 + * + * 注意:ROOT_FOLDER_ID、getConfig() 及 getOrCreateFolder() 定義於 Shared.gs。 */ -// ─── 唯一需要手動設定的值 ──────────────────────────────────────────────────── -const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; // ← 只需填寫這個 -// ───────────────────────────────────────────────────────────────────────────── - -/** - * 從 Script Properties 讀取設定,若尚未設定則自動從根文件夾探索並儲存。 - * @returns {{UPLOAD_FOLDER_ID: string, PENDING_FOLDER_ID: string}} - */ -function getConfig() { - const props = PropertiesService.getScriptProperties(); - let config = props.getProperties(); - - if (!config.UPLOAD_FOLDER_ID || !config.PENDING_FOLDER_ID) { - const root = DriveApp.getFolderById(ROOT_FOLDER_ID); - config.UPLOAD_FOLDER_ID = getOrCreateFolder(root, '01_學生上傳區').getId(); - config.PENDING_FOLDER_ID = getOrCreateFolder(root, '02_待批改課業').getId(); - props.setProperties({ - UPLOAD_FOLDER_ID: config.UPLOAD_FOLDER_ID, - PENDING_FOLDER_ID: config.PENDING_FOLDER_ID - }); - Logger.log('✅ 已自動探索並儲存文件夾 ID。'); - } - - return config; -} - // ───────────────────────────────────────────────────────────────────────────── // 主函數 // ───────────────────────────────────────────────────────────────────────────── @@ -155,15 +131,6 @@ function collectKeywordsRecursive(folder, keywordFolders) { } } -/** - * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 - */ -function getOrCreateFolder(parent, name) { - const iter = parent.getFoldersByName(name); - if (iter.hasNext()) return iter.next(); - return parent.createFolder(name); -} - // ───────────────────────────────────────────────────────────────────────────── // 觸發器設置 // ───────────────────────────────────────────────────────────────────────────── @@ -172,7 +139,7 @@ function getOrCreateFolder(parent, name) { * 建立每 1 分鐘觸發一次 sortStudentAssignments 的時間觸發器。 * 執行前會先刪除舊有的同名觸發器,避免重複。 */ -function createTrigger() { +function createCollectTrigger() { ScriptApp.getProjectTriggers().forEach(function(trigger) { if (trigger.getHandlerFunction() === 'sortStudentAssignments') { ScriptApp.deleteTrigger(trigger); diff --git a/Index.html b/Index.html index cc25102..fa5923f 100644 --- a/Index.html +++ b/Index.html @@ -161,7 +161,11 @@

應用操作

後臺設置