diff --git a/AutoReturn.gs b/AutoReturn.gs new file mode 100644 index 0000000..8452473 --- /dev/null +++ b/AutoReturn.gs @@ -0,0 +1,161 @@ +/** + * 「帙雲」02 - 自動發還課業 + * + * 功用:將老師批改完畢、放在「03_老師回饋區」的檔案,自動歸類至「04_已發還課業」。 + * 方法:提取檔案名稱中的【班別】、【姓名】及課業【關鍵詞】,並配對至對應的學生文件夾。 + * + * 觸發器:distributeHomework,每 15 分鐘觸發一次。 + * 執行 createReturnTrigger() 可自動建立觸發器。 + * + * 注意:ROOT_FOLDER_ID、getConfig() 及 getOrCreateFolder() 定義於 Shared.gs。 + */ + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 將「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]); + } + // 完全無法匹配,留在原地 + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + +// ───────────────────────────────────────────────────────────────────────────── +// 觸發器設置 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 建立每 15 分鐘觸發一次 distributeHomework 的時間觸發器。 + */ +function createReturnTrigger() { + 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/AutoShare.gs b/AutoShare.gs new file mode 100644 index 0000000..41b23d3 --- /dev/null +++ b/AutoShare.gs @@ -0,0 +1,116 @@ +/** + * 「帙雲」03 - 自動共用、收集位址 + * + * 功用:將「04_已發還課業」中每位學生的專屬文件夾共用給學生, + * 並將文件夾位址記錄至「自動共用、收集位址」試算表的 C 欄, + * 方便批量分發給學生。 + * + * 方法:在試算表中手動輸入學號(A 欄)及學生姓名(B 欄), + * 然後手動執行 shareAllClasses() 或針對特定班別執行 shareFoldersForClass()。 + * + * 觸發器:不設觸發器,手動執行。 + * + * 試算表格式(每個分頁對應一個班別): + * A1=學號, B1=姓名, C1=文件夾位址 + * A2 起:實際學號, B2 起:實際姓名, C2 起(自動填入):文件夾 URL + * + * 注意:ROOT_FOLDER_ID、SCHOOL_EMAIL_DOMAIN、getConfig() 及 getOrCreateFolder() + * 定義於 Shared.gs,此處直接使用。 + */ + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 針對試算表中所有班別,共用學生專屬文件夾並收集位址。 + * 試算表中每個分頁(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); + } + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 輔助函數 +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/CollectHomework.gs b/CollectHomework.gs new file mode 100644 index 0000000..b08f0e8 --- /dev/null +++ b/CollectHomework.gs @@ -0,0 +1,153 @@ +/** + * 「帙雲」01 - 收集功課 + * + * 功用:將「01_學生上傳區」的檔案移至「02_待批改課業」,並自動歸類。 + * 方法:提取檔案名稱中的班別(如 1C、4A)及子文件夾以「【】」括起的關鍵詞,並作配對。 + * + * 觸發器:sortStudentAssignments,每 1 分鐘觸發一次。 + * 執行 createCollectTrigger() 可自動建立觸發器。 + * + * 注意:ROOT_FOLDER_ID、getConfig() 及 getOrCreateFolder() 定義於 Shared.gs。 + */ + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 將「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); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 觸發器設置 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 建立每 1 分鐘觸發一次 sortStudentAssignments 的時間觸發器。 + * 執行前會先刪除舊有的同名觸發器,避免重複。 + */ +function createCollectTrigger() { + 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/Index.html b/Index.html new file mode 100644 index 0000000..fa5923f --- /dev/null +++ b/Index.html @@ -0,0 +1,180 @@ + + + + + + 帙雲 - 控制面板 + + + + + +
+
+

帙雲

+
+
+

課業及回饋

+ +
+
+

應用操作

+ +
+
+

後臺設置

