/** * QR Code 產生器 - Google Sheets 外掛 * 主控制器 */ // ============ 設定 ============ const CONFIG = { // Server API 端點(請修改為你的 server 位址) API_ENDPOINT: 'https://your-server.com/api/qrcode/generate.php', // Google Picker API Key(請在 Google Cloud Console 建立) PICKER_API_KEY: 'YOUR_PICKER_API_KEY', // 試算表欄位對應(1-indexed) COLUMNS: { CLASS_PREFIX: 1, // A: 課程代號 STUDENT_NO: 2, // B: 學號 NAME: 3, // C: 姓名 PHONE: 4, // D: 手機 EMAIL: 5, // E: 信箱 LINK: 6, // F: 連結 QRCODE_ID: 7, // G: QR Code ID EMAIL_ENCRYPT: 8 // H: Email Encrypt }, // 資料起始列(跳過標題列) DATA_START_ROW: 2 }; // ============ 外掛入口點 ============ /** * 外掛首頁觸發器 */ function onHomepage(e) { return createHomepageCard(); } /** * 建立首頁卡片 */ function createHomepageCard() { const card = CardService.newCardBuilder(); const section = CardService.newCardSection() .setHeader('QR Code 產生器') .addWidget( CardService.newTextParagraph() .setText('點擊下方按鈕開啟操作面板,為學員產生 QR Code。') ) .addWidget( CardService.newTextButton() .setText('🚀 開啟操作面板') .setOnClickAction( CardService.newAction().setFunctionName('openSidebar') ) ); card.addSection(section); return card.build(); } /** * 開啟側邊欄 */ function openSidebar() { const html = HtmlService.createHtmlOutputFromFile('Sidebar') .setTitle('QR Code 產生器') .setWidth(400); SpreadsheetApp.getUi().showSidebar(html); } /** * 安裝觸發選單(用於綁定腳本模式) */ function onOpen() { SpreadsheetApp.getUi() .createMenu('🎓 QR Code 工具') .addItem('開啟操作面板', 'openSidebar') .addSeparator() .addItem('關於', 'showAbout') .addToUi(); } /** * 顯示關於資訊 */ function showAbout() { const ui = SpreadsheetApp.getUi(); ui.alert( '關於 QR Code 產生器', '版本: 1.0.0\n\n' + '功能:\n' + '• 自動產生學員 QR Code\n' + '• 上傳到 Google Drive\n' + '• 寫入資料庫\n' + '• 回填試算表\n\n' + '開發:AIA 台灣人工智慧學校', ui.ButtonSet.OK ); } // ============ 資料讀取 ============ /** * 取得試算表資料 * @param {string} classFilter - 課程代號篩選(可選) * @returns {Object} 資料物件 */ function getSheetData(classFilter) { const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); const lastRow = sheet.getLastRow(); const lastCol = sheet.getLastColumn(); if (lastRow < CONFIG.DATA_START_ROW) { return { rows: [], classPrefixes: [] }; } const dataRange = sheet.getRange(CONFIG.DATA_START_ROW, 1, lastRow - CONFIG.DATA_START_ROW + 1, lastCol); const data = dataRange.getValues(); const rows = []; const classPrefixSet = new Set(); data.forEach((row, index) => { const classPrefix = String(row[CONFIG.COLUMNS.CLASS_PREFIX - 1] || '').trim(); const studentNo = String(row[CONFIG.COLUMNS.STUDENT_NO - 1] || '').trim(); const name = String(row[CONFIG.COLUMNS.NAME - 1] || '').trim(); const phone = String(row[CONFIG.COLUMNS.PHONE - 1] || '').trim(); const email = String(row[CONFIG.COLUMNS.EMAIL - 1] || '').trim(); const existingLink = String(row[CONFIG.COLUMNS.LINK - 1] || '').trim(); const existingQRCodeId = String(row[CONFIG.COLUMNS.QRCODE_ID - 1] || '').trim(); // 收集所有課程代號 if (classPrefix) { classPrefixSet.add(classPrefix); } // 篩選邏輯 if (classFilter && classFilter !== 'all' && classPrefix !== classFilter) { return; } // 只加入有基本資料的列 if (studentNo && email) { rows.push({ rowIndex: CONFIG.DATA_START_ROW + index, classPrefix: classPrefix, studentNo: studentNo, name: name, phone: phone, email: email, existingLink: existingLink, existingQRCodeId: existingQRCodeId, hasData: !!(existingLink && existingQRCodeId) }); } }); return { rows: rows, classPrefixes: Array.from(classPrefixSet).sort() }; } /** * 取得預覽資料 * @param {string} classFilter - 課程代號篩選 * @param {boolean} skipExisting - 是否跳過已有資料的列 * @returns {Object} 預覽資料 */ function getPreviewData(classFilter, skipExisting) { const { rows, classPrefixes } = getSheetData(classFilter); const previewRows = rows.map(row => { // 預先計算 hash(僅供預覽,實際產生由 Server API 處理) const qrcodeId = row.existingQRCodeId || hashClassPrefixEmail(row.classPrefix, row.email); return { rowIndex: row.rowIndex, studentNo: row.studentNo, name: row.name, email: row.email, qrcodeId: qrcodeId, hasData: row.hasData, willProcess: skipExisting ? !row.hasData : true }; }); const toProcess = previewRows.filter(r => r.willProcess); const toSkip = previewRows.filter(r => !r.willProcess); return { total: previewRows.length, toProcess: toProcess.length, toSkip: toSkip.length, rows: previewRows, classPrefixes: classPrefixes }; } // ============ 主處理流程 ============ /** * 處理學員資料(主進入點) * @param {Object} options - 處理選項 * @returns {Object} 處理結果 */ function processStudents(options) { const { classFilter, skipExisting, folderId, actions } = options; const { rows } = getSheetData(classFilter); const results = { success: [], failed: [], skipped: [] }; const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); for (let i = 0; i < rows.length; i++) { const row = rows[i]; // 跳過已有資料的列 if (skipExisting && row.hasData) { results.skipped.push({ studentNo: row.studentNo, name: row.name, reason: '已有資料' }); continue; } try { // 呼叫處理單筆學員 const result = processSingleStudent(row, folderId, actions); if (result.success) { // 回填試算表 if (actions.updateSheet) { updateSheetRow(sheet, row.rowIndex, result.data); } results.success.push({ studentNo: row.studentNo, name: row.name, qrcodeId: result.data.qrcodeId, link: result.data.link }); } else { results.failed.push({ studentNo: row.studentNo, name: row.name, stage: result.error.stage, errorCode: result.error.code, errorMessage: result.error.message }); } } catch (error) { results.failed.push({ studentNo: row.studentNo, name: row.name, stage: 'unknown', errorCode: 'EXCEPTION', errorMessage: error.message }); } } return results; } /** * 處理單筆學員 * @param {Object} row - 學員資料 * @param {string} folderId - Google Drive 資料夾 ID * @param {Object} actions - 執行選項 * @returns {Object} 處理結果 */ function processSingleStudent(row, folderId, actions) { // 1. 呼叫 Server API 產生 QR Code const apiResult = callServerAPI(row, actions); if (!apiResult.success) { return { success: false, error: apiResult.error }; } let driveFileLink = ''; // 2. 上傳到 Google Drive if (actions.uploadToDrive && folderId) { try { const uploadResult = uploadToDrive( apiResult.data.qrcode_image_base64, apiResult.data.filename, folderId ); driveFileLink = uploadResult.webViewLink; } catch (error) { return { success: false, error: { stage: 'upload_drive', code: 'DRIVE_ERROR', message: error.message } }; } } return { success: true, data: { qrcodeId: apiResult.data.qrcode_id, link: apiResult.data.file_link, driveLink: driveFileLink, emailEncrypt: apiResult.data.qrcode_id // Email Encrypt 與 QR Code ID 相同 } }; } /** * 更新試算表單列 * @param {Sheet} sheet - 試算表物件 * @param {number} rowIndex - 列索引 * @param {Object} data - 要寫入的資料 */ function updateSheetRow(sheet, rowIndex, data) { // F: 連結 sheet.getRange(rowIndex, CONFIG.COLUMNS.LINK).setValue(data.link); // G: QR Code ID sheet.getRange(rowIndex, CONFIG.COLUMNS.QRCODE_ID).setValue(data.qrcodeId); // H: Email Encrypt sheet.getRange(rowIndex, CONFIG.COLUMNS.EMAIL_ENCRYPT).setValue(data.emailEncrypt); } // ============ Hash 函數(供預覽用)============ /** * 計算 QR Code ID (與 Python 版本一致) * @param {string} classPrefix - 課程代號 * @param {string} email - 信箱 * @returns {string} MD5 hash */ function hashClassPrefixEmail(classPrefix, email) { const salt = 'newQRCODE' + classPrefix + email + 'from20201118byBaoYunIdea'; const hash = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, salt, Utilities.Charset.UTF_8); return hash.map(b => ('0' + ((b < 0 ? b + 256 : b).toString(16))).slice(-2)).join(''); } // ============ 設定相關 ============ /** * 取得 Picker API Key */ function getPickerApiKey() { return CONFIG.PICKER_API_KEY; } /** * 取得 OAuth Token(供 Picker 使用) */ function getOAuthToken() { return ScriptApp.getOAuthToken(); } /** * 取得設定 */ function getConfig() { return { columns: CONFIG.COLUMNS, dataStartRow: CONFIG.DATA_START_ROW }; }