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 @@ + + +
+ + +| 學生姓名 | + for (const hw of classInfo.homework) { ?> +
+
+ = hw.name ?>
+ + = hw.deadline ?> + |
+ } ?>
+
|---|---|
| = student.name ?> | + for (const submission of student.submissions) { ?> ++ } ?> + |