+ +
+
+ + diff --git a/OverdueAssignments.gs b/OverdueAssignments.gs new file mode 100644 index 0000000..d079961 --- /dev/null +++ b/OverdueAssignments.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..51c0775 --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +# 帙雲 (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 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 + +| 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 **standalone** project named `帙雲`. +2. In the project, add all files from this repository (one by one using the **+** button). Google Apps Script does not support subfolders — all files must be at the project root. +3. In `Shared.gs`, replace `YOUR_ROOT_FOLDER_ID_HERE` with your root folder ID: + ```javascript + const ROOT_FOLDER_ID = 'your_actual_folder_id_here'; + ``` +4. Also in `Shared.gs`, update `SCHOOL_EMAIL_DOMAIN` to your school's Google email domain. +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 — Add All Scripts to the Apps Script Project + +The **main** `帙雲` project contains all files except `OverdueAssignments.gs`. All shared constants (`ROOT_FOLDER_ID`, `SCHOOL_EMAIL_DOMAIN`, etc.) and shared utilities (`getConfig()`, `getOrCreateFolder()`) are defined **once** in `Shared.gs`. + +**Server-side scripts (`.gs`):** + +| File | Description | Trigger function | +|---|---|---| +| `Shared.gs` | Shared constants & utilities | — | +| `Setup.gs` | One-time setup | Run `setup()` once manually | +| `CollectHomework.gs` | Collect & sort student homework | `createCollectTrigger()` → every 1 min | +| `AutoReturn.gs` | Auto-return marked homework | `createReturnTrigger()` → every 15 min | +| `AutoShare.gs` | Share student folders & collect URLs | No trigger (run `shareAllClasses()` manually) | +| `SubmissionRecord.gs` | Submission tracking & folder creation | `createSubmissionTrigger()` → every 5 min | +| `WebInterface.gs` | Web App backend | Web App deployment | + +**HTML templates (`.html`):** + +| File | Description | +|---|---| +| `Index.html` | Control panel | +| `record.html` | Submission records viewer | +| `homework.html` | Homework assignment form | +| `setup.html` | Class & student management panel | + +**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` at the top of the file. +4. Run `createTrigger()` to set up the 1-min trigger. + +**For the main project:** +1. All configuration is in `Shared.gs` — set `ROOT_FOLDER_ID` and `SCHOOL_EMAIL_DOMAIN` there. +2. Run `createCollectTrigger()`, `createReturnTrigger()`, and `createSubmissionTrigger()` once each. +3. Run `shareAllClasses()` manually from `AutoShare.gs` when needed. + +### Step 4 — Deploy the Web Interface + +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). +5. Click **Deploy** and copy the **Web app URL**. + +--- + +## 📊 Spreadsheet Setup + +### 繳交紀錄及課業佈置 + +Class tabs and student lists are now managed via the **班別及學生管理** web panel (`?page=setup`). You no longer need to edit the spreadsheet directly for this. + +Each **sheet tab** = one class, managed automatically by the web panel. + +| Cell | Content | +|---|---| +| A1 | Class name (e.g. `1C`) — set by web panel | +| A2 | Leave blank initially — auto-filled with `created` after folders are built | +| B1, C1 … | Homework name — set via `homework.html` web panel | +| B2, C2 … | Deadline — set via `homework.html` web panel | +| B3, C3 … | Folder ID — **auto-filled**, do not edit | +| A4 onwards | Student names — set by web panel | +| 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) + +### 自動共用、收集位址 + +Student IDs and names are now managed via the **班別及學生管理** web panel (`?page=setup` → 學生帳號管理 tab). You no longer need to edit the spreadsheet directly. + +| Column | Content | +|---|---| +| A | Student ID (學號) | +| B | Student name (姓名) | +| C | Personal folder URL — auto-filled by `AutoShare.gs` | + +After entering student IDs and names via the web panel, run `shareAllClasses()` in `AutoShare.gs` 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) | +| `?page=setup` | `setup.html` | Add classes, manage student lists and student accounts | + +--- + +## ⚙️ 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 `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 `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. + +``` +/ +├── Shared.gs ← Shared constants & utilities (ROOT_FOLDER_ID, getConfig, etc.) +├── 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, setup panel API) +├── OverdueAssignments.gs ← Generate overdue list for Power Automate (1-min trigger, separate project) +├── Index.html ← Control panel (Web App page) +├── record.html ← Submission records viewer (Web App page) +├── homework.html ← Homework assignment form (Web App page) +├── setup.html ← Class & student management panel (Web App page) +├── Draft.md ← Original design draft (for reference) +└── README.md ← This file +``` diff --git a/Setup.gs b/Setup.gs new file mode 100644 index 0000000..fee96a2 --- /dev/null +++ b/Setup.gs @@ -0,0 +1,154 @@ +/** + * 「帙雲」一次性設置腳本 + * + * 使用方法: + * 1. 在 Google Drive 建立一個根文件夾(例如「帙雲」),複製其 ID。 + * 2. 將 Shared.gs 中的 ROOT_FOLDER_ID 替換為你的根文件夾 ID。 + * 3. 在 Apps Script 編輯器中執行 setup()。 + * 4. 查看執行紀錄(View → Logs),複製所有 ID 以供其他腳本使用。 + * + * 注意:ROOT_FOLDER_ID、FOLDER_NAMES、SHEET_NAMES、PROP_KEYS 及 getOrCreateFolder() + * 定義於 Shared.gs,此處直接使用。 + */ + +// ───────────────────────────────────────────────────────────────────────────── +// 主入口 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 一次性設置函數。 + * 在根文件夾下建立四個文件夾及三個試算表,並將所有 ID 儲存至 Script Properties。 + */ +function setup() { + if (ROOT_FOLDER_ID === 'YOUR_ROOT_FOLDER_ID_HERE') { + throw new Error( + '❌ 請先將 Shared.gs 中的 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 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('══════════════════════════════════════════════'); +} diff --git a/Shared.gs b/Shared.gs new file mode 100644 index 0000000..975c8cd --- /dev/null +++ b/Shared.gs @@ -0,0 +1,106 @@ +/** + * 「帙雲」共用常數與工具函數 + * + * 在 Google Apps Script 中,同一專案的所有 .gs 檔案共用同一全域範疇。 + * 將共用常數與函數集中於此,避免重複宣告而導致的錯誤。 + * + * ⚠️ 部署前,請將下方 ROOT_FOLDER_ID 替換為你的 Google Drive 根文件夾 ID。 + * + * 注意:OverdueAssignments.gs 部署於獨立的 Apps Script 專案, + * 因此不共用此檔案,並自行宣告所需常數。 + */ + +// ─── 需要手動設定的值 ───────────────────────────────────────────────────────── + +/** Google Drive 根文件夾 ID(所有腳本共用) */ +const ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID_HERE'; + +/** 學生 Google 帳號電郵域名(AutoShare.gs 使用) */ +const SCHOOL_EMAIL_DOMAIN = 'ccckyc.edu.hk'; + +// ───────────────────────────────────────────────────────────────────────────── + +/** Drive 文件夾名稱常數 */ +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' +}; + +// ───────────────────────────────────────────────────────────────────────────── +// 共用函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 從 Script Properties 讀取設定。 + * 若必要的 ID 尚未儲存,則自動從根文件夾探索並儲存至 Script Properties。 + * @returns {Object} 包含所有資源 ID 的設定物件 + */ +function getConfig() { + const props = PropertiesService.getScriptProperties(); + let config = props.getProperties(); + + const needsDiscovery = !config.UPLOAD_FOLDER_ID || + !config.PENDING_FOLDER_ID || + !config.TEACHER_RETURN_FOLDER_ID || + !config.RETURNED_FOLDER_ID || + !config.SUBMISSION_SHEET_ID; + + if (needsDiscovery) { + const root = DriveApp.getFolderById(ROOT_FOLDER_ID); + + config.UPLOAD_FOLDER_ID = getOrCreateFolder(root, FOLDER_NAMES.UPLOAD).getId(); + config.PENDING_FOLDER_ID = getOrCreateFolder(root, FOLDER_NAMES.PENDING).getId(); + config.TEACHER_RETURN_FOLDER_ID = getOrCreateFolder(root, FOLDER_NAMES.TEACHER_RETURN).getId(); + config.RETURNED_FOLDER_ID = getOrCreateFolder(root, FOLDER_NAMES.RETURNED).getId(); + + const shareIter = root.getFilesByName(SHEET_NAMES.SHARE); + config.SHARE_SHEET_ID = shareIter.hasNext() ? shareIter.next().getId() : ''; + + const subIter = root.getFilesByName(SHEET_NAMES.SUBMISSION); + if (subIter.hasNext()) { + config.SUBMISSION_SHEET_ID = subIter.next().getId(); + } else { + throw new Error( + '找不到「' + SHEET_NAMES.SUBMISSION + '」試算表,請先執行 setup()。' + ); + } + + props.setProperties(config); + Logger.log('✅ 已自動探索並儲存資源 ID。'); + } + + return config; +} + +/** + * 在 parent 文件夾下取得或建立名為 name 的子文件夾(冪等)。 + * @param {GoogleAppsScript.Drive.Folder} parent + * @param {string} name + * @returns {GoogleAppsScript.Drive.Folder} + */ +function getOrCreateFolder(parent, name) { + const iter = parent.getFoldersByName(name); + if (iter.hasNext()) return iter.next(); + return parent.createFolder(name); +} diff --git a/SubmissionRecord.gs b/SubmissionRecord.gs new file mode 100644 index 0000000..6af16ae --- /dev/null +++ b/SubmissionRecord.gs @@ -0,0 +1,227 @@ +/** + * 「帙雲」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 分鐘觸發一次。 + * 執行 createSubmissionTrigger() 可自動建立觸發器。 + * + * 注意:ROOT_FOLDER_ID、getConfig() 及 getOrCreateFolder() 定義於 Shared.gs。 + */ + +// 全局文件夾緩存(減少 API 呼叫次數)。 +// 注意:在 Apps Script 中,全局變數的生命週期與單次函數執行相同, +// 每次觸發器執行都會重建此物件,不存在跨執行週期的過時資料問題。 +const folderCache = {}; + +// ───────────────────────────────────────────────────────────────────────────── +// 主函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 主函數:建立分層文件夾(如未建立)並更新繳交狀態。 + * 每 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; +} + +/** + * 批次更新試算表中的繳交狀態。 + * 比對「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 createSubmissionTrigger() { + 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/WebInterface.gs b/WebInterface.gs new file mode 100644 index 0000000..3956647 --- /dev/null +++ b/WebInterface.gs @@ -0,0 +1,298 @@ +/** + * 「帙雲」05 - 繳交紀錄及課業佈置介面(Web App) + * + * 功用:建立網頁介面,連結至各文件夾,供老師查閱繳交紀錄及佈置課業。 + * 部署:以 Web App 方式部署(Deploy → New deployment → Web app)。 + * + * 頁面: + * / → Index.html 控制面板 + * ?page=record → record.html 繳交紀錄 + * ?page=homework → homework.html 佈置課業 + * ?page=setup → setup.html 班別及學生管理 + * + * 注意:ROOT_FOLDER_ID、getConfig() 及 getOrCreateFolder() 定義於 Shared.gs。 + */ + +// ───────────────────────────────────────────────────────────────────────────── +// 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('布置課業'); + } + + if (page === 'setup') { + const template = HtmlService.createTemplateFromFile('setup'); + 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 sheet = spreadsheet.getSheets().find(function(s) { + return s.getRange('A1').getValue().toString().trim() === className; + }); + if (!sheet) throw new Error('找不到指定的班別:' + className); + + // 找到下一個可用欄位(欄 A 為班別名稱,課業從欄 B 起) + const nextColumn = Math.max(2, sheet.getLastColumn() + 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; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 設置面板後端函數 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 獲取設置面板所需的所有資料。 + * @returns {{ submissionClasses: Array, shareClasses: Array }} + */ +function getSetupData() { + const config = getConfig(); + const submissionSS = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + + const submissionClasses = submissionSS.getSheets().map(function(sheet) { + const className = sheet.getRange('A1').getValue().toString().trim(); + if (!className) return null; + const lastRow = sheet.getLastRow(); + const students = lastRow >= 4 + ? sheet.getRange('A4:A' + lastRow).getValues().flat().filter(String) + : []; + return { name: className, students: students }; + }).filter(Boolean); + + const shareClasses = []; + if (config.SHARE_SHEET_ID) { + const shareSS = SpreadsheetApp.openById(config.SHARE_SHEET_ID); + shareSS.getSheets().forEach(function(sheet) { + const sheetName = sheet.getName(); + const lastRow = sheet.getLastRow(); + const students = []; + if (lastRow >= 2) { + sheet.getRange('A2:C' + lastRow).getValues().forEach(function(row) { + if (row[0] || row[1]) { + students.push({ id: row[0].toString(), name: row[1].toString(), url: row[2].toString() }); + } + }); + } + shareClasses.push({ name: sheetName, students: students }); + }); + } + + return { submissionClasses: submissionClasses, shareClasses: shareClasses }; +} + +/** + * 新增班別至繳交紀錄試算表。 + * @param {string} className 班別名稱(如 1C) + */ +function addClass(className) { + className = className.toString().trim(); + if (!className) throw new Error('班別名稱不可為空'); + + const config = getConfig(); + const ss = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + + const existing = ss.getSheets().some(function(s) { + return s.getRange('A1').getValue().toString().trim() === className; + }); + if (existing) throw new Error('班別「' + className + '」已存在'); + + const sheet = ss.insertSheet(className); + sheet.getRange('A1').setValue(className); +} + +/** + * 設定某班別的學生名單(覆蓋 A4 以下的現有名單)。 + * @param {string} className 班別名稱 + * @param {string} studentsText 學生姓名(每行一個) + */ +function setClassStudents(className, studentsText) { + const config = getConfig(); + const ss = SpreadsheetApp.openById(config.SUBMISSION_SHEET_ID); + + const sheet = ss.getSheets().find(function(s) { + return s.getRange('A1').getValue().toString().trim() === className; + }); + if (!sheet) throw new Error('找不到班別:' + className); + + const names = studentsText.toString().split('\n') + .map(function(n) { return n.trim(); }) + .filter(Boolean); + + // 清除現有學生名單(A4 以下) + const lastRow = sheet.getLastRow(); + if (lastRow >= 4) { + sheet.getRange('A4:A' + lastRow).clearContent(); + } + + if (names.length > 0) { + sheet.getRange(4, 1, names.length, 1).setValues(names.map(function(n) { return [n]; })); + } +} + +/** + * 設定共用試算表中某班別的學生帳號資料(覆蓋現有資料)。 + * @param {string} className 班別名稱 + * @param {string} studentsJson JSON 字串,格式:[{id, name}, ...] + */ +function setShareStudents(className, studentsJson) { + const config = getConfig(); + if (!config.SHARE_SHEET_ID) throw new Error('找不到「自動共用、收集位址」試算表,請先執行 setup()。'); + + const ss = SpreadsheetApp.openById(config.SHARE_SHEET_ID); + let sheet = ss.getSheetByName(className); + if (!sheet) { + sheet = ss.insertSheet(className); + sheet.getRange('A1:C1').setValues([['學號', '姓名', '文件夾位址']]); + sheet.getRange('A1:C1').setFontWeight('bold'); + } + + const students = JSON.parse(studentsJson); + + // 清除現有資料(第 2 行以下) + const lastRow = sheet.getLastRow(); + if (lastRow >= 2) { + sheet.getRange(2, 1, lastRow - 1, 3).clearContent(); + } + + if (students.length > 0) { + const rows = students.map(function(s) { return [s.id || '', s.name || '', '']; }); + sheet.getRange(2, 1, rows.length, 3).setValues(rows); + } +} diff --git a/homework.html b/homework.html new file mode 100644 index 0000000..d025e91 --- /dev/null +++ b/homework.html @@ -0,0 +1,349 @@ + + + + + + 布置課業 + + + + +
+

布置課業

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

作業繳交紀錄

+
+
+
+ + + +
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + +
學生姓名 +
+
+ +
+
+
+
+ +
+
+ + + diff --git a/setup.html b/setup.html new file mode 100644 index 0000000..23023e9 --- /dev/null +++ b/setup.html @@ -0,0 +1,444 @@ + + + + + + 班別及學生管理 + + + + +
載入中…
+ +
+

班別及學生管理

+ ← 返回控制面板 +
+ +
+
+ + +
+ + +
+ + +
+

新增班別

+ +
+ + +
+
+
+ + +
+

學生名單

+

選擇一個班別後,可新增或更新學生姓名。

+ +
+ 載入中… +
+ +
+ +
+ + +
+
+

學生帳號(共用文件夾用)

+

+ 輸入每位學生的學號及姓名,用於自動共用文件夾及發送 Teams 通知。
+ 儲存後,可在控制面板執行「自動共用專屬文件夾」。 +

+ +
+ 載入中… +
+ +
+
+
+ + + +