完全免登入 LINE 後台、免手動拉框!透過 Google Apps Script 一鍵全自動安裝「3 分頁切換式旗艦圖文選單」,毫秒級流暢切換日常反查、事後改存與事前鎖定!
支援文字、語音直接說、拍照多模態上傳與聊天室快捷指令反查!
| 快捷指令 | 功能說明 | 快速複製 |
|---|---|---|
| /安裝選單 | 全自動為 LINE 官方帳號安裝「3 分頁切換式旗艦圖文選單」 | |
| /網址 <網址> | 綁定個人的 Google Apps Script Web App 專屬儀表板網址 | |
| /查 今日 | 查看今日聯絡簿公告與今日 Google 日曆行程 | |
| /查 進度 | 查看各班最新教學進度、章節紀錄與交代事項 | |
| /查 缺交 | 查看全班各科未補交作業清單與座號名單 | |
| /查 聯絡人 | 查詢通訊錄、電話、Email 與單位 | |
| /查 花費 | 查看記帳本本月累計總支出金額與最近 3 筆消費明細 | |
| /名片 、 /行事曆 | 事前鎖定 5 分鐘強制歸檔模式(拍照即刻精準分類) | |
| 改存 聯絡人 | 事後一鍵轉移上一筆紀錄至聯絡人(並自動清除舊紀錄) | |
| /undo | 撤回並刪除上一筆存入 Google 試算表的資料(誤記修正) | |
| /help | 顯示完整指令手冊與 11 大分類輸入範例指南 |
完全免登入 LINE 後台、免手動拉框!透過程式 3 秒自動註冊 3 大分頁、上傳立體晶透底圖並動態綁定個人專屬儀表板!
包含 10 大反查指令、個人專屬儀表板直達與 40 張指令卡片盒,日常備課與查閱最直覺!
若剛剛存錯分類,點一下按鈕即可將上一筆資料自動轉移至新分頁,並清除舊紀錄!
拍照或口述前點一下,鎖定 5 分鐘強制歸檔(如拍名片必存通訊錄、拍發票必記帳)!
進入 Google 試算表 ➔「擴充功能 > Apps Script」➔ 點選左側齒輪 ⚙️ 「專案設定」 ➔ 拉到最下方「指令碼屬性」,確認以下 3 個項目皆已建立:
LINE_ACCESS_TOKEN:LINE Messaging API 的 Channel Access TokenGEMINI_API_KEY:Google AI Studio 的 API KeyWEB_APP_URL:自己部署出來的應用程式網址(結尾為 /exec)在 Google Apps Script 編輯器畫面上方:
setupMultiTabRichMenus。🎉 恭喜!全套「3 分頁切換式旗艦圖文選單」已成功全自動安裝並發布!
直接在與管家的 LINE 聊天室中:
/網址 您的AppsScript網址(例如:/網址 https://script.google.com/macros/s/.../exec)
/安裝選單
原本試算表內的所有紀錄完全保留!只要 2 分鐘,無縫升級為最新 3 分頁圖文選單系統!
打開您原本的 Google 試算表 ➔ 點選「擴充功能 > Apps Script」➔ 點進 Code.gs 編輯器:
Ctrl + A(全選舊程式碼)➔ 按 Delete 清空。/**
* ====================================================================
* 【LINE 官方帳號 + Gemini AI + Google 試算表 全能教育與生活智慧管家】
*
* 專為奕鈞老師與教師/導師量身打造的免伺服器雲端智慧系統
* 支援 11+1 大智慧分類:
* 1. 👤 聯絡人(拍名片/文字自動歸檔,並自動同步新增至 Google 聯絡人通訊錄!)
* 2. 📖 教學進度(班級、章節進度、頁數、小老師交代事項)
* 3. 📢 今日聯絡簿(自動產生一鍵轉發班群美化公告,亦可拍照辨識)
* 4. 📝 作業缺交與追蹤(支援隨時下指令查詢未補交名單)
* 5. 👨🎓 學生概況(支援主要學生 + 關聯同行學生)
* 6. 📞 家長聯繫紀錄
* 7. 💡 教學備課與靈感庫(支援文字、黑白板手寫筆記拍照辨識)
* 8. 📅 行事曆待辦(支援文字、拍研習海報/簡章自動同步 Google 日曆)
* 9. 💰 記帳本(支援文字、拍發票/收據拍照自動記帳)
* 10. 📌 生活雜事備忘
* 11. 🎁 好物介紹連結
* 12. 💬 AI 備課/出題/專業諮詢
*
* 🌟 旗艦進階功能:
* • 📸 圖片多模態辨識(名片強制優先建檔、發票記帳、活動海報、手寫筆記)
* • 🎯 事前強制鎖定模式(/名片、/行事曆、/記帳、/進度 等,5分鐘內強制歸類)
* • 🔄 事後一鍵改存轉移(改存 聯絡人、改存 行事曆 等,自動轉存並清除舊紀錄)
* • 🎙️ 語音直錄聽寫歸檔(LINE 語音訊息自動聽寫並分類)
* • 📇 Google 聯絡人 (People API) 雲端通訊錄自動同步
* • 🤖 動態模型切換(/model 查詢與切換指定模型)
* • 🔍 聊天室反向搜尋(/查 聯絡人/進度/缺交/今日/花費)
* • 🗑️ 精準刪除指定紀錄(/del <關鍵字> 快速刪除錯誤資料)
* • ↩️ 一鍵撤回紀錄(/undo 刪除上一筆存入的資料)
* • 🌅 每日早安晨報(定時排程主動推播當日行程與待辦)
*
* 作者:奕鈞老師 (https://ijun-ai.com)
* ====================================================================
*/
// ==================== 1. 基本設定區 ====================
var CONFIG = {
LINE_ACCESS_TOKEN: PropertiesService.getScriptProperties().getProperty('LINE_ACCESS_TOKEN') || 'YOUR_LINE_ACCESS_TOKEN',
GEMINI_API_KEY: PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY') || 'YOUR_GEMINI_API_KEY',
SPREADSHEET_ID: PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID') || (function(){ try { return SpreadsheetApp.getActiveSpreadsheet().getId(); } catch(e) { return '1TSbmh4ANMilmyu5QWxS3j0nET3jfSFMzdLLPHupsAUc'; } })(),
GEMINI_MODEL: 'gemini-1.5-flash',
SHEET_NAMES: {
CONTACTS: '聯絡人',
TEACHING_PROGRESS: '教學進度',
CONTACT_BOOK: '今日聯絡簿',
HOMEWORK_MISSING: '作業缺交',
STUDENT: '學生概況',
PARENT_LOG: '家長聯繫',
TEACHING_IDEA: '備課靈感',
CALENDAR: '行事曆',
EXPENSE: '記帳本',
TASK: '生活雜事',
GOODS: '好物分享'
}
};
// ==================== 2. LINE Webhook 與 網頁儀表板入口 (doPost & doGet) ====================
function doPost(e) {
if (!e || !e.postData) {
return ContentService.createTextOutput(JSON.stringify({ status: 'no post data' })).setMimeType(ContentService.MimeType.JSON);
}
try {
const postData = JSON.parse(e.postData.contents);
const events = postData.events;
if (!events || events.length === 0) {
return ContentService.createTextOutput(JSON.stringify({ status: 'ok' })).setMimeType(ContentService.MimeType.JSON);
}
for (const event of events) {
const replyToken = event.replyToken;
const userId = event.source ? event.source.userId : null;
if (userId) {
PropertiesService.getScriptProperties().setProperty('ADMIN_USER_ID', userId);
}
if (event.type === 'message') {
const msgType = event.message.type;
// 1. 純文字訊息處理
if (msgType === 'text') {
const userText = String(event.message.text || '').trim();
const commandReply = handleUserCommand(userText, userId);
if (commandReply) {
sendLineReply(replyToken, commandReply);
continue;
}
const parsedResult = parseWithGemini(userText);
const writeResult = saveToSpreadsheet(parsedResult, userText);
const replyMessage = formatReplyMessage(parsedResult, writeResult);
sendLineReply(replyToken, replyMessage);
}
// 2. 圖片訊息處理(名片辨識、發票記帳、海報行程、白板筆記、聯絡簿辨識)
else if (msgType === 'image') {
try {
const imageBlob = getLineMessageContentBlob(event.message.id);
if (!imageBlob || imageBlob.getBytes().length === 0) {
sendLineReply(replyToken, '⚠️ 無法從 LINE 取得圖片內容,請確認 LINE_ACCESS_TOKEN 權限或稍後重試。');
continue;
}
const base64Data = Utilities.base64Encode(imageBlob.getBytes());
const mimeType = imageBlob.getContentType() || 'image/jpeg';
// 檢查是否有使用者強制指定的分類模式
let forceCategory = null;
if (userId) {
const forceJson = PropertiesService.getScriptProperties().getProperty('FORCE_MODE_' + userId);
if (forceJson) {
try {
const forceObj = JSON.parse(forceJson);
if (forceObj.expireAt > Date.now()) {
forceCategory = forceObj.category;
} else {
PropertiesService.getScriptProperties().deleteProperty('FORCE_MODE_' + userId);
}
} catch(e) {}
}
}
const parsedResult = parseWithGeminiMultimodal({
type: 'image',
mimeType: mimeType,
base64Data: base64Data,
forceCategory: forceCategory
});
if (forceCategory) {
parsedResult.category = forceCategory;
PropertiesService.getScriptProperties().deleteProperty('FORCE_MODE_' + userId);
}
const writeResult = saveToSpreadsheet(parsedResult, '[名片/圖片多模態辨識]');
const replyMessage = '📸 【圖片多模態智慧辨識完成】\n' + formatReplyMessage(parsedResult, writeResult);
sendLineReply(replyToken, replyMessage);
} catch (imgErr) {
console.error('圖片辨識處理失敗:', imgErr);
sendLineReply(replyToken, '⚠️ 圖片辨識失敗:' + imgErr.toString() + '\n💡 請確認:\n1. 試算表指令碼屬性 LINE_ACCESS_TOKEN 是否正確\n2. 是否已完成 Google 帳號授權\n3. 圖片是否過大');
}
}
// 3. 語音訊息處理
else if (msgType === 'audio') {
try {
const audioBlob = getLineMessageContentBlob(event.message.id);
if (!audioBlob || audioBlob.getBytes().length === 0) {
sendLineReply(replyToken, '⚠️ 無法從 LINE 取得語音檔案,請確認 LINE_ACCESS_TOKEN 權限或稍後重試。');
continue;
}
const base64Data = Utilities.base64Encode(audioBlob.getBytes());
let mimeType = audioBlob.getContentType() || 'audio/mp4';
if (!mimeType.includes('/')) mimeType = 'audio/mp4';
const parsedResult = parseWithGeminiMultimodal({
type: 'audio',
mimeType: mimeType,
base64Data: base64Data
});
const writeResult = saveToSpreadsheet(parsedResult, '[語音多模態辨識]');
const replyMessage = '🎙️ 【語音直錄辨識歸檔完成】\n' + formatReplyMessage(parsedResult, writeResult);
sendLineReply(replyToken, replyMessage);
} catch (audErr) {
console.error('語音辨識處理失敗:', audErr);
sendLineReply(replyToken, '⚠️ 語音辨識失敗:' + audErr.toString() + '\n💡 請確認錄音長度是否適中,以及 LINE_ACCESS_TOKEN 是否正確設定。');
}
}
}
}
return ContentService.createTextOutput(JSON.stringify({ status: 'success' })).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
console.error('doPost 發生錯誤:', err);
return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: err.toString() })).setMimeType(ContentService.MimeType.JSON);
}
}
// 🌐 網頁端入口
function doGet(e) {
if (e && e.parameter && e.parameter.action === 'get_data') {
const allData = getAllSheetRecords();
return ContentService.createTextOutput(JSON.stringify(allData)).setMimeType(ContentService.MimeType.JSON);
}
const htmlOutput = HtmlService.createHtmlOutput(renderDashboardHtml())
.setTitle('全能教師智慧管家・個人雲端儀表板')
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
return htmlOutput;
}
// 讀取試算表所有分頁的資料
function getAllSheetRecords() {
const ssId = CONFIG.SPREADSHEET_ID;
const result = { sheets: {}, stats: { totalExpense: 0, missingCount: 0, todayContact: null, contactCount: 0 } };
try {
let ss = null;
if (ssId && ssId !== 'YOUR_SPREADSHEET_ID') { try { ss = SpreadsheetApp.openById(ssId); } catch(e){} }
if (!ss) { try { ss = SpreadsheetApp.getActiveSpreadsheet(); } catch(e){} }
if (!ss) return result;
ensureSheetHeaders(ss);
Object.keys(CONFIG.SHEET_NAMES).forEach(key => {
const sheetName = CONFIG.SHEET_NAMES[key];
const sheet = ss.getSheetByName(sheetName);
if (sheet) {
// 使用 getDisplayValues() 確保所有欄位皆為純字串,避免 Date 導致 GAS 序列化失敗
const data = sheet.getDataRange().getDisplayValues();
if (data.length > 1) {
const headers = data[0];
const rowsWithMeta = [];
for (let r = 1; r < data.length; r++) {
rowsWithMeta.push({
actualRow: r + 1,
values: data[r]
});
}
rowsWithMeta.reverse(); // 最新在最上面
result.sheets[sheetName] = { headers: headers, rows: rowsWithMeta };
if (sheetName === CONFIG.SHEET_NAMES.EXPENSE) {
rowsWithMeta.forEach(item => {
if (item.values[1] === '支出') {
const num = parseFloat(String(item.values[4] || '').replace(/[^0-9.-]/g, '')) || 0;
result.stats.totalExpense += num;
}
});
}
if (sheetName === CONFIG.SHEET_NAMES.HOMEWORK_MISSING) {
rowsWithMeta.forEach(item => {
if (item.values[4] === '未補交' || item.values[4] === '待追蹤') result.stats.missingCount++;
});
}
if (sheetName === CONFIG.SHEET_NAMES.CONTACTS) {
result.stats.contactCount = rowsWithMeta.length;
}
if (sheetName === CONFIG.SHEET_NAMES.CONTACT_BOOK && rowsWithMeta.length > 0) {
result.stats.todayContact = rowsWithMeta[0].values;
}
} else {
result.sheets[sheetName] = { headers: data[0] || [], rows: [] };
}
}
});
} catch (err) {
console.error('讀取試算表資料異常:', err);
}
return result;
}
// ==================== 3. LINE 聊天室快捷指令處理核心 ====================
function handleUserCommand(text, userId) {
if (!text) return null;
const lower = String(text).toLowerCase().trim();
// 🎯 【事前強制指定分類模式】(例如:/聯絡人、/名片、/行事曆、/記帳、/進度 等)
const forceMap = {
'/聯絡人': '聯絡人', '/名片': '聯絡人', '強制聯絡人': '聯絡人', '強制 聯絡人': '聯絡人',
'/行事曆': '行事曆', '/日曆': '行事曆', '強制行事曆': '行事曆', '強制 行事曆': '行事曆',
'/記帳': '記帳', '/發票': '記帳', '強制記帳': '記帳', '強制 記帳': '記帳',
'/進度': '教學進度', '強制進度': '教學進度', '強制 進度': '教學進度',
'/聯絡簿': '今日聯絡簿', '強制聯絡簿': '今日聯絡簿', '強制 聯絡簿': '今日聯絡簿',
'/靈感': '備課靈感', '強制靈感': '備課靈感', '強制 靈感': '備課靈感',
'/缺交': '作業缺交', '強制缺交': '作業缺交', '強制 缺交': '作業缺交',
'/學生': '學生概況', '強制學生': '學生概況', '強制 學生': '學生概況',
'/家長': '家長聯繫', '強制家長': '家長聯繫', '強制 家長': '家長聯繫',
'/雜事': '生活雜事', '強制雜事': '生活雜事', '強制 雜事': '生活雜事',
'/好物': '好物分享', '強制好物': '好物分享', '強制 好物': '好物分享'
};
if (userId && forceMap[lower]) {
const targetCat = forceMap[lower];
const forceObj = { category: targetCat, expireAt: Date.now() + 5 * 60 * 1000 };
PropertiesService.getScriptProperties().setProperty('FORCE_MODE_' + userId, JSON.stringify(forceObj));
return '🎯 【已鎖定強制「' + targetCat + '」模式】\n━━━━━━━━━━━━━━\n📷 請立即拍照或上傳圖片/文字(5 分鐘內有效),管家將 100% 強制歸檔至「' + targetCat + '」分頁!\n\n💡 若想取消鎖定請輸入 /cancel';
}
if (userId && (lower === '/cancel' || lower === '取消' || lower === '取消強制')) {
PropertiesService.getScriptProperties().deleteProperty('FORCE_MODE_' + userId);
return '🔄 已取消強制指定模式,恢復為 AI 自動智慧判斷!';
}
// 🔄 【事後一鍵改存/轉移分類】(例如:改存 聯絡人、轉存 行事曆、/改 記帳)
if (lower.startsWith('改存') || lower.startsWith('轉存') || lower.startsWith('/改') || lower.startsWith('/to')) {
const targetKw = text.replace(/^(改存|轉存|\/改|\/to)\s*/i, '').trim();
const catKeys = Object.values(CONFIG.SHEET_NAMES);
let matchedCat = catKeys.find(c => targetKw.includes(c) || c.includes(targetKw));
if (targetKw.includes('名片') || targetKw.includes('電話')) matchedCat = '聯絡人';
if (targetKw.includes('日曆') || targetKw.includes('行程')) matchedCat = '行事曆';
if (targetKw.includes('發票') || targetKw.includes('開銷')) matchedCat = '記帳本';
if (targetKw.includes('進度')) matchedCat = '教學進度';
if (targetKw.includes('聯絡簿')) matchedCat = '今日聯絡簿';
if (targetKw.includes('靈感')) matchedCat = '備課靈感';
if (!matchedCat) {
return '🔄 請指定要轉存的分類名稱,例如:\n• 改存 聯絡人\n• 改存 行事曆\n• 改存 記帳\n• 改存 教學進度';
}
const lastRecordJson = PropertiesService.getScriptProperties().getProperty('LAST_SAVED_RECORD');
if (!lastRecordJson) {
return '⚠️ 目前沒有上一筆紀錄可供轉存。';
}
try {
const lastRecord = JSON.parse(lastRecordJson);
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const oldSheet = ss.getSheetByName(lastRecord.sheetName);
let originalContent = lastRecord.summary || '';
if (oldSheet && lastRecord.row <= oldSheet.getLastRow()) {
const rowValues = oldSheet.getRange(lastRecord.row, 1, 1, oldSheet.getLastColumn()).getValues()[0];
originalContent = rowValues.slice(1).filter(Boolean).join(' ');
oldSheet.deleteRow(lastRecord.row);
}
const forcedPrompt = '請將以下內容強制解析歸類為「' + matchedCat + '」分類,並提取對應欄位:\n' + originalContent;
const reParsed = parseWithGemini(forcedPrompt);
reParsed.category = (matchedCat === '記帳本' ? '記帳' : matchedCat);
const writeResult = saveToSpreadsheet(reParsed, originalContent);
return '🔄 【已成功轉存至「' + writeResult.sheetName + '」】\n━━━━━━━━━━━━━━\n• 舊分頁「' + lastRecord.sheetName + '」紀錄已自動清除\n• ' + formatReplyMessage(reParsed, writeResult);
} catch (reErr) {
return '⚠️ 轉存失敗:' + reErr.message;
}
}
// 🌟 圖文選單管理指令 (/安裝選單, /移除選單, /儀表板)
if (lower === '/安裝選單' || lower === '/選單安裝' || lower === '/menu' || lower === '/setup-menu' || lower === '安裝選單') {
try {
const res = setupMultiTabRichMenus();
return res.message;
} catch (menuErr) {
return '⚠️ 安裝圖文選單失敗:' + menuErr.message + '\n💡 請確認 LINE_ACCESS_TOKEN 是否已正確填入指令碼屬性中。';
}
}
if (lower === '/移除選單' || lower === '/刪除選單' || lower === '/delete-menu' || lower === '移除選單') {
try {
const res = deleteMultiTabRichMenus();
return res.message;
} catch (delErr) {
return '⚠️ 移除選單失敗:' + delErr.message;
}
}
// 🌐 設定或查詢專屬儀表板網址 (/網址, /設定網址, /儀表板)
if (lower.startsWith('/網址') || lower.startsWith('/設定網址') || lower.startsWith('/url') || lower.startsWith('/seturl') || lower === '/儀表板' || lower === '/後台' || lower === '儀表板' || lower === '後台') {
const parts = text.split(/\s+/);
if (parts.length > 1 && parts[1].startsWith('http')) {
const newUrl = parts[1].trim();
PropertiesService.getScriptProperties().setProperty('WEB_APP_URL', newUrl);
try {
setupMultiTabRichMenus();
return '🎉 【儀表板網址已成功綁定並更新選單!】\n━━━━━━━━━━━━━━\n🔗 最新專屬網址:\n' + newUrl + '\n━━━━━━━━━━━━━━\n📱 圖文選單的「🌐 個人儀表板」按鈕已即時同步更新!';
} catch (e) {
return '✅ 網址已儲存至系統!\n🔗 ' + newUrl + '\n💡 請重新輸入 /安裝選單 即可同步至圖文選單按鈕!';
}
}
let currentUrl = PropertiesService.getScriptProperties().getProperty('WEB_APP_URL');
if (!currentUrl) {
try { currentUrl = ScriptApp.getService().getUrl(); } catch(e) {}
}
if (!currentUrl || !currentUrl.startsWith('http')) {
currentUrl = 'https://ijun-ai.com/tutor/line-bot/';
}
return '🌐 【您的專屬個人雲端儀表板】\n━━━━━━━━━━━━━━\n👉 點此開啟:\n' + currentUrl + '\n━━━━━━━━━━━━━━\n💡 如何更換為自己的 Apps Script 網頁?\n只要在 LINE 輸入:\n/網址 <貼上您的 Apps Script 網址>\n(例如:/網址 https://script.google.com/macros/s/.../exec)\n管家就會自動將圖文選單按鈕換成您的專屬網址!';
}
// 1. 模型查詢與切換指令 (/model)
if (lower.startsWith('/model') || lower.startsWith('模型') || lower.startsWith('查模型')) {
const parts = text.split(/\s+/);
const manualModel = PropertiesService.getScriptProperties().getProperty('GEMINI_MODEL');
if (parts.length === 1 || parts[1] === 'list' || parts[1] === '清單') {
const candidates = getCandidateModels(CONFIG.GEMINI_API_KEY);
let reply = '🤖 【Gemini 模型設定狀態】\n━━━━━━━━━━━━━━\n';
if (manualModel) {
reply += '📌 當前模式:手動鎖定\n🎯 使用模型:' + manualModel + '\n\n';
} else {
reply += '📌 當前模式:✨ 智慧動態輪詢(自動推薦最佳模型)\n🎯 優先模型:' + candidates[0] + '\n\n';
}
reply += '📋 推薦可用候選清單:\n';
candidates.slice(0, 5).forEach((m, idx) => {
reply += (idx + 1) + '. ' + m + (m === (manualModel || candidates[0]) ? ' (使用中)' : '') + '\n';
});
reply += '━━━━━━━━━━━━━━\n💡 切換指令:\n• 手動切換:輸入 「/model <模型名>」\n (例: /model gemini-2.5-pro)\n• 恢復自動:輸入 「/model auto」';
return reply;
}
const targetModel = parts[1].trim();
if (targetModel.toLowerCase() === 'auto' || targetModel.toLowerCase() === 'reset' || targetModel === '自動' || targetModel === '預設') {
PropertiesService.getScriptProperties().deleteProperty('GEMINI_MODEL');
return '🔄 已成功恢復為【✨ 智慧動態輪詢模式】!\n系統將自動探測並優先調用最新、最穩定的 Flash 模型。';
}
PropertiesService.getScriptProperties().setProperty('GEMINI_MODEL', targetModel);
return '✅ 已成功切換指定模型為:\n🎯 「' + targetModel + '」\n\n下一則訊息將立即以此模型進行分析與辨識!';
}
// 2. 撤銷/刪除上一筆紀錄 (/undo)
if (lower === '/undo' || lower === '/撤銷' || lower === '/撤回' || lower === '撤回上一筆' || lower === '刪除上一筆') {
const lastRecordJson = PropertiesService.getScriptProperties().getProperty('LAST_SAVED_RECORD');
if (!lastRecordJson) {
return '⚠️ 目前沒有可撤回的紀錄(或已撤回過)。';
}
try {
const lastRecord = JSON.parse(lastRecordJson);
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(lastRecord.sheetName);
if (sheet && lastRecord.row <= sheet.getLastRow()) {
sheet.deleteRow(lastRecord.row);
PropertiesService.getScriptProperties().deleteProperty('LAST_SAVED_RECORD');
return '🗑️ 【已成功撤回上一筆紀錄】\n━━━━━━━━━━━━━━\n• 來源分頁:' + lastRecord.sheetName + '\n• 刪除內容:' + (lastRecord.summary || '已清除') + '\n━━━━━━━━━━━━━━\n✨ 試算表已完成同步刪除!';
} else {
return '⚠️ 找不到對應的資料列,可能已手動編輯過。';
}
} catch (e) {
return '⚠️ 撤回失敗:' + e.message;
}
}
// 3. 🎯 精準搜尋並刪除指定紀錄 (/del <關鍵字> 或 /刪除 <關鍵字>)
if (lower.startsWith('/del') || lower.startsWith('/delete') || lower.startsWith('/刪除') || text.startsWith('刪除 ')) {
const kw = text.replace(/^(\/del|\/delete|\/刪除|刪除)\s*/i, '').trim();
if (!kw) {
return '🗑️ 請提供想刪除的關鍵字,例如:\n• /del 205 (刪除包含 205 的最新紀錄)\n• /del 王大明 (刪除聯絡人或紀錄)\n• /del 排骨便當 (刪除該筆記帳)';
}
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const searchSheets = [
CONFIG.SHEET_NAMES.CONTACTS,
CONFIG.SHEET_NAMES.TEACHING_PROGRESS,
CONFIG.SHEET_NAMES.EXPENSE,
CONFIG.SHEET_NAMES.HOMEWORK_MISSING,
CONFIG.SHEET_NAMES.STUDENT,
CONFIG.SHEET_NAMES.PARENT_LOG,
CONFIG.SHEET_NAMES.CONTACT_BOOK,
CONFIG.SHEET_NAMES.CALENDAR,
CONFIG.SHEET_NAMES.TASK,
CONFIG.SHEET_NAMES.TEACHING_IDEA,
CONFIG.SHEET_NAMES.GOODS
];
let foundSheet = null;
let foundRowIndex = -1;
let foundContent = '';
let foundTime = '';
for (const sName of searchSheets) {
const sheet = ss.getSheetByName(sName);
if (sheet && sheet.getLastRow() > 1) {
const data = sheet.getDataRange().getValues();
for (let i = data.length - 1; i >= 1; i--) {
const rowStr = data[i].join(' ');
if (rowStr.toLowerCase().includes(kw.toLowerCase())) {
foundSheet = sheet;
foundRowIndex = i + 1;
foundTime = String(data[i][0] || '').substring(0, 16);
foundContent = data[i].slice(1).filter(Boolean).join(' | ').substring(0, 40);
break;
}
}
}
if (foundSheet) break;
}
if (foundSheet && foundRowIndex > 0) {
foundSheet.deleteRow(foundRowIndex);
return '🗑️ 【已成功精準刪除 1 筆紀錄】\n━━━━━━━━━━━━━━\n• 來源分頁:' + foundSheet.getName() + '\n• 記錄時間:' + (foundTime || '無') + '\n• 刪除內容:' + foundContent + '...\n━━━━━━━━━━━━━━\n✨ 試算表已同步即時刪除!';
} else {
return '🔍 查無包含關鍵字「' + kw + '」的紀錄,未執行任何刪除。';
}
}
// 4. 跨分頁與特定主題快速反向查詢 (/查)
if (lower.startsWith('/查') || lower.startsWith('/find') || lower.startsWith('/search') || text.startsWith('查 ')) {
const kw = text.replace(/^(\/查|\/find|\/search|查)\s*/i, '').trim();
if (!kw) {
return '🔍 請提供想查詢的關鍵字,例如:\n• /查 聯絡人 (或 /查 王大明)\n• /查 進度 (或 /查 205)\n• /查 缺交\n• /查 今日\n• /查 記帳';
}
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
// 1. 聯絡人通訊錄查詢
if (kw === '聯絡人' || kw === '通訊錄' || kw === '名片' || kw === '電話') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.CONTACTS);
if (!sheet || sheet.getLastRow() <= 1) return '👤 目前聯絡人通訊錄尚無紀錄。';
const data = sheet.getDataRange().getValues();
const list = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
list.push('• 【' + row[1] + '】' + (row[4] ? row[4] + ' | ' : '') + '📞 ' + (row[2] || '無電話') + (row[3] ? ' | ✉️ ' + row[3] : ''));
if (list.length >= 5) break;
}
return '👤 【最新聯絡人通訊錄(共 ' + (data.length - 1) + ' 位)】\n━━━━━━━━━━━━━━\n' + list.join('\n') + '\n━━━━━━━━━━━━━━\n💡 輸入「/查 <姓名>」可快速搜尋特定對象。';
}
// 2. 教學進度查詢
if (kw === '進度' || kw === '教學進度' || kw === '進度表') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.TEACHING_PROGRESS);
if (!sheet || sheet.getLastRow() <= 1) return '📖 目前教學進度表尚無紀錄。';
const data = sheet.getDataRange().getValues();
const progressList = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
progressList.push('• 【' + row[1] + '】' + (row[2] || '') + ' ' + row[3] + ' (' + (row[4] || '無交代事項') + ')');
if (progressList.length >= 5) break;
}
return '📖 【最新教學進度紀錄(最新 5 筆)】\n━━━━━━━━━━━━━━\n' + progressList.join('\n') + '\n━━━━━━━━━━━━━━\n💡 點擊儀表板可查看完整進度表。';
}
// 3. 作業缺交名單查詢
if (kw === '缺交' || kw === '作業缺交' || kw === '作業') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.HOMEWORK_MISSING);
if (!sheet) return '⚠️ 找不到「作業缺交」分頁。';
const data = sheet.getDataRange().getValues();
const unsubmitted = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (row[4] === '未補交' || row[4] === '待追蹤') {
unsubmitted.push('• 【' + row[1] + '】座號/名單:' + row[2] + ' (' + (row[3] || '無備註') + ')');
}
}
if (unsubmitted.length === 0) {
return '🎉 太棒了!目前「作業缺交」中沒有任何未補交的紀錄!';
}
return '📝 【目前未補交作業清單(共 ' + unsubmitted.length + ' 筆)】\n━━━━━━━━━━━━━━\n' + unsubmitted.slice(0, 10).join('\n') + '\n━━━━━━━━━━━━━━\n💡 點擊儀表板可查看完整紀錄。';
}
// 4. 今日總覽速報(今日聯絡簿 + 今日日曆行程)
if (kw === '今日' || kw === '今天' || kw === '今日聯絡簿' || kw === '聯絡簿') {
const cbSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.CONTACT_BOOK);
const calSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.CALENDAR);
let reply = '📢 【今日聯絡簿 & 概況速報】\n━━━━━━━━━━━━━━\n';
if (cbSheet && cbSheet.getLastRow() > 1) {
const cbData = cbSheet.getDataRange().getValues();
const lastCb = cbData[cbData.length - 1];
reply += '📚 【今日作業】\n' + (lastCb[1] || '無') + '\n\n🎒 【攜帶物品】\n' + (lastCb[2] || '無') + '\n\n📄 【通知單/回條】\n' + (lastCb[3] || '無') + '\n\n❤️ 【導師叮嚀】\n' + (lastCb[4] || '無') + '\n\n';
} else {
reply += '(今日尚無聯絡簿紀錄)\n\n';
}
if (calSheet && calSheet.getLastRow() > 1) {
const calData = calSheet.getDataRange().getValues();
const todayStr = Utilities.formatDate(new Date(), 'Asia/Taipei', 'yyyy/MM/dd');
const todayEvents = [];
for (let i = 1; i < calData.length; i++) {
const row = calData[i];
const timeStr = String(row[2] || '');
if (timeStr.includes(todayStr) || row[5] !== '已完成') {
todayEvents.push('• ' + row[1] + ' (' + timeStr + ') - ' + (row[3] || '無地點'));
}
}
if (todayEvents.length > 0) {
reply += '📅 【近期/今日待辦行程】\n' + todayEvents.slice(0, 5).join('\n') + '\n';
}
}
reply += '━━━━━━━━━━━━━━\n✨ 隨手記、隨時查!';
return reply;
}
// 5. 家長聯繫紀錄查詢
if (kw === '家長' || kw === '家長聯繫' || kw === '聯繫' || kw === '訪談' || kw === '家長紀錄') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.PARENT_LOG);
if (!sheet || sheet.getLastRow() <= 1) return '📞 目前「家長聯繫」尚無紀錄。';
const data = sheet.getDataRange().getValues();
const list = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
list.push('• 【' + row[1] + '(' + (row[2] || '家長') + ')】' + (row[3] ? ' [' + row[3] + '] ' : '') + String(row[4] || '').substring(0, 25) + '... (追蹤:' + (row[5] || '無') + ')');
if (list.length >= 5) break;
}
return '📞 【最新家長聯繫紀錄(最新 5 筆)】\n━━━━━━━━━━━━━━\n' + list.join('\n') + '\n━━━━━━━━━━━━━━\n💡 輸入「/查 <學生姓名>」可查特定學生所有通話紀錄。';
}
// 6. 備課靈感庫查詢
if (kw === '備課' || kw === '備課靈感' || kw === '靈感' || kw === '教案' || kw === '靈感庫') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.TEACHING_IDEA);
if (!sheet || sheet.getLastRow() <= 1) return '💡 目前「備課靈感庫」尚無紀錄。';
const data = sheet.getDataRange().getValues();
const list = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
list.push('• 【' + (row[1] ? row[1] + ' - ' : '') + row[2] + '】' + String(row[3] || '').substring(0, 30) + '... (器材:' + (row[4] || '無') + ')');
if (list.length >= 5) break;
}
return '💡 【最新備課靈感庫(最新 5 筆)】\n━━━━━━━━━━━━━━\n' + list.join('\n') + '\n━━━━━━━━━━━━━━\n💡 點擊儀表板可查看完整教案架構。';
}
// 7. 學生概況與事件查詢
if (kw === '學生' || kw === '學生概況' || kw === '概況' || kw === '衝突' || kw === '常規') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.STUDENT);
if (!sheet || sheet.getLastRow() <= 1) return '👨🎓 目前「學生概況」尚無紀錄。';
const data = sheet.getDataRange().getValues();
const list = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
list.push('• 【' + row[1] + (row[2] && row[2] !== '無' ? ' & ' + row[2] : '') + '】[' + (row[3] || '事件') + '] ' + String(row[4] || '').substring(0, 25) + '... (' + String(row[0]).substring(0, 10) + ')');
if (list.length >= 5) break;
}
return '👨🎓 【最新學生概況紀錄(最新 5 筆)】\n━━━━━━━━━━━━━━\n' + list.join('\n') + '\n━━━━━━━━━━━━━━\n💡 輸入「/查 <學生姓名>」可查特定學生所有事件紀錄。';
}
// 8. 行事曆與活動行程查詢
if (kw === '行事曆' || kw === '行程' || kw === '日曆' || kw === '活動') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.CALENDAR);
if (!sheet || sheet.getLastRow() <= 1) return '📅 目前行事曆尚無活動紀錄。';
const data = sheet.getDataRange().getValues();
const list = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
list.push('• 【' + row[1] + '】時間:' + (row[2] || '未定') + (row[3] ? ' | 地點:' + row[3] : '') + ' [' + (row[5] || '進行中') + ']');
if (list.length >= 5) break;
}
return '📅 【行事曆活動紀錄(最新 5 筆)】\n━━━━━━━━━━━━━━\n' + list.join('\n') + '\n━━━━━━━━━━━━━━\n✨ 已同步至您的 Google 日曆!';
}
// 9. 生活雜事待辦查詢
if (kw === '雜事' || kw === '生活雜事' || kw === '待辦' || kw === '備忘' || kw === '代辦') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.TASK);
if (!sheet || sheet.getLastRow() <= 1) return '📌 目前生活雜事待辦清單尚無紀錄。';
const data = sheet.getDataRange().getValues();
const list = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
list.push('• 【' + row[1] + '】' + (row[2] ? row[2] + ' ' : '') + '[' + (row[4] || '待辦') + ']');
if (list.length >= 5) break;
}
return '📌 【生活雜事待辦清單(最新 5 筆)】\n━━━━━━━━━━━━━━\n' + list.join('\n') + '\n━━━━━━━━━━━━━━\n💡 點擊儀表板可一鍵切換完成狀態。';
}
// 10. 好物分享推薦查詢
if (kw === '好物' || kw === '好物分享' || kw === '推薦' || kw === '書籍' || kw === '工具') {
const sheet = ss.getSheetByName(CONFIG.SHEET_NAMES.GOODS);
if (!sheet || sheet.getLastRow() <= 1) return '🎁 目前好物分享庫尚無紀錄。';
const data = sheet.getDataRange().getValues();
const list = [];
for (let i = data.length - 1; i >= 1; i--) {
const row = data[i];
list.push('• 【' + row[1] + '】' + (row[3] ? row[3] + ' ' : '') + (row[2] && row[2] !== '無' ? '🔗 ' + row[2] : ''));
if (list.length >= 5) break;
}
return '🎁 【好物書籍與工具推薦(最新 5 筆)】\n━━━━━━━━━━━━━━\n' + list.join('\n') + '\n━━━━━━━━━━━━━━';
}
// 11. 記帳本支出統計查詢
if (kw === '花費' || kw === '記帳' || kw === '支出' || kw === '開銷' || kw === '記帳本') {
const expSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.EXPENSE);
if (!expSheet || expSheet.getLastRow() <= 1) return '💰 目前記帳本尚無紀錄。';
const data = expSheet.getDataRange().getValues();
let total = 0;
const recent = [];
for (let i = data.length - 1; i >= 1; i--) {
const r = data[i];
if (r[1] === '支出') {
total += (Number(r[4]) || 0);
if (recent.length < 3) {
recent.push('• ' + r[3] + ':$' + r[4] + ' (' + r[2] + ')');
}
}
}
return '💰 【記帳本支出統計】\n━━━━━━━━━━━━━━\n💵 累計總支出:$' + total.toLocaleString() + ' 元\n\n🕒 最近 3 筆消費:\n' + recent.join('\n') + '\n━━━━━━━━━━━━━━';
}
// 12. 跨分頁模糊關鍵字搜尋
const searchSheets = [
CONFIG.SHEET_NAMES.CONTACTS,
CONFIG.SHEET_NAMES.TEACHING_PROGRESS,
CONFIG.SHEET_NAMES.STUDENT,
CONFIG.SHEET_NAMES.PARENT_LOG,
CONFIG.SHEET_NAMES.HOMEWORK_MISSING,
CONFIG.SHEET_NAMES.TEACHING_IDEA,
CONFIG.SHEET_NAMES.CALENDAR,
CONFIG.SHEET_NAMES.TASK,
CONFIG.SHEET_NAMES.GOODS
];
const matched = [];
searchSheets.forEach(sName => {
const sheet = ss.getSheetByName(sName);
if (sheet && sheet.getLastRow() > 1) {
const data = sheet.getDataRange().getValues();
for (let i = data.length - 1; i >= 1; i--) {
const rowStr = data[i].join(' ');
if (rowStr.toLowerCase().includes(kw.toLowerCase())) {
matched.push('📑 [' + sName + '] ' + String(data[i][1] || '').substring(0, 20) + '... (' + String(data[i][0]).substring(0, 10) + ')');
if (matched.length >= 6) break;
}
}
}
});
if (matched.length === 0) {
return '🔍 查無包含「' + kw + '」的相關紀錄。\n\n💡 專屬分類反查指令:\n• /查 今日(聯絡簿與日曆)\n• /查 家長(聯繫訪談紀錄)\n• /查 備課(教學靈感庫)\n• /查 聯絡人(通訊錄)\n• /查 學生(概況與衝突)\n• /查 行事曆(行程活動)\n• /查 雜事(待辦清單)\n• /查 進度(教學進度)\n• /查 缺交(作業缺交)\n• /查 花費(支出記帳)';
}
return '🔍 【搜尋「' + kw + '」結果(前 ' + matched.length + ' 筆)】\n━━━━━━━━━━━━━━\n' + matched.join('\n') + '\n━━━━━━━━━━━━━━\n💡 請在瀏覽器開啟儀表板查看完整明細。';
}
// 5. 指令說明手冊 (/help)
if (lower === '/help' || lower === '/說明' || lower === '/指令' || lower === 'help' || lower === '選單') {
return '🤖 【全能教師智慧管家・指令與多模態秘笈】\n━━━━━━━━━━━━━━\n' +
'📸 【拍照與多模態上傳】\n' +
'• 拍名片 ➜ 自動解析姓名電話,同步存入 Google 聯絡人\n' +
'• 拍發票/收據 ➜ 自動解析金額並記帳\n' +
'• 拍研習海報 ➜ 自動擷取時間同步 Google 日曆\n' +
'• 拍黑白板/筆記 ➜ 自動整理重點歸檔\n\n' +
'🎙️ 【語音直錄】\n' +
'• 長按麥克風直接說話 ➜ 免打字自動聽寫歸檔\n\n' +
'⚡ 【快捷管理指令】\n' +
'• /名片 或 /聯絡人 ➜ 鎖定強制聯絡人模式\n' +
'• /日曆 或 /行事曆 ➜ 鎖定強制行事曆模式\n' +
'• /發票 或 /記帳 ➜ 鎖定強制記帳模式\n' +
'• 改存 聯絡人 ➜ 事後將上一筆轉移至聯絡人\n' +
'• 改存 行事曆 ➜ 事後將上一筆轉移至行事曆\n' +
'• /model ➜ 查詢與切換 Gemini AI 模型\n' +
'• /model auto ➜ 恢復預設智慧動態輪詢\n' +
'• /查 聯絡人 ➜ 查詢通訊錄與電話\n' +
'• /查 進度 ➜ 查看各班最新教學進度\n' +
'• /查 缺交 ➜ 查看未補交作業清單\n' +
'• /查 今日 ➜ 查看今日聯絡簿與行程\n' +
'• /查 花費 ➜ 查看本月累計支出\n' +
'• /查 <關鍵字> ➜ 跨表快速搜尋紀錄\n' +
'• /del <關鍵字> ➜ 精準搜尋並刪除指定紀錄\n' +
'• /undo ➜ 撤回並刪除上一筆存入的資料\n' +
'• /help ➜ 顯示本指令說明清單\n━━━━━━━━━━━━━━\n' +
'✨ 隨時在 LINE 傳送文字、語音或名片/發票圖片開始使用!';
}
return null;
}
// 取得 LINE 訊息多媒體檔案 Blob
function getLineMessageContentBlob(messageId) {
const url = 'https://api-data.line.me/v2/bot/message/' + messageId + '/content';
const token = CONFIG.LINE_ACCESS_TOKEN;
const options = {
method: 'get',
headers: { 'Authorization': 'Bearer ' + token },
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const code = response.getResponseCode();
if (code !== 200) {
throw new Error('LINE 伺服器回傳狀態碼 ' + code + ',請確認 LINE_ACCESS_TOKEN 是否正確!');
}
return response.getBlob();
}
// ==================== 4. Gemini AI 語意解析核心 ====================
function getCandidateModels(apiKey) {
const manualModel = PropertiesService.getScriptProperties().getProperty('GEMINI_MODEL');
const baseDefaults = [
'gemini-2.5-flash',
'gemini-2.0-flash',
'gemini-1.5-flash-latest',
'gemini-1.5-flash',
'gemini-2.5-flash-lite',
'gemini-2.0-flash-lite'
];
if (manualModel) {
return [manualModel, ...baseDefaults.filter(m => m !== manualModel)];
}
try {
const listUrl = 'https://generativelanguage.googleapis.com/v1beta/models?key=' + apiKey;
const response = UrlFetchApp.fetch(listUrl, { method: 'get', muteHttpExceptions: true });
const json = JSON.parse(response.getContentText());
if (json.models && json.models.length > 0) {
const validModels = json.models
.filter(m => m.supportedGenerationMethods && m.supportedGenerationMethods.includes('generateContent'))
.map(m => m.name.replace(/^models\//, ''))
.filter(name => !/(omni|embed|tts|stt|imagen|realtime)/i.test(name))
.filter(name => /flash/i.test(name));
if (validModels.length > 0) {
validModels.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
return [...new Set([...validModels, ...baseDefaults])];
}
}
} catch (e) {
console.warn('[動態探測] 查詢清單失敗,使用預設清單:', e);
}
return baseDefaults;
}
function getSystemPrompt() {
const now = new Date();
const currentDateTimeStr = Utilities.formatDate(now, 'Asia/Taipei', 'yyyy-MM-dd HH:mm (E)');
return `
你是專業的「全能教育與生活智慧管家」,專門協助教師、導師與上班族自動分析並整理訊息(支援文字、名片/發票/海報照片辨識、語音聽寫)。
當前台灣時間為:${currentDateTimeStr}。
【🌟 圖片與多模態輸入的「核心優先級判定規則」】:
1. 📇 圖片為「名片 / 講師介紹卡 / 通訊錄截圖」時(畫面中包含姓名、電話、手機、Email、學校/公司、職稱):
👉 【必須 100% 強制判定為「聯絡人」】!
⚠️ 嚴格禁止誤判為行事曆!即使名片上有年份、創立日期、營業時間、活動名稱或地址,只要主要主體是「名片 / 介紹人」,一律嚴格歸類為「聯絡人」!
2. 💰 圖片為「發票 / 收據 / 結帳單 / 繳費單」時(畫面中包含消費品項、總金額):
👉 【必須判定為「記帳」】!
3. 📅 圖片為「研習海報 / 講座宣傳單 / 會議簡章 / 公文」時(畫面主體為公告某個特定活動的舉辦日期與時間):
👉 【判定為「行事曆」】!
4. 📢 圖片為「黑白板筆記 / 聯絡簿手寫字」時:
👉 【判定為「今日聯絡簿」或「備課靈感」】!
請將使用者的輸入準確歸類為下列十二個分類之一:
1. "聯絡人":記錄新增聯絡人通訊錄、交換的名片、教師/同仁/家長/廠商之姓名、電話、手機、Email、學校或任職單位與職稱。(例如:拍名片照片、或輸入「新增聯絡人:王大明,0912-345678,ming@gmail.com,大仁國中理化老師」)
2. "教學進度":記錄各班上課進度、章節單元、進度頁數、交代作業或給小老師/學生的交代事項。(例如:「205班上到第二章,P.19頁,已提醒該班小老師要收作業簿給老師」)
3. "今日聯絡簿":包含作業、攜帶物品、發放回條/通知單、導師叮嚀事項。(例如:「今日聯絡簿:國習P20-22、數作第5回、發下流感疫苗意願書週五前交、明天穿運動服帶跳繩」)
4. "家長聯繫":記錄與家長的電話、面談、LINE 溝通紀錄、交代事項與承諾。(例如:「下午跟小明媽媽通電話,反應小明作業拖延,已建議設定計時器,下週五再追蹤」)
5. "備課靈感":教學活動設計、探究題目、課堂提問、教案靈感、實驗點子。(例如:「備課靈感:國二浮力單元可以用沉浮子玩具探究氣體體積與浮力關係」)
6. "作業缺交":登記缺交作業的學生座號或姓名、原因與補交期限。(例如:「國語習作沒交:5號、12號、23號,12號說明天補交」)
7. "學生概況":學生的課堂狀況、常規紀律、同儕衝突、獎懲、受傷送保健室。(注意:若有同行或關聯學生,務必拆分出主要學生與關聯學生)
8. "記帳":花費、買東西、發票收據、收入、繳費等金錢相關。(例如:「午餐排骨便當 110 元」或辨識出的發票總金額與明細)
9. "行事曆":特定時間的行程、會議、代辦、研習活動、預約。(例如:「明天下午 2 點在 3 樓會議室開會」或研習海報上的活動時間與地點)
10. "生活雜事":日常備忘、非特定時間的雜項待辦。(例如:「週末記得買濾水器濾心」)
11. "好物分享":推薦或收藏的書籍、工具、軟體、網購商品連結。(例如:「推薦這款人體工學椅超好坐特價3990 https://...」)
12. "AI諮詢":使用者提問請求解答、備課出題或閒聊。(例如:「幫我想三個國中理化浮力的生活情境探究題」)
你必須嚴格輸出 JSON 物件,格式如下:
{
"category": "聯絡人" | "教學進度" | "今日聯絡簿" | "家長聯繫" | "備課靈感" | "作業缺交" | "學生概況" | "記帳" | "行事曆" | "生活雜事" | "好物分享" | "AI諮詢",
"summary": "一句話摘要,15字以內",
"contact": {
"name": "姓名(如 王大明)",
"phone": "電話或手機號碼,若無填'無'",
"email": "電子郵件,若無填'無'",
"company_title": "單位、學校、任教科目或職稱(如 大仁國中 理化老師 / 教務主任)",
"notes": "其他備註或介紹人,若無填'無'"
},
"teaching_progress": {
"class_name": "班級名稱(如 205班、一年三班)",
"subject": "科目或冊別(如 國文、理化、第二冊,若未提及填'未註明')",
"progress": "章節單元與進度頁數(如 第二章 P.19)",
"notes": "交代事項、作業繳交提醒或備註,若無填'無'"
},
"contact_book": {
"homework": "條列今日作業,例如:1. 國習P20-22\\n2. 數作第5回",
"bring_items": "條列攜帶物品,若無填'無'",
"notices": "發放通知單/回條與繳交期限,若無填'無'",
"teacher_words": "導師的話或生活提醒,若無填'無'"
},
"parent_log": {
"student_name": "學生姓名/座號",
"parent_role": "家長稱謂(如媽媽、爸爸、監護人)",
"contact_type": "電話/面談/LINE/聯絡簿/其他",
"content": "溝通主旨與詳細內容",
"action_plan": "約定事項或後續追蹤承諾,若無填'無'"
},
"teaching_idea": {
"target_grade_subject": "適用年級或科目(如國中理化、國小數學、全年級)",
"topic": "主題單元名稱",
"core_idea": "核心點子、教學提問或活動設計詳情",
"materials_needed": "所需器材或教材備註,若無填'無'"
},
"homework_missing": {
"assignment_name": "作業名稱(如國語習作、數學學習單)",
"missing_students": "缺交座號或學生名單(如 5號, 12號, 23號)",
"reason_deadline": "原因與補交期限說明,若無填'明日補交'",
"status": "未補交/待追蹤"
},
"student": {
"primary_student": "主要學生姓名/座號",
"related_students": "關聯/同行/衝突學生姓名,若無填'無'",
"event_type": "學習表現/常規紀律/同儕衝突/受傷處置/其他",
"detail": "事件詳細內容與觀察處置",
"note": "後續備註,若無填'無'"
},
"expense": {
"type": "支出" | "收入",
"item": "項目名稱",
"amount": 100,
"classification": "餐飲/交通/購物/文具教材/娛樂/其他",
"note": "備註,無則填'無'"
},
"calendar": {
"title": "事項名稱",
"target_time": "推算出的具體日期時間(如 2026/08/24 14:00),無指定填'待定'",
"location": "地點,無則填'無'",
"note": "備註,無則填'無'"
},
"task": {
"title": "雜事主旨",
"detail": "詳細說明",
"note": "備註,無則填'無'"
},
"goods": {
"name": "好物/工具/書籍名稱",
"url": "擷取出的網址連結,若無填'無'",
"reason": "推薦原因或心得重點",
"note": "價格或其他備註,無則填'無'"
},
"ai_reply": "若為 AI諮詢,請在此給出精準熱情且專業的回覆;若非 AI諮詢則留空即可"
}
`;
}
function executeGeminiGenerate(parts) {
const apiKey = CONFIG.GEMINI_API_KEY;
if (!apiKey || apiKey === 'YOUR_GEMINI_API_KEY') {
return {
category: 'AI諮詢',
summary: '未設定 Gemini API 金鑰',
ai_reply: '⚠️ 系統尚未設定 GEMINI_API_KEY,請在 Google 試算表「擴充功能 > Apps Script > 專案設定 > 指令碼屬性」填入您的 API 金鑰。'
};
}
const candidateModels = getCandidateModels(apiKey);
let lastErrorMessage = '';
for (const modelName of candidateModels) {
const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + modelName + ':generateContent?key=' + apiKey;
const payload = {
contents: [
{
role: 'user',
parts: parts
}
],
generationConfig: {
responseMimeType: 'application/json',
temperature: 0.2
}
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(url, options);
const json = JSON.parse(response.getContentText());
if (json.error) {
lastErrorMessage = json.error.message;
console.warn('[模型輪詢] ' + modelName + ' 失敗: ' + lastErrorMessage + ',立即切換下一個模型...');
continue;
}
if (json.candidates && json.candidates.length > 0 && json.candidates[0].content && json.candidates[0].content.parts) {
const candidateText = json.candidates[0].content.parts[0].text;
console.log('[模型成功] 使用模型: ' + modelName);
return JSON.parse(candidateText);
}
} catch (e) {
lastErrorMessage = e.toString();
console.warn('[模型輪詢] ' + modelName + ' 連線異常: ' + e + ',嘗試下一個...');
}
}
return {
category: 'AI諮詢',
summary: 'AI 解析失敗',
ai_reply: '⚠️ AI 解析發生錯誤(' + lastErrorMessage + '),請檢查 Gemini API Key 是否正確。'
};
}
function parseWithGemini(userText) {
const systemPrompt = getSystemPrompt();
const parts = [
{ text: systemPrompt + '\n\n使用者輸入:\n' + userText }
];
return executeGeminiGenerate(parts);
}
function parseWithGeminiMultimodal(mediaObj) {
const systemPrompt = getSystemPrompt();
let mediaNotice = '';
if (mediaObj.type === 'image') {
const forceNotice = mediaObj.forceCategory
? '\n\n【🔥 核心特別指令:使用者已明確強制指定此圖片必須歸類為「' + mediaObj.forceCategory + '」!請務必將 category 設為 "' + mediaObj.forceCategory + '" 並提取該分類對應的所有結構化欄位!】'
: '';
mediaNotice = '\n\n【重要圖片多模態辨識指示】:\n' +
'1. 若圖片為【名片/商務卡/講師介紹】(畫面有人名、職稱、電話、Email、機構名):必須 100% 強制歸類為 "category": "聯絡人",請提取姓名 (name)、電話 (phone)、電子郵件 (email)、任職單位與職稱 (company_title)、備註 (notes)。絕不可誤判為行事曆!\n' +
'2. 若圖片為【發票/收據】:必須歸類為 "category": "記帳" 並提取總金額。\n' +
'3. 若圖片為【研習/活動海報】:必須歸類為 "category": "行事曆" 並提取時間地點。\n' +
'4. 請仔細辨識圖片所有文字,輸出最精確的結構化 JSON 物件。' + forceNotice;
} else {
mediaNotice = '\n\n【注意:使用者上傳了一段語音訊息。請先將音訊內容完整聽寫為繁體中文,再進行語意分析與結構化 JSON 分類】';
}
const parts = [
{ text: systemPrompt + mediaNotice },
{
inlineData: {
mimeType: mediaObj.mimeType,
data: mediaObj.base64Data
}
}
];
return executeGeminiGenerate(parts);
}
// ==================== 5. Google 試算表與 Google 聯絡人自動分類儲存 ====================
function saveToSpreadsheet(parsed, rawText) {
const ssId = CONFIG.SPREADSHEET_ID;
if (!ssId || ssId === 'YOUR_SPREADSHEET_ID') {
return { success: false, message: '未設定 Google 試算表 ID' };
}
let ss;
try {
ss = SpreadsheetApp.openById(ssId);
} catch (e) {
return { success: false, message: '無法開啟試算表:' + e.message };
}
const now = new Date();
const timestamp = Utilities.formatDate(now, 'Asia/Taipei', 'yyyy-MM-dd HH:mm:ss');
const cat = parsed.category;
if (cat === 'AI諮詢') {
return { success: true, isAiQuery: true, sheetName: null };
}
ensureSheetHeaders(ss);
let sheetName = '';
let rowData = [];
switch (cat) {
case '聯絡人':
sheetName = CONFIG.SHEET_NAMES.CONTACTS;
const ct = parsed.contact || {};
const contactSyncResult = createGoogleContact(ct);
const contactStatus = contactSyncResult.success ? '✅ 已同步Google聯絡人' : '未同步';
rowData = [
timestamp,
ct.name || parsed.summary || rawText,
ct.phone || '無',
ct.email || '無',
ct.company_title || '未註明',
ct.notes || '無',
contactStatus
];
parsed._contactSync = contactSyncResult.success;
break;
case '教學進度':
sheetName = CONFIG.SHEET_NAMES.TEACHING_PROGRESS;
const tp = parsed.teaching_progress || {};
rowData = [
timestamp,
tp.class_name || '未指定班級',
tp.subject || '未指定',
tp.progress || parsed.summary || rawText,
tp.notes || '無'
];
break;
case '今日聯絡簿':
sheetName = CONFIG.SHEET_NAMES.CONTACT_BOOK;
const cb = parsed.contact_book || {};
rowData = [
timestamp,
cb.homework || '無',
cb.bring_items || '無',
cb.notices || '無',
cb.teacher_words || '無'
];
break;
case '家長聯繫':
sheetName = CONFIG.SHEET_NAMES.PARENT_LOG;
const pl = parsed.parent_log || {};
rowData = [
timestamp,
pl.student_name || parsed.summary || rawText,
pl.parent_role || '家長',
pl.contact_type || '電話/面談',
pl.content || rawText,
pl.action_plan || '無'
];
break;
case '備課靈感':
sheetName = CONFIG.SHEET_NAMES.TEACHING_IDEA;
const ti = parsed.teaching_idea || {};
rowData = [
timestamp,
ti.target_grade_subject || '未註明',
ti.topic || parsed.summary || '未指定主題',
ti.core_idea || rawText,
ti.materials_needed || '無'
];
break;
case '作業缺交':
sheetName = CONFIG.SHEET_NAMES.HOMEWORK_MISSING;
const hm = parsed.homework_missing || {};
rowData = [
timestamp,
hm.assignment_name || parsed.summary || rawText,
hm.missing_students || '未指定',
hm.reason_deadline || '明日補交',
hm.status || '未補交'
];
break;
case '學生概況':
sheetName = CONFIG.SHEET_NAMES.STUDENT;
const stu = parsed.student || {};
rowData = [
timestamp,
stu.primary_student || parsed.summary || rawText,
stu.related_students || '無',
stu.event_type || '一般紀錄',
stu.detail || rawText,
stu.note || '無'
];
break;
case '記帳':
sheetName = CONFIG.SHEET_NAMES.EXPENSE;
const exp = parsed.expense || {};
rowData = [
timestamp,
exp.type || '支出',
exp.classification || '一般',
exp.item || parsed.summary || rawText,
Number(exp.amount) || 0,
exp.note || '無'
];
break;
case '行事曆':
sheetName = CONFIG.SHEET_NAMES.CALENDAR;
const cal = parsed.calendar || {};
const calResult = createGoogleCalendarEvent(cal);
const calStatus = calResult.success ? '已同步Google日曆' : '未完成';
rowData = [
timestamp,
cal.title || parsed.summary,
cal.target_time || '待定',
cal.location || '無',
cal.note || '無',
calStatus
];
parsed._calSync = calResult.success;
break;
case '生活雜事':
sheetName = CONFIG.SHEET_NAMES.TASK;
const tsk = parsed.task || {};
rowData = [
timestamp,
tsk.title || parsed.summary,
tsk.detail || rawText,
tsk.note || '無',
'待辦'
];
break;
case '好物分享':
sheetName = CONFIG.SHEET_NAMES.GOODS;
const gds = parsed.goods || {};
rowData = [
timestamp,
gds.name || parsed.summary,
gds.url || '無',
gds.reason || rawText,
gds.note || '無'
];
break;
default:
return { success: false, message: '未知分類: ' + cat };
}
const targetSheet = ss.getSheetByName(sheetName);
if (targetSheet) {
targetSheet.appendRow(rowData);
const lastRow = targetSheet.getLastRow();
PropertiesService.getScriptProperties().setProperty('LAST_SAVED_RECORD', JSON.stringify({
sheetName: sheetName,
row: lastRow,
summary: parsed.summary || (rowData[1] ? String(rowData[1]) : '')
}));
return { success: true, sheetName: sheetName, rowData: rowData };
} else {
return { success: false, message: '找不到分頁: ' + sheetName };
}
}
// 建立並美化 11 大分頁與表頭樣式
function ensureSheetHeaders(ss) {
const schema = [
{
name: CONFIG.SHEET_NAMES.CONTACTS,
color: '#303f9f',
headers: ['記錄時間', '姓名', '電話/手機', 'Email', '單位/學校/職稱', '備註說明', 'Google聯絡人同步狀態']
},
{
name: CONFIG.SHEET_NAMES.TEACHING_PROGRESS,
color: '#00838f',
headers: ['記錄時間', '班級', '科目/冊別', '章節單元與進度頁數', '交代事項與備註']
},
{
name: CONFIG.SHEET_NAMES.CONTACT_BOOK,
color: '#00695c',
headers: ['記錄時間', '今日作業', '攜帶物品', '發放通知單/回條(繳交日)', '導師叮嚀與備註']
},
{
name: CONFIG.SHEET_NAMES.PARENT_LOG,
color: '#c2185b',
headers: ['記錄時間', '學生姓名/座號', '家長稱謂', '溝通管道', '溝通主旨與詳細內容', '約定追蹤事項']
},
{
name: CONFIG.SHEET_NAMES.TEACHING_IDEA,
color: '#2e7d32',
headers: ['記錄時間', '適用年級/科目', '主題單元', '核心教學點子/活動提問設計', '所需器材/備註']
},
{
name: CONFIG.SHEET_NAMES.HOMEWORK_MISSING,
color: '#d84315',
headers: ['記錄時間', '作業名稱', '缺交名單/座號', '原因與補交期限', '追蹤狀態']
},
{
name: CONFIG.SHEET_NAMES.STUDENT,
color: '#b71c1c',
headers: ['記錄時間', '主要學生', '關聯學生(同行/衝突)', '事件類型', '詳細觀察與處置', '後續備註']
},
{
name: CONFIG.SHEET_NAMES.EXPENSE,
color: '#1b5e20',
headers: ['記錄時間', '收支類型', '分類', '項目名稱', '金額(元)', '備註']
},
{
name: CONFIG.SHEET_NAMES.CALENDAR,
color: '#0d47a1',
headers: ['記錄時間', '事項名稱', '預定日期時間', '地點', '備註', '完成狀態']
},
{
name: CONFIG.SHEET_NAMES.TASK,
color: '#e65100',
headers: ['記錄時間', '事項主旨', '詳細內容說明', '備註', '處理狀態']
},
{
name: CONFIG.SHEET_NAMES.GOODS,
color: '#4a148c',
headers: ['記錄時間', '好物/書籍/工具名稱', '網址連結', '推薦心得與特色', '價格/備註']
}
];
schema.forEach(item => {
let sheet = ss.getSheetByName(item.name);
if (!sheet) {
sheet = ss.insertSheet(item.name);
sheet.appendRow(item.headers);
const headerRange = sheet.getRange(1, 1, 1, item.headers.length);
headerRange.setBackground(item.color)
.setFontColor('#ffffff')
.setFontWeight('bold')
.setHorizontalAlignment('center');
sheet.setFrozenRows(1);
sheet.setRowHeight(1, 35);
for (let i = 1; i <= item.headers.length; i++) {
sheet.setColumnWidth(i, 165);
}
}
});
}
// ==================== 6. 格式化 LINE 回覆訊息 ====================
function formatReplyMessage(parsed, writeResult) {
const cat = parsed.category;
if (cat === 'AI諮詢') {
return parsed.ai_reply || '管家收到您的訊息囉!';
}
if (!writeResult.success) {
return '⚠️ 記錄失敗:' + writeResult.message + '\n(請檢查試算表 ID 與授權)';
}
if (cat === '今日聯絡簿') {
const cb = parsed.contact_book || {};
const dateStr = Utilities.formatDate(new Date(), 'Asia/Taipei', 'yyyy/MM/dd (E)');
return '📢 【' + dateStr + ' 班級聯絡簿公告】\n(已存入試算表,可直接長按複製轉發班群👇)\n━━━━━━━━━━━━━━\n📚 【今日作業】\n' + (cb.homework || '無') + '\n\n🎒 【攜帶物品】\n' + (cb.bring_items || '無') + '\n\n📄 【重要通知 / 回條】\n' + (cb.notices || '無') + '\n\n❤️ 【導師叮嚀】\n' + (cb.teacher_words || '請家長撥冗簽閱聯絡簿,祝大家有美好的一天!') + '\n━━━━━━━━━━━━━━\n✨ 試算表【今日聯絡簿】已同步歸檔!';
}
let icon = '📝';
let detailText = '';
switch (cat) {
case '聯絡人':
icon = '👤';
const ct = parsed.contact || {};
const contactSyncText = parsed._contactSync ? '(✅ 已自動新增至 Google 聯絡人)' : '';
detailText = '• 姓名:' + (ct.name || '未註明') + ' ' + contactSyncText + '\n• 電話:' + (ct.phone || '無') + '\n• Email:' + (ct.email || '無') + '\n• 單位/職稱:' + (ct.company_title || '無') + '\n• 備註:' + (ct.notes || '無');
break;
case '教學進度':
icon = '📖';
const tp = parsed.teaching_progress || {};
detailText = '• 班級:' + (tp.class_name || '未指定') + '\n• 科目/冊別:' + (tp.subject || '未指定') + '\n• 章節進度:' + (tp.progress || '無') + '\n• 交代事項:' + (tp.notes || '無');
break;
case '家長聯繫':
icon = '📞';
const pl = parsed.parent_log || {};
detailText = '• 學生:' + (pl.student_name || '未指定') + '(' + (pl.parent_role || '家長') + ')\n• 管道:' + (pl.contact_type || '電話') + '\n• 內容:' + (pl.content || '無') + '\n• 約定追蹤:' + (pl.action_plan || '無');
break;
case '備課靈感':
icon = '💡';
const ti = parsed.teaching_idea || {};
detailText = '• 科目/年級:' + (ti.target_grade_subject || '未註明') + '\n• 主題:' + (ti.topic || '未指定') + '\n• 教學點子:' + (ti.core_idea || '無') + '\n• 所需器材:' + (ti.materials_needed || '無');
break;
case '作業缺交':
icon = '📝';
const hm = parsed.homework_missing || {};
detailText = '• 作業:' + (hm.assignment_name || '未指定') + '\n• 缺交名單:' + (hm.missing_students || '無') + '\n• 原因/期限:' + (hm.reason_deadline || '無') + '\n• 狀態:' + (hm.status || '未補交');
break;
case '學生概況':
icon = '👨🎓';
const stu = parsed.student || {};
detailText = '• 主要學生:' + (stu.primary_student || '未指定') + '\n• 關聯學生:' + (stu.related_students || '無') + '\n• 類別:' + (stu.event_type || '一般') + '\n• 處置紀錄:' + (stu.detail || '無') + '\n• 後續:' + (stu.note || '無');
break;
case '記帳':
icon = '💰';
const exp = parsed.expense || {};
detailText = '• 類型:' + (exp.type || '支出') + '(' + (exp.classification || '一般') + ')\n• 項目:' + (exp.item || '未註明') + '\n• 金額:$' + (exp.amount || 0) + ' 元\n• 備註:' + (exp.note || '無');
break;
case '行事曆':
icon = '📅';
const cal = parsed.calendar || {};
const syncText = parsed._calSync ? '(✅ 已同步建立至 Google 日曆)' : '';
detailText = '• 事項:' + (cal.title || '未命名') + '\n• 時間:' + (cal.target_time || '待定') + ' ' + syncText + '\n• 地點:' + (cal.location || '無') + '\n• 備註:' + (cal.note || '無');
break;
case '生活雜事':
icon = '📌';
const tsk = parsed.task || {};
detailText = '• 事項:' + (tsk.title || '未命名') + '\n• 內容:' + (tsk.detail || '無') + '\n• 備註:' + (tsk.note || '無');
break;
case '好物分享':
icon = '🎁';
const gds = parsed.goods || {};
detailText = '• 名稱:' + (gds.name || '未命名') + '\n• 連結:' + (gds.url || '無') + '\n• 心得:' + (gds.reason || '無');
break;
}
return icon + ' 【已為您自動歸檔至「' + writeResult.sheetName + '」】\n━━━━━━━━━━━━━━\n' + detailText + '\n━━━━━━━━━━━━━━\n✨ 試算表已同步更新完成!\n(💡 輸入 /undo 撤回上一筆,或 /del <關鍵字> 刪除特定紀錄)';
}
// ==================== 7. LINE API 發送核心 (Reply & Push) ====================
function sendLineReply(replyToken, text) {
const url = 'https://api.line.me/v2/bot/message/reply';
const token = CONFIG.LINE_ACCESS_TOKEN;
if (!token || token === 'YOUR_LINE_ACCESS_TOKEN') {
console.error('LINE Access Token 未設定');
return;
}
const payload = {
replyToken: replyToken,
messages: [{ type: 'text', text: text }]
};
const options = {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': 'Bearer ' + token },
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
UrlFetchApp.fetch(url, options);
} catch (e) {
console.error('LINE Reply 發送失敗:', e);
}
}
function sendLinePush(userId, text) {
const url = 'https://api.line.me/v2/bot/message/push';
const token = CONFIG.LINE_ACCESS_TOKEN;
if (!token || token === 'YOUR_LINE_ACCESS_TOKEN' || !userId) {
console.error('LINE Push 未設定 token 或無目標 userId');
return;
}
const payload = {
to: userId,
messages: [{ type: 'text', text: text }]
};
const options = {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': 'Bearer ' + token },
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
UrlFetchApp.fetch(url, options);
console.log('[LINE Push 成功] 推播給: ' + userId);
} catch (e) {
console.error('LINE Push 發送失敗:', e);
}
}
// ==================== 8. 每日早安晨報與定時觸發器 ====================
function sendDailyMorningReport() {
const userId = PropertiesService.getScriptProperties().getProperty('ADMIN_USER_ID');
if (!userId) {
console.warn('尚未記錄 ADMIN_USER_ID,請先在 LINE 與管家傳送任意訊息以完成綁定。');
return;
}
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const now = new Date();
const dateStr = Utilities.formatDate(now, 'Asia/Taipei', 'yyyy/MM/dd (E)');
let calendarBrief = '無特定行程';
try {
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
const events = CalendarApp.getDefaultCalendar().getEvents(todayStart, todayEnd);
if (events && events.length > 0) {
calendarBrief = events.map(ev => '• ' + ev.getTitle() + ' (' + Utilities.formatDate(ev.getStartTime(), 'Asia/Taipei', 'HH:mm') + ')').join('\n');
}
} catch (calErr) {
console.warn('讀取 Google 日曆失敗:', calErr);
}
let missingBrief = '無缺交,全員到齊 🎉';
try {
const missingSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.HOMEWORK_MISSING);
if (missingSheet && missingSheet.getLastRow() > 1) {
const data = missingSheet.getDataRange().getValues();
const unsubmitted = [];
for (let i = 1; i < data.length; i++) {
if (data[i][4] === '未補交') {
unsubmitted.push('• ' + data[i][1] + ':' + data[i][2]);
}
}
if (unsubmitted.length > 0) {
missingBrief = unsubmitted.slice(0, 5).join('\n');
}
}
} catch (mErr) {}
let taskBrief = '無待辦事項';
try {
const taskSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.TASK);
if (taskSheet && taskSheet.getLastRow() > 1) {
const data = taskSheet.getDataRange().getValues();
const todos = [];
for (let i = 1; i < data.length; i++) {
if (data[i][4] === '待辦') {
todos.push('• ' + data[i][1]);
}
}
if (todos.length > 0) {
taskBrief = todos.slice(0, 5).join('\n');
}
}
} catch (tErr) {}
const morningMessage = '🌅 【' + dateStr + ' 早安!全能管家今日晨報】\n━━━━━━━━━━━━━━\n' +
'📅 【今日行程與會議】\n' + calendarBrief + '\n\n' +
'📝 【待追蹤作業缺交】\n' + missingBrief + '\n\n' +
'📌 【待處理生活備忘】\n' + taskBrief + '\n━━━━━━━━━━━━━━\n' +
'✨ 祝老師今天教學順利、心情愉快!💪';
sendLinePush(userId, morningMessage);
}
function setupDailyTrigger() {
removeDailyTrigger();
ScriptApp.newTrigger('sendDailyMorningReport')
.timeBased()
.atHour(7)
.nearMinute(30)
.everyDays(1)
.inTimezone('Asia/Taipei')
.create();
Logger.log('✅ 每日早安晨報定時觸發器已建立(每天早上 07:30 推播)!');
}
function removeDailyTrigger() {
const triggers = ScriptApp.getProjectTriggers();
triggers.forEach(t => {
if (t.getHandlerFunction() === 'sendDailyMorningReport') {
ScriptApp.deleteTrigger(t);
}
});
Logger.log('🧹 已清除每日早安晨報定時觸發器。');
}
// ==================== 9. Google 日曆與 Google 聯絡人自動同步核心 ====================
function createGoogleCalendarEvent(cal) {
if (!cal || !cal.title) return { success: false, error: '無事項標題' };
try {
const calendar = CalendarApp.getDefaultCalendar();
const targetTimeStr = cal.target_time;
const title = cal.title;
const location = (cal.location && cal.location !== '無') ? cal.location : '';
const note = (cal.note && cal.note !== '無') ? cal.note : '無';
const description = '【LINE 全能教師管家自動建立】\n• 事項:' + title + '\n• 地點:' + (location || '無') + '\n• 備註:' + note;
if (targetTimeStr && targetTimeStr !== '待定') {
const parsedDate = new Date(targetTimeStr.replace(/-/g, '/'));
if (!isNaN(parsedDate.getTime())) {
if (targetTimeStr.includes(':')) {
const endTime = new Date(parsedDate.getTime() + 60 * 60 * 1000);
calendar.createEvent(title, parsedDate, endTime, {
location: location,
description: description
});
return { success: true, isAllDay: false };
} else {
calendar.createAllDayEvent(title, parsedDate, {
location: location,
description: description
});
return { success: true, isAllDay: true };
}
}
}
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
calendar.createAllDayEvent('[待定] ' + title, tomorrow, {
location: location,
description: description + '\n(原始推算時間:' + targetTimeStr + ')'
});
return { success: true, isAllDay: true };
} catch (e) {
console.error('Google 日曆同步異常:', e);
return { success: false, error: e.toString() };
}
}
function createGoogleContact(ct) {
if (!ct || !ct.name || ct.name === '未註明') return { success: false, error: '無姓名' };
try {
const fullName = String(ct.name).trim();
let familyName = '';
let givenName = fullName;
if (fullName.length === 2) {
familyName = fullName.substring(0, 1);
givenName = fullName.substring(1);
} else if (fullName.length === 3) {
familyName = fullName.substring(0, 1);
givenName = fullName.substring(1);
} else if (fullName.length === 4) {
familyName = fullName.substring(0, 2);
givenName = fullName.substring(2);
}
const email = (ct.email && ct.email !== '無' && ct.email.includes('@')) ? ct.email.trim() : '';
const phone = (ct.phone && ct.phone !== '無') ? String(ct.phone).trim() : '';
const companyTitle = (ct.company_title && ct.company_title !== '未註明') ? ct.company_title.trim() : '';
const notes = (ct.notes && ct.notes !== '無') ? ct.notes.trim() : '';
const noteContent = '【LINE 全能智慧管家自動建立】\n• 單位/職稱:' + companyTitle + '\n• 備註:' + notes;
const personPayload = {
names: [
{
familyName: familyName,
givenName: givenName,
displayName: fullName
}
]
};
if (phone) {
personPayload.phoneNumbers = [{ value: phone, type: 'mobile' }];
}
if (email) {
personPayload.emailAddresses = [{ value: email, type: 'work' }];
}
if (companyTitle) {
personPayload.organizations = [{ name: companyTitle, title: '教師/同仁/教育夥伴' }];
}
if (noteContent) {
personPayload.biographies = [{ value: noteContent, contentType: 'TEXT_PLAIN' }];
}
if (typeof People !== 'undefined' && People.People && People.People.createContact) {
const resp = People.People.createContact(personPayload);
console.log('[Google 聯絡人建立成功 (Advanced Service)] ' + fullName + ' (' + phone + ')');
return { success: true, resourceName: resp.resourceName };
}
const token = ScriptApp.getOAuthToken();
const url = 'https://people.googleapis.com/v1/people:createContact';
const options = {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': 'Bearer ' + token },
payload: JSON.stringify(personPayload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const respCode = response.getResponseCode();
const respText = response.getContentText();
if (respCode === 200 || respCode === 201) {
const respJson = JSON.parse(respText);
console.log('[Google 聯絡人建立成功 (REST)] ' + fullName + ' (' + phone + ')');
return { success: true, resourceName: respJson.resourceName };
} else {
console.warn('[Google 聯絡人建立失敗] HTTP ' + respCode + ': ' + respText);
return { success: false, error: 'People API 回傳 (' + respCode + '): ' + respText };
}
} catch (e) {
console.error('Google 聯絡人同步異常:', e);
return { success: false, error: e.toString() };
}
}
// ==================== 10. 個人雲端儀表板 HTML 渲染與 google.script.run 原生核心 ====================
function renderDashboardHtml() {
return "<!DOCTYPE html>\n<html lang=\"zh-TW\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>全能教師智慧管家・個人雲端儀表板</title>\n <link href=\"https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&family=Noto+Sans+TC:wght@400;500;700&display=swap\" rel=\"stylesheet\">\n <style>\n :root {\n --bg-primary: #F8FAFC;\n --bg-card: #FFFFFF;\n --text-main: #1E293B;\n --text-muted: #64748B;\n --accent-orange: #F97316;\n --accent-orange-light: #FFF7ED;\n --accent-green: #10B981;\n --accent-blue: #0284C7;\n --accent-purple: #6366F1;\n --border-color: #E2E8F0;\n --card-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.05);\n }\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n font-family: 'Plus Jakarta Sans', 'Noto Sans TC', sans-serif;\n background-color: var(--bg-primary);\n color: var(--text-main);\n padding: 16px;\n max-width: 1240px;\n margin: 0 auto;\n min-height: 100vh;\n }\n header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 16px 20px;\n background: var(--bg-card);\n border-radius: 16px;\n box-shadow: var(--card-shadow);\n margin-bottom: 20px;\n flex-wrap: wrap;\n gap: 12px;\n }\n .brand-title { display: flex; align-items: center; gap: 12px; }\n .brand-icon {\n width: 44px;\n height: 44px;\n background: var(--accent-orange-light);\n color: var(--accent-orange);\n border-radius: 12px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 24px;\n }\n .brand-text h1 { font-size: 1.25rem; font-weight: 800; color: var(--text-main); }\n .brand-text p { font-size: 0.85rem; color: var(--text-muted); }\n .header-actions { display: flex; gap: 10px; }\n .btn {\n padding: 10px 16px;\n border-radius: 10px;\n font-weight: 600;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n border: none;\n font-size: 0.9rem;\n transition: all 0.2s;\n }\n .btn-refresh { background: #F1F5F9; color: #475569; }\n .btn-refresh:hover { background: #E2E8F0; }\n .btn-add { background: var(--accent-orange); color: white; }\n .btn-add:hover { opacity: 0.9; box-shadow: 0 4px 12px rgba(249,115,22,0.3); }\n .stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n gap: 16px;\n margin-bottom: 24px;\n }\n .stat-card {\n background: var(--bg-card);\n padding: 20px;\n border-radius: 16px;\n box-shadow: var(--card-shadow);\n border-left: 4px solid var(--accent-purple);\n }\n .stat-card.orange { border-left-color: var(--accent-orange); }\n .stat-card.blue { border-left-color: var(--accent-blue); }\n .stat-card.green { border-left-color: var(--accent-green); }\n .stat-label { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 6px; }\n .stat-value { font-size: 1.5rem; font-weight: 800; color: var(--text-main); }\n .tabs-wrapper {\n display: flex;\n gap: 8px;\n overflow-x: auto;\n padding-bottom: 12px;\n margin-bottom: 16px;\n scrollbar-width: thin;\n }\n .tab-btn {\n background: var(--bg-card);\n border: 1px solid var(--border-color);\n padding: 10px 18px;\n border-radius: 12px;\n font-size: 0.9rem;\n font-weight: 600;\n color: var(--text-muted);\n cursor: pointer;\n white-space: nowrap;\n transition: all 0.2s;\n }\n .tab-btn.active {\n background: var(--accent-orange);\n color: white;\n border-color: var(--accent-orange);\n box-shadow: 0 4px 12px rgba(249, 115, 22, 0.25);\n }\n .content-card {\n background: var(--bg-card);\n border-radius: 16px;\n box-shadow: var(--card-shadow);\n padding: 20px;\n overflow: hidden;\n }\n .card-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 16px;\n flex-wrap: wrap;\n gap: 10px;\n }\n .card-header-left { display: flex; align-items: center; gap: 10px; }\n .card-header h2 { font-size: 1.15rem; font-weight: 700; }\n .badge-count {\n background: var(--accent-orange-light);\n color: var(--accent-orange);\n padding: 4px 10px;\n border-radius: 20px;\n font-size: 0.8rem;\n font-weight: 700;\n }\n .table-container {\n overflow-x: auto;\n max-height: 600px;\n }\n .data-table {\n width: 100%;\n border-collapse: collapse;\n text-align: left;\n font-size: 0.9rem;\n }\n .data-table th {\n background: #F8FAFC;\n padding: 12px 14px;\n font-weight: 700;\n color: var(--text-muted);\n border-bottom: 2px solid var(--border-color);\n position: sticky;\n top: 0;\n z-index: 10;\n white-space: nowrap;\n }\n .data-table td {\n padding: 12px 14px;\n border-bottom: 1px solid var(--border-color);\n vertical-align: middle;\n }\n .data-table tr:hover { background-color: #FAFAFA; }\n .badge-tag {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 5px 10px;\n border-radius: 8px;\n font-size: 0.82rem;\n font-weight: 700;\n cursor: pointer;\n user-select: none;\n transition: all 0.2s;\n border: 1px solid transparent;\n }\n .badge-tag:hover { transform: scale(1.04); }\n .badge-red { background: #FEE2E2; color: #B91C1C; border-color: #FECACA; }\n .badge-orange { background: #FFEDD5; color: #C2410C; border-color: #FED7AA; }\n .badge-green { background: #D1FAE5; color: #047857; border-color: #A7F3D0; }\n .badge-blue { background: #E0F2FE; color: #0369A1; border-color: #BAE6FD; }\n .action-btn {\n padding: 5px 8px;\n border-radius: 6px;\n border: 1px solid #CBD5E1;\n background: white;\n color: #475569;\n cursor: pointer;\n font-size: 0.8rem;\n font-weight: 600;\n transition: all 0.15s;\n margin-right: 4px;\n }\n .action-btn:hover { background: #F1F5F9; color: #0F172A; }\n .action-btn.btn-del:hover { background: #FEE2E2; color: #DC2626; border-color: #FCA5A5; }\n .empty-state {\n text-align: center;\n padding: 40px 20px;\n color: var(--text-muted);\n }\n .modal-backdrop {\n position: fixed;\n top: 0; left: 0; right: 0; bottom: 0;\n background: rgba(15, 23, 42, 0.5);\n backdrop-filter: blur(4px);\n display: none;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n padding: 16px;\n }\n .modal-box {\n background: white;\n border-radius: 16px;\n width: 100%;\n max-width: 520px;\n padding: 24px;\n box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);\n max-height: 90vh;\n overflow-y: auto;\n }\n .modal-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 16px;\n padding-bottom: 12px;\n border-bottom: 1px solid var(--border-color);\n }\n .modal-header h3 { font-size: 1.15rem; font-weight: 700; }\n .modal-close { background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-muted); }\n .form-group {\n margin-bottom: 14px;\n }\n .form-group label {\n display: block;\n font-size: 0.85rem;\n font-weight: 700;\n color: var(--text-main);\n margin-bottom: 6px;\n }\n .form-control {\n width: 100%;\n padding: 10px 14px;\n border: 1px solid #CBD5E1;\n border-radius: 10px;\n font-size: 0.95rem;\n font-family: inherit;\n outline: none;\n }\n .form-control:focus { border-color: var(--accent-orange); box-shadow: 0 0 0 3px rgba(249,115,22,0.15); }\n .modal-footer {\n display: flex;\n justify-content: flex-end;\n gap: 10px;\n margin-top: 20px;\n padding-top: 14px;\n border-top: 1px solid var(--border-color);\n }\n .toast-msg {\n position: fixed;\n bottom: 24px;\n right: 24px;\n background: #1E293B;\n color: white;\n padding: 12px 20px;\n border-radius: 10px;\n font-size: 0.9rem;\n font-weight: 600;\n box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);\n display: none;\n z-index: 2000;\n animation: fadeIn 0.3s ease;\n }\n @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }\n </style>\n</head>\n<body>\n <header>\n <div class=\"brand-title\">\n <div class=\"brand-icon\">🤖</div>\n <div class=\"brand-text\">\n <h1>全能智慧管家・個人雲端儀表板</h1>\n <p>即時同步 Google 試算表 11 大專屬分頁 · 支援一鍵修改狀態與手動增刪編輯</p>\n </div>\n </div>\n <div class=\"header-actions\">\n <button class=\"btn btn-refresh\" onclick=\"loadAllData()\">🔄 重新整理</button>\n </div>\n </header>\n <div class=\"stats-grid\">\n <div class=\"stat-card\"><div class=\"stat-label\">👤 累計聯絡人人數</div><div class=\"stat-value\" id=\"statContacts\">載入中...</div></div>\n <div class=\"stat-card orange\"><div class=\"stat-label\">💰 本期記帳總支出</div><div class=\"stat-value\" id=\"statExpense\">載入中...</div></div>\n <div class=\"stat-card blue\"><div class=\"stat-label\">📝 待追蹤作業缺交</div><div class=\"stat-value\" id=\"statMissing\">載入中...</div></div>\n <div class=\"stat-card green\"><div class=\"stat-label\">📢 最新聯絡簿發布</div><div class=\"stat-value\" id=\"statContact\" style=\"font-size: 1.05rem;\">載入中...</div></div>\n </div>\n <div class=\"tabs-wrapper\" id=\"tabsList\"></div>\n <div class=\"content-card\">\n <div class=\"card-header\">\n <div class=\"card-header-left\">\n <h2 id=\"currentTabTitle\">生活雜事</h2>\n <span class=\"badge-count\" id=\"currentTabCount\">載入中...</span>\n </div>\n <button class=\"btn btn-add\" onclick=\"openAddModal()\">➕ 新增紀錄</button>\n </div>\n <div class=\"table-container\" id=\"tableContent\"><div class=\"empty-state\">⏳ 正在連線讀取 Google 試算表最新紀錄...</div></div>\n </div>\n\n <!-- 新增 / 編輯 Modal -->\n <div class=\"modal-backdrop\" id=\"editModal\" onclick=\"if(event.target===this) closeModal()\">\n <div class=\"modal-box\">\n <div class=\"modal-header\">\n <h3 id=\"modalTitle\">新增紀錄</h3>\n <button class=\"modal-close\" onclick=\"closeModal()\">×</button>\n </div>\n <form id=\"editForm\" onsubmit=\"handleFormSubmit(event)\">\n <div id=\"formFields\"></div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-refresh\" onclick=\"closeModal()\">取消</button>\n <button type=\"submit\" class=\"btn btn-add\" id=\"btnSaveModal\">確認儲存</button>\n </div>\n </form>\n </div>\n </div>\n\n <div class=\"toast-msg\" id=\"toast\"></div>\n\n <script>\n var APP_DATA = { sheets: {}, stats: { totalExpense: 0, missingCount: 0, todayContact: null, contactCount: 0 } };\n var TAB_CONFIG = [\n { name: '生活雜事', icon: '📌' },\n { name: '作業缺交', icon: '📝' },\n { name: '行事曆', icon: '📅' },\n { name: '聯絡人', icon: '👤' },\n { name: '教學進度', icon: '📖' },\n { name: '今日聯絡簿', icon: '📢' },\n { name: '學生概況', icon: '👨🎓' },\n { name: '家長聯繫', icon: '📞' },\n { name: '備課靈感', icon: '💡' },\n { name: '記帳本', icon: '💰' },\n { name: '好物分享', icon: '🎁' }\n ];\n var currentSheetName = '生活雜事';\n var editingActualRow = null;\n\n function showToast(msg) {\n var t = document.getElementById('toast');\n if (t) {\n t.textContent = msg;\n t.style.display = 'block';\n setTimeout(function() { t.style.display = 'none'; }, 3000);\n }\n }\n\n function loadAllData() {\n showToast('🔄 正在同步試算表最新資料...');\n if (typeof google !== 'undefined' && google.script && google.script.run) {\n google.script.run\n .withSuccessHandler(function(data) {\n if (data && typeof data === 'object') {\n APP_DATA = data;\n if (!APP_DATA.sheets) APP_DATA.sheets = {};\n if (!APP_DATA.stats) APP_DATA.stats = { totalExpense: 0, missingCount: 0, todayContact: null, contactCount: 0 };\n }\n renderAll();\n })\n .withFailureHandler(function(err) {\n console.error('讀取試算表失敗:', err);\n showToast('⚠️ 讀取試算表失敗,已顯示離線介面');\n renderAll();\n })\n .getAllSheetRecords();\n } else {\n renderAll();\n }\n }\n\n function renderAll() {\n var stats = (APP_DATA && APP_DATA.stats) ? APP_DATA.stats : { totalExpense: 0, missingCount: 0, todayContact: null, contactCount: 0 };\n \n var elContacts = document.getElementById('statContacts');\n if (elContacts) elContacts.textContent = (stats.contactCount || 0) + ' 位聯絡人';\n\n var elExpense = document.getElementById('statExpense');\n if (elExpense) elExpense.textContent = 'NT$ ' + (stats.totalExpense || 0).toLocaleString() + ' 元';\n\n var elMissing = document.getElementById('statMissing');\n if (elMissing) elMissing.textContent = (stats.missingCount || 0) + ' 筆待補交';\n\n var elContact = document.getElementById('statContact');\n if (elContact) {\n if (stats.todayContact && stats.todayContact.length > 0) {\n elContact.textContent = (stats.todayContact[0] || '').substring(0, 10) + ' 已發布';\n } else {\n elContact.textContent = '尚無紀錄';\n }\n }\n \n var tabsContainer = document.getElementById('tabsList');\n if (tabsContainer) {\n tabsContainer.innerHTML = '';\n TAB_CONFIG.forEach(function(tab) {\n var btn = document.createElement('button');\n btn.className = 'tab-btn' + (tab.name === currentSheetName ? ' active' : '');\n btn.innerHTML = '<span>' + tab.icon + '</span> ' + tab.name;\n btn.onclick = function() { selectTab(tab.name, btn); };\n tabsContainer.appendChild(btn);\n });\n }\n renderSheet(currentSheetName);\n }\n\n function selectTab(sheetName, btnElement) {\n currentSheetName = sheetName;\n document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });\n if (btnElement) btnElement.classList.add('active');\n renderSheet(sheetName);\n }\n\n function renderSheet(sheetName) {\n var titleEl = document.getElementById('currentTabTitle');\n if (titleEl) titleEl.textContent = sheetName;\n\n var sheets = (APP_DATA && APP_DATA.sheets) ? APP_DATA.sheets : {};\n var sheetData = sheets[sheetName];\n var tableContent = document.getElementById('tableContent');\n var countEl = document.getElementById('currentTabCount');\n\n if (!sheetData || !sheetData.rows || sheetData.rows.length === 0) {\n if (countEl) countEl.textContent = '尚無紀錄';\n if (tableContent) {\n tableContent.innerHTML = '<div class=\"empty-state\">📑 目前「' + sheetName + '」分頁尚無任何紀錄,可點擊上方「➕ 新增紀錄」或在 LINE 發送訊息!</div>';\n }\n return;\n }\n\n if (countEl) countEl.textContent = '共 ' + sheetData.rows.length + ' 筆紀錄';\n var html = '<table class=\"data-table\"><thead><tr>';\n (sheetData.headers || []).forEach(function(h) { html += '<th>' + h + '</th>'; });\n html += '<th style=\"text-align:right;\">⚙️ 操作</th></tr></thead><tbody>';\n\n sheetData.rows.forEach(function(item) {\n var actualRow = item.actualRow;\n var row = item.values || [];\n html += '<tr>';\n row.forEach(function(cell, colIdx) {\n var text = (cell !== undefined && cell !== null) ? cell.toString() : '';\n var headerName = (sheetData.headers && sheetData.headers[colIdx]) || '';\n var formatted = text.replace(/\\n/g, '<br>');\n \n if (headerName.includes('狀態') || headerName.includes('狀況') || headerName.includes('進度') || headerName.includes('追蹤')) {\n if (text === '待辦') {\n formatted = '<span class=\"badge-tag badge-orange btn-toggle-status\" title=\"點擊切換為已完成\" data-sheet=\"' + sheetName + '\" data-row=\"' + actualRow + '\" data-target=\"已完成\">⏳ 待辦 (點擊完成)</span>';\n } else if (text === '已完成') {\n formatted = '<span class=\"badge-tag badge-green btn-toggle-status\" title=\"點擊切換為待辦\" data-sheet=\"' + sheetName + '\" data-row=\"' + actualRow + '\" data-target=\"待辦\">✅ 已完成 (點擊重開)</span>';\n } else if (text.includes('未補交') || text.includes('待追蹤')) {\n formatted = '<span class=\"badge-tag badge-red btn-toggle-status\" title=\"點擊切換為已補交\" data-sheet=\"' + sheetName + '\" data-row=\"' + actualRow + '\" data-target=\"已補交\">⚠️ 未補交 (點擊補交)</span>';\n } else if (text === '已補交') {\n formatted = '<span class=\"badge-tag badge-green btn-toggle-status\" title=\"點擊切換為未補交\" data-sheet=\"' + sheetName + '\" data-row=\"' + actualRow + '\" data-target=\"未補交\">✅ 已補交 (點擊重開)</span>';\n } else if (text.includes('已同步')) {\n formatted = '<span class=\"badge-tag badge-blue\">🗓️ ' + text + '</span>';\n }\n } else if (text === '支出') {\n formatted = '<span class=\"badge-tag badge-red\">支出</span>';\n } else if (text === '收入') {\n formatted = '<span class=\"badge-tag badge-green\">收入</span>';\n } else if (text.startsWith('http')) {\n formatted = '<a href=\"' + text + '\" target=\"_blank\" style=\"color:var(--accent-blue); text-decoration:none; font-weight:600;\">🔗 開啟連結</a>';\n }\n html += '<td>' + formatted + '</td>';\n });\n html += '<td style=\"text-align:right; white-space:nowrap;\">' +\n '<button class=\"action-btn btn-edit-row\" data-sheet=\"' + sheetName + '\" data-row=\"' + actualRow + '\">✏️ 編輯</button>' +\n '<button class=\"action-btn btn-del btn-delete-row\" data-sheet=\"' + sheetName + '\" data-row=\"' + actualRow + '\">🗑️ 刪除</button>' +\n '</td>';\n html += '</tr>';\n });\n html += '</tbody></table>';\n if (tableContent) tableContent.innerHTML = html;\n }\n\n // 事件委派:統一處理表格內所有按鈕點擊,零跳脫錯誤\n var tc = document.getElementById('tableContent');\n if (tc) {\n tc.addEventListener('click', function(e) {\n var toggleBtn = e.target.closest('.btn-toggle-status');\n if (toggleBtn) {\n var sheet = toggleBtn.getAttribute('data-sheet');\n var row = toggleBtn.getAttribute('data-row');\n var target = toggleBtn.getAttribute('data-target');\n toggleStatus(sheet, row, target);\n return;\n }\n var editBtn = e.target.closest('.btn-edit-row');\n if (editBtn) {\n var sheet = editBtn.getAttribute('data-sheet');\n var row = editBtn.getAttribute('data-row');\n openEditModal(sheet, row);\n return;\n }\n var delBtn = e.target.closest('.btn-delete-row');\n if (delBtn) {\n var sheet = delBtn.getAttribute('data-sheet');\n var row = delBtn.getAttribute('data-row');\n deleteRow(sheet, row);\n return;\n }\n });\n }\n\n function toggleStatus(sheetName, actualRow, newStatus) {\n showToast('⏳ 正在更新狀態為「' + newStatus + '」...');\n if (typeof google !== 'undefined' && google.script && google.script.run) {\n google.script.run\n .withSuccessHandler(function(res) {\n if (res && res.success) {\n showToast('✅ ' + res.message);\n if (APP_DATA.sheets && APP_DATA.sheets[sheetName]) {\n var sheetData = APP_DATA.sheets[sheetName];\n var target = sheetData.rows.find(function(r) { return r.actualRow == actualRow; });\n if (target) {\n var headers = sheetData.headers;\n var sCol = headers.findIndex(function(h) { return h.includes('狀態') || h.includes('狀況') || h.includes('進度') || h.includes('追蹤'); });\n if (sCol !== -1) target.values[sCol] = newStatus;\n renderSheet(sheetName);\n }\n }\n } else { alert('更新失敗: ' + (res ? res.error : '未知錯誤')); }\n })\n .updateSheetStatus(sheetName, actualRow, newStatus);\n }\n }\n\n function deleteRow(sheetName, actualRow) {\n if (!confirm('確定要刪除這筆紀錄嗎?此動作將同步從 Google 試算表中移除。')) return;\n showToast('⏳ 正在刪除紀錄...');\n if (typeof google !== 'undefined' && google.script && google.script.run) {\n google.script.run\n .withSuccessHandler(function(res) {\n if (res && res.success) {\n showToast('🗑️ ' + res.message);\n if (APP_DATA.sheets && APP_DATA.sheets[sheetName]) {\n var sheetData = APP_DATA.sheets[sheetName];\n sheetData.rows = sheetData.rows.filter(function(r) { return r.actualRow != actualRow; });\n renderSheet(sheetName);\n }\n } else { alert('刪除失敗: ' + (res ? res.error : '未知錯誤')); }\n })\n .deleteSheetRow(sheetName, actualRow);\n }\n }\n\n function openAddModal() {\n editingActualRow = null;\n document.getElementById('modalTitle').textContent = '➕ 新增「' + currentSheetName + '」紀錄';\n var sheets = (APP_DATA && APP_DATA.sheets) ? APP_DATA.sheets : {};\n var sheetData = sheets[currentSheetName] || { headers: ['紀錄日期', '內容', '備註'] };\n var formFields = document.getElementById('formFields');\n formFields.innerHTML = '';\n (sheetData.headers || []).forEach(function(h, idx) {\n var defaultVal = (idx === 0) ? new Date().toISOString().substring(0, 10).replace(/-/g, '/') : '';\n formFields.innerHTML += '<div class=\"form-group\"><label>' + h + '</label><input type=\"text\" class=\"form-control\" name=\"field_' + idx + '\" value=\"' + defaultVal + '\" required></div>';\n });\n document.getElementById('editModal').style.display = 'flex';\n }\n\n function openEditModal(sheetName, actualRow) {\n editingActualRow = actualRow;\n document.getElementById('modalTitle').textContent = '✏️ 編輯「' + sheetName + '」紀錄';\n var sheets = (APP_DATA && APP_DATA.sheets) ? APP_DATA.sheets : {};\n var sheetData = sheets[sheetName];\n if (!sheetData) return;\n var target = sheetData.rows.find(function(r) { return r.actualRow == actualRow; });\n if (!target) return;\n var formFields = document.getElementById('formFields');\n formFields.innerHTML = '';\n (sheetData.headers || []).forEach(function(h, idx) {\n var val = target.values[idx] || '';\n formFields.innerHTML += '<div class=\"form-group\"><label>' + h + '</label><input type=\"text\" class=\"form-control\" name=\"field_' + idx + '\" value=\"' + String(val).replace(/\"/g, '"') + '\" required></div>';\n });\n document.getElementById('editModal').style.display = 'flex';\n }\n\n function closeModal() { document.getElementById('editModal').style.display = 'none'; }\n\n function handleFormSubmit(e) {\n e.preventDefault();\n var form = e.target;\n var sheets = (APP_DATA && APP_DATA.sheets) ? APP_DATA.sheets : {};\n var sheetData = sheets[currentSheetName] || { headers: [] };\n var rowData = [];\n (sheetData.headers || []).forEach(function(h, idx) {\n rowData.push(form.elements['field_' + idx].value);\n });\n var isEdit = (editingActualRow !== null);\n showToast(isEdit ? '⏳ 正在儲存修改...' : '⏳ 正在新增紀錄...');\n\n if (typeof google !== 'undefined' && google.script && google.script.run) {\n if (isEdit) {\n google.script.run.withSuccessHandler(function(res) {\n if (res && res.success) {\n showToast('🎉 ' + res.message);\n closeModal();\n loadAllData();\n } else { alert('儲存失敗: ' + (res ? res.error : '未知錯誤')); }\n }).editSheetRow(currentSheetName, editingActualRow, rowData);\n } else {\n google.script.run.withSuccessHandler(function(res) {\n if (res && res.success) {\n showToast('🎉 ' + res.message);\n closeModal();\n loadAllData();\n } else { alert('儲存失敗: ' + (res ? res.error : '未知錯誤')); }\n }).addSheetRow(currentSheetName, rowData);\n }\n }\n }\n\n loadAllData();\n </script>\n</body>\n</html>";
}
// 儀表板:更新狀態
function updateSheetStatus(sheetName, actualRow, newStatus) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(sheetName);
if (!sheet) return { success: false, error: '找不到分頁: ' + sheetName };
const row = parseInt(actualRow, 10);
if (row >= 2 && row <= sheet.getLastRow()) {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
let statusCol = -1;
headers.forEach((h, idx) => {
if (h.includes('狀態') || h.includes('狀況') || h.includes('進度') || h.includes('追蹤')) {
statusCol = idx + 1;
}
});
if (statusCol !== -1) {
sheet.getRange(row, statusCol).setValue(newStatus);
return { success: true, message: '狀態已更新為「' + newStatus + '」' };
}
}
return { success: false, error: '未找到狀態欄位或列數無效' };
} catch (err) {
return { success: false, error: err.toString() };
}
}
// 儀表板:刪除指定列
function deleteSheetRow(sheetName, actualRow) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(sheetName);
if (!sheet) return { success: false, error: '找不到分頁: ' + sheetName };
const row = parseInt(actualRow, 10);
if (row >= 2 && row <= sheet.getLastRow()) {
sheet.deleteRow(row);
return { success: true, message: '已成功刪除該筆紀錄' };
}
return { success: false, error: '列數無效' };
} catch (err) {
return { success: false, error: err.toString() };
}
}
// 儀表板:新增一筆紀錄
function addSheetRow(sheetName, rowData) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(sheetName);
if (!sheet) return { success: false, error: '找不到分頁: ' + sheetName };
sheet.appendRow(rowData);
return { success: true, message: '已成功新增紀錄' };
} catch (err) {
return { success: false, error: err.toString() };
}
}
// 儀表板:編輯更新整列
function editSheetRow(sheetName, actualRow, rowData) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(sheetName);
if (!sheet) return { success: false, error: '找不到分頁: ' + sheetName };
const row = parseInt(actualRow, 10);
if (row >= 2 && row <= sheet.getLastRow()) {
sheet.getRange(row, 1, 1, rowData.length).setValues([rowData]);
return { success: true, message: '已成功更新紀錄' };
}
return { success: false, error: '列數無效' };
} catch (err) {
return { success: false, error: err.toString() };
}
}
// ==================== 11. 一鍵初始化與日曆/聯絡人授權測試函式 ====================
function initAllSheets() {
const ssId = CONFIG.SPREADSHEET_ID;
if (!ssId || ssId === 'YOUR_SPREADSHEET_ID') {
Logger.log('❌ 請先填寫 SPREADSHEET_ID!');
return;
}
const ss = SpreadsheetApp.openById(ssId);
ensureSheetHeaders(ss);
Logger.log('✅ 11 大專屬分頁與美化表頭初始化完成!');
}
function testAllPermissions() {
Logger.log('🚀 開始檢測並打通【Google 日曆 & Google 聯絡人】雙向授權...');
// 1. 測試 Google 日曆連線
try {
const calId = testCreateCalendarEvent();
Logger.log('✅ Google 日曆授權與活動建立測試成功!');
} catch (e) {
Logger.log('❌ Google 日曆測試異常: ' + e.message);
}
// 2. 測試 Google 聯絡人連線
try {
testCreateContact();
Logger.log('✅ Google 聯絡人授權與通訊錄建立測試成功!');
} catch (e) {
Logger.log('❌ Google 聯絡人測試異常: ' + e.message);
}
Logger.log('🎉 【恭喜】Google 日曆 & Google 聯絡人雙向雲端授權已 100% 打通完成!');
}
function testCreateCalendarEvent() {
const cal = CalendarApp.getDefaultCalendar();
const sampleTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
const event = cal.createEvent('【測試】全能管家日曆連線成功', sampleTime, new Date(sampleTime.getTime() + 3600000), {
description: '恭喜!您的 LINE 智慧管家已成功取得 Google 日曆授權!'
});
Logger.log('🎉 Google 日曆授權並建立成功,活動 ID: ' + event.getId());
return event.getId();
}
function testCreateContact() {
const sample = createGoogleContact({
name: '測試聯絡人',
phone: '0912-345678',
email: 'test@example.com',
company_title: '測試學校 教師'
});
Logger.log('🎉 Google 聯絡人授權並建立成功: ' + JSON.stringify(sample));
}
// ==================== 5. 旗艦 3 分頁圖文選單全自動安裝與管理 ====================
/**
* 建立並發布 3 分頁切換式旗艦圖文選單 (Tab 1: 日常反查, Tab 2: 事後改存, Tab 3: 事前鎖定)
*/
function setupMultiTabRichMenus() {
const token = CONFIG.LINE_ACCESS_TOKEN;
if (!token || token === 'YOUR_LINE_ACCESS_TOKEN') {
throw new Error('未設定 LINE_ACCESS_TOKEN,請先在指令碼屬性中填入金鑰!');
}
// 取得使用者個人的 Web App 儀表板網址(優先讀取手動設定的 WEB_APP_URL)
let webAppUrl = PropertiesService.getScriptProperties().getProperty('WEB_APP_URL');
if (!webAppUrl || !webAppUrl.startsWith('http')) {
try {
webAppUrl = ScriptApp.getService().getUrl();
} catch(e) {}
}
// 濾除非公開發布網址或無效連結
if (!webAppUrl || !webAppUrl.startsWith('http') || webAppUrl.includes('/edit') || webAppUrl.includes('/dev')) {
webAppUrl = 'https://ijun-ai.com/tutor/line-bot/';
}
const cdnBase = 'https://ijun-ai.com/tutor/line-bot/assets/';
const images = {
tab1: cdnBase + 'richmenu_tab1_query.jpg',
tab2: cdnBase + 'richmenu_tab2_remap.jpg',
tab3: cdnBase + 'richmenu_tab3_lock.jpg'
};
// 先清理舊的 Alias 與選單以防衝突
try { deleteMultiTabRichMenus(); } catch(e) { Logger.log('清理舊選單完成或無舊選單: ' + e.message); }
// 座標尺寸
const colW = 833;
const colW3 = 834;
const rowH = 360;
const tabH = 240;
// 定義 3 大分頁的頂部切換區塊 (使用 richmenuswitch 瞬時切換)
const topTabs = (currentTab) => [
{
bounds: { x: 0, y: 0, width: colW, height: tabH },
action: (currentTab === 1) ? { type: 'message', text: '/查 今日' } : { type: 'richmenuswitch', richMenuAliasId: 'tab_query', data: 'tab1' }
},
{
bounds: { x: colW, y: 0, width: colW, height: tabH },
action: (currentTab === 2) ? { type: 'message', text: '/undo' } : { type: 'richmenuswitch', richMenuAliasId: 'tab_remap', data: 'tab2' }
},
{
bounds: { x: colW * 2, y: 0, width: colW3, height: tabH },
action: (currentTab === 3) ? { type: 'message', text: '/cancel' } : { type: 'richmenuswitch', richMenuAliasId: 'tab_lock', data: 'tab3' }
}
];
// 12 格座標產生器 (4 列 x 3 欄)
function getGridAreas(currentTab, gridActions) {
const areas = topTabs(currentTab);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 3; c++) {
const idx = r * 3 + c;
const x = (c === 2) ? (colW * 2) : (c * colW);
const w = (c === 2) ? colW3 : colW;
const y = tabH + r * rowH;
const act = gridActions[idx];
if (act) {
areas.push({
bounds: { x: x, y: y, width: w, height: rowH },
action: act
});
}
}
}
return areas;
}
// --- Tab 1 Actions (日常反查與功能) ---
const tab1Actions = [
{ type: 'message', text: '/查 今日' },
{ type: 'message', text: '/查 進度' },
{ type: 'message', text: '/查 缺交' },
{ type: 'message', text: '/查 行事曆' },
{ type: 'message', text: '/查 家長' },
{ type: 'message', text: '/查 學生' },
{ type: 'message', text: '/查 備課' },
{ type: 'message', text: '/查 雜事' },
{ type: 'message', text: '/查 花費' },
{ type: 'message', text: '/查 聯絡人' },
{ type: 'uri', uri: webAppUrl },
{ type: 'uri', uri: 'https://ijun-ai.com/tutor/line-bot/commands.html' }
];
// --- Tab 2 Actions (事後改存) ---
const tab2Actions = [
{ type: 'message', text: '改存 聯絡人' },
{ type: 'message', text: '改存 行事曆' },
{ type: 'message', text: '改存 記帳' },
{ type: 'message', text: '改存 教學進度' },
{ type: 'message', text: '改存 今日聯絡簿' },
{ type: 'message', text: '改存 備課靈感' },
{ type: 'message', text: '改存 作業缺交' },
{ type: 'message', text: '改存 學生概況' },
{ type: 'message', text: '改存 家長聯繫' },
{ type: 'message', text: '改存 生活雜事' },
{ type: 'message', text: '改存 好物分享' },
{ type: 'message', text: '/undo' }
];
// --- Tab 3 Actions (事前鎖定) ---
const tab3Actions = [
{ type: 'message', text: '/名片' },
{ type: 'message', text: '/行事曆' },
{ type: 'message', text: '/記帳' },
{ type: 'message', text: '/進度' },
{ type: 'message', text: '/聯絡簿' },
{ type: 'message', text: '/靈感' },
{ type: 'message', text: '/缺交' },
{ type: 'message', text: '/學生' },
{ type: 'message', text: '/家長' },
{ type: 'message', text: '/雜事' },
{ type: 'message', text: '/好物' },
{ type: 'message', text: '/cancel' }
];
// 1. 先建立一個無 richmenuswitch 的暫存 Dummy Menu,用以初始化 3 個 Alias
const dummyId = createSingleRichMenu('init_temp', [{
bounds: { x: 0, y: 0, width: 100, height: 100 },
action: { type: 'message', text: 'init' }
}], token);
// 2. 初始化 3 個 Alias 指向 dummyId(解決 LINE API 先有 Alias 才能建 switch 的依賴限制)
createOrUpdateRichMenuAlias('tab_query', dummyId, token);
createOrUpdateRichMenuAlias('tab_remap', dummyId, token);
createOrUpdateRichMenuAlias('tab_lock', dummyId, token);
// 3. 正式建立 3 個包含 richmenuswitch 的真實 Rich Menu
const menu1Id = createSingleRichMenu('日常反查與功能', getGridAreas(1, tab1Actions), token);
const menu2Id = createSingleRichMenu('事後一鍵改存', getGridAreas(2, tab2Actions), token);
const menu3Id = createSingleRichMenu('事前強制鎖定', getGridAreas(3, tab3Actions), token);
// 4. 上傳 3 張高畫質底圖
uploadRichMenuImage(menu1Id, images.tab1, token);
uploadRichMenuImage(menu2Id, images.tab2, token);
uploadRichMenuImage(menu3Id, images.tab3, token);
// 5. 更新 3 個 Alias 正式指向真實選單
createOrUpdateRichMenuAlias('tab_query', menu1Id, token);
createOrUpdateRichMenuAlias('tab_remap', menu2Id, token);
createOrUpdateRichMenuAlias('tab_lock', menu3Id, token);
// 6. 設定 Tab 1 為所有使用者預設選單
setDefaultRichMenu(menu1Id, token);
// 7. 清理暫存 Dummy Menu
try {
UrlFetchApp.fetch('https://api.line.me/v2/bot/richmenu/' + dummyId, {
method: 'delete',
headers: { 'Authorization': 'Bearer ' + token },
muteHttpExceptions: true
});
} catch(e) {}
// 8. 儲存 ID 至 ScriptProperties
PropertiesService.getScriptProperties().setProperties({
'RICHMENU_ID_TAB1': menu1Id,
'RICHMENU_ID_TAB2': menu2Id,
'RICHMENU_ID_TAB3': menu3Id
});
return {
success: true,
message: '🎉 恭喜!全套「3 分頁切換式旗艦圖文選單」已成功全自動安裝並發布!\n━━━━━━━━━━━━━━\n• 🔍 分頁 1:日常反查與功能(已動態綁定個人專屬儀表板)\n• 🔄 分頁 2:事後一鍵改存\n• 🎯 分頁 3:事前強制鎖定\n\n💡 請重新關閉再打開手機 LINE 聊天室,立即體驗毫秒級切換!'
};
}
/**
* 建立或更新 Rich Menu Alias
*/
function createOrUpdateRichMenuAlias(aliasId, richMenuId, token) {
// 先嘗試建立
const createUrl = 'https://api.line.me/v2/bot/richmenu/alias';
const payload = {
richMenuAliasId: aliasId,
richMenuId: richMenuId
};
const createOptions = {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': 'Bearer ' + token },
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const res = UrlFetchApp.fetch(createUrl, createOptions);
if (res.getResponseCode() === 400 || res.getResponseCode() === 409) {
// 若已存在則更新
const updateUrl = 'https://api.line.me/v2/bot/richmenu/alias/' + aliasId;
const updateOptions = {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': 'Bearer ' + token },
payload: JSON.stringify({ richMenuId: richMenuId }),
muteHttpExceptions: true
};
UrlFetchApp.fetch(updateUrl, updateOptions);
}
}
function createSingleRichMenu(name, areas, token) {
const url = 'https://api.line.me/v2/bot/richmenu';
const payload = {
size: { width: 2500, height: 1686 },
selected: true,
name: name,
chatBarText: '📋 開啟智慧管家選單',
areas: areas
};
const options = {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': 'Bearer ' + token },
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const resCode = response.getResponseCode();
const resText = response.getContentText();
if (resCode !== 200) {
throw new Error('建立選單「' + name + '」失敗 (' + resCode + '): ' + resText);
}
const json = JSON.parse(resText);
return json.richMenuId;
}
/**
* 上傳 Rich Menu 底圖
*/
function uploadRichMenuImage(richMenuId, imageUrl, token) {
// 從 CDN 下載圖片 Blob
const imgRes = UrlFetchApp.fetch(imageUrl, { muteHttpExceptions: true });
if (imgRes.getResponseCode() !== 200) {
throw new Error('下載選單底圖失敗 (' + imgRes.getResponseCode() + '): ' + imageUrl);
}
const contentType = (imageUrl.toLowerCase().endsWith('.jpg') || imageUrl.toLowerCase().endsWith('.jpeg')) ? 'image/jpeg' : 'image/png';
const imgBlob = imgRes.getBlob().setContentType(contentType);
const uploadUrl = 'https://api-data.line.me/v2/bot/richmenu/' + richMenuId + '/content';
const options = {
method: 'post',
contentType: contentType,
headers: { 'Authorization': 'Bearer ' + token },
payload: imgBlob.getBytes(),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(uploadUrl, options);
const resCode = response.getResponseCode();
if (resCode !== 200) {
throw new Error('上傳選單底圖至 LINE 失敗 (' + resCode + '): ' + response.getContentText());
}
}
/**
* 建立 Rich Menu Alias
*/
function createRichMenuAlias(aliasId, richMenuId, token) {
const url = 'https://api.line.me/v2/bot/richmenu/alias';
const payload = {
richMenuAliasId: aliasId,
richMenuId: richMenuId
};
const options = {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': 'Bearer ' + token },
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
UrlFetchApp.fetch(url, options);
}
/**
* 設定全體使用者預設選單
*/
function setDefaultRichMenu(richMenuId, token) {
const url = 'https://api.line.me/v2/bot/user/all/richmenu/' + richMenuId;
const options = {
method: 'post',
headers: { 'Authorization': 'Bearer ' + token },
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
if (response.getResponseCode() !== 200) {
throw new Error('設定預設選單失敗: ' + response.getContentText());
}
}
/**
* 移除並清理所有 API 圖文選單
*/
function deleteMultiTabRichMenus() {
const token = CONFIG.LINE_ACCESS_TOKEN;
if (!token || token === 'YOUR_LINE_ACCESS_TOKEN') return { success: false, message: '未設定 Token' };
// 1. 取消預設選單
try {
UrlFetchApp.fetch('https://api.line.me/v2/bot/user/all/richmenu', {
method: 'delete',
headers: { 'Authorization': 'Bearer ' + token },
muteHttpExceptions: true
});
} catch(e) {}
// 2. 刪除 Aliases
['tab_query', 'tab_remap', 'tab_lock'].forEach(alias => {
try {
UrlFetchApp.fetch('https://api.line.me/v2/bot/richmenu/alias/' + alias, {
method: 'delete',
headers: { 'Authorization': 'Bearer ' + token },
muteHttpExceptions: true
});
} catch(e) {}
});
// 3. 刪除所有 Rich Menu 清單
try {
const listRes = UrlFetchApp.fetch('https://api.line.me/v2/bot/richmenu/list', {
method: 'get',
headers: { 'Authorization': 'Bearer ' + token },
muteHttpExceptions: true
});
if (listRes.getResponseCode() === 200) {
const list = JSON.parse(listRes.getContentText()).richmenus || [];
list.forEach(m => {
UrlFetchApp.fetch('https://api.line.me/v2/bot/richmenu/' + m.richMenuId, {
method: 'delete',
headers: { 'Authorization': 'Bearer ' + token },
muteHttpExceptions: true
});
});
}
} catch(e) {}
PropertiesService.getScriptProperties().deleteProperty('RICHMENU_ID_TAB1');
PropertiesService.getScriptProperties().deleteProperty('RICHMENU_ID_TAB2');
PropertiesService.getScriptProperties().deleteProperty('RICHMENU_ID_TAB3');
return {
success: true,
message: '🗑️ 已成功移除所有 API 圖文選單!手機將自動恢復為官方後台預設設定。'
};
}
點擊 Apps Script 左側齒輪 ⚙️ 「專案設定」 ➔ 拉到最下方「指令碼屬性」:
WEB_APP_URL:確認內容是自己部署的 https://script.google.com/macros/s/.../exec 網址。WEB_APP_URL、值填入自己的 /exec 網址 ➔ 點擊「儲存指令碼屬性」。點擊左側程式碼 < > 回到編輯器:
setupMultiTabRichMenus。🎉 恭喜!全套「3 分頁切換式旗艦圖文選單」已成功全自動安裝並發布!。在 Apps Script 畫面右上角:
🎉 大功告成!將手機 LINE 聊天室關閉再重新點開,即可享受全新 3 分頁立體晶透旗艦選單!
如果您想自行在 LINE 官方後台手動拉框、設定顯示時間或套用傳統 6 格版型,請參考此處步驟。
在設定圖文選單前,建議先為管家換上專屬的頭像與封面背景,讓聊天室更具專業與親和力:
點選後台左側選單 「聊天室相關」 ➔ 「圖文選單」,接著點擊右上角的綠色按鈕 「建立」。
在基本設定區塊中依序完成以下項目:
教師管家 6 格選單)。2030 年),確保選單常駐不中斷。在內容設定區塊中:
依序為 A~F 格設定對應的文字動作:
/查 今日/查 進度/查 缺交/查 聯絡人/查 花費/help最後將「預設顯示方式」設為 「顯示」,點擊最下方 「儲存」 即可發布生效!
完全符合 LINE 官方標準 2500 × 1686 規格(檔案均小於 1MB),點擊直接下載!
清爽 Google Workspace 色調,搭配超清晰 Emoji 圖示與指令小標籤,一目瞭然。
⬇️ 下載 JPG 圖檔
快速解答關於多模態拍照、語音直錄與快捷管理指令的所有疑問!
/安裝選單 ➜ 一鍵自動安裝與發布 3 分頁旗艦圖文選單/網址 <URL> ➜ 綁定自己的專屬 Google Apps Script 儀表板網址/查 進度 ➜ 查看各班最新教學章節進度與交代事項/查 缺交 ➜ 查看全班各科未補交作業清單與座號/查 今日 ➜ 查看今日聯絡簿公告與今日日曆行程/查 花費 ➜ 查看記帳本本月累計總支出與近 3 筆消費明細/查 聯絡人 ➜ 查看通訊錄電話與 Email/del <關鍵字> ➜ 精準搜尋並自動刪除指定的紀錄項目/undo ➜ 撤回並自動從 Google 試算表刪除上一筆存入的資料/help ➜ 顯示完整指令手冊與 11 大分類輸入範例
感謝您對教育開源工具與教學手冊的支持!
您的每一份心意,都是支持本站雲端伺服器、AI 算力與持續產出教學神器的最大動力。
🔒 支援信用卡、Apple Pay、Google Pay 等安全金流機制。
※ 本贊助屬自由個人心意打賞(無對價贈與),全數用於伺服器及 API 營運成本。