0. 作品介紹
2. 資料表規則
請照下面規則建立分頁與欄位。
第1分頁: 校園知識庫
A 欄: 分類
B 欄: 標題 / 業務項目
C 欄: 詳細內容與設定步驟
D 欄: 狀態
E 欄: 更新時間
📄 解析結果(自動表格)
分頁:校園知識庫
| A 欄 | B 欄 | C 欄 | D 欄 | E 欄 |
|---|---|---|---|---|
| 分類 | 標題 / 業務項目 | 詳細內容與設定步驟 | 狀態 | 更新時間 |
3. 開啟 Apps Script
從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 Code.gs。
/**
* ============================================================================
* 🏫 全國校園 LINE Bot AI 智慧小幫手・開源通用範本 (GAS + Gemini RAG 後端)
* ============================================================================
* 專為台灣中小學、幼兒園、大專院校各處室(資訊組、教務處、學務處、輔導室、總務處)設計。
*
* 核心功能:
* 1. ⚡ Google 試算表 RAG 業務知識庫(零快取即時讀取,隨改隨生效,免工程師維護)
* 2. 🛠️ 設備線上報修登記系統(支援「#報修 地點與問題」自動生成工單編號並寫入試算表)
* 3. 🧠 未解問題自動收集庫(AI 查無答案時自動記下,組長後續補填即可實現知識庫自進化)
* 4. 📱 LINE 群組與私訊雙模式(私訊秒回;群組需 @小幫手 提問,避免洗版干擾)
* 5. ⏳ LINE 原生「正在輸入中...」載入中動畫(防使用者重複按鍵)
* 6. 📅 可選擴充:Google 公開行事曆即時查詢(下週活動、段考日程等,無行事曆可留空)
* 7. 💻 可選擴充:校園設備/平板線上借用系統 API 介接(無借用系統可留空)
* 8. 🚀 一鍵初始化函式:initKnowledgeBaseSheet() 自動建立 12+ 條台灣校園通用標準知識庫
* ============================================================================
*/
const CONFIG = {
// --------------------------------------------------------------------------
// 1. 必填金鑰 (請至 Google AI Studio 與 LINE Developers 後台取得)
// --------------------------------------------------------------------------
GEMINI_API_KEY: '填入您的_GEMINI_API_KEY',
LINE_ACCESS_TOKEN: '填入您的_LINE_CHANNEL_ACCESS_TOKEN',
// --------------------------------------------------------------------------
// 2. 學校與單位基本設定 (自動化組裝 AI 小幫手專屬人設)
// --------------------------------------------------------------------------
SCHOOL_NAME: '竹光國民中學', // 學校名稱(例:光明國中、快樂國小)
UNIT_NAME: '教務處資訊組', // 處室/組別名稱(例:資訊組、教務處、學務處)
BOT_NAME: '校園資訊 AI 小幫手', // 機器人稱呼
ADMIN_TITLE: '資訊組長', // 負責人/管理員稱呼(例:資訊組長、大乃老師)
CONTACT_PHONE: '校內分機 614', // 緊急聯絡電話或分機
CONTACT_LOCATION: '行政大樓 2 樓', // 處室/辦公室位置
// --------------------------------------------------------------------------
// 3. 可選擴充模組 (若無需求請維持預設空字串 '',系統將自動略過不報錯)
// --------------------------------------------------------------------------
// [擴充 A] Google 行事曆查詢 (填入公開 Google 行事曆 ID 即可查詢週會、段考時程)
CALENDAR_ID: '', // 例: your_school@group.calendar.google.com
CALENDAR_WEB_URL: '', // 行事曆網頁瀏覽網址
// [擴充 B] 線上平板/教室即時借用系統 API (若校內有建立預約借用系統可填入)
BOOKING_API_URL: '', // 例: https://your-booking.pages.dev/api/bookings
BOOKING_WEB_URL: '', // 借用系統前台網址
// --------------------------------------------------------------------------
// 4. 管理後台與工作表設定
// --------------------------------------------------------------------------
ADMIN_PASSWORD: '', // 手機 LINE 寫入知識庫密碼(留空即為免密碼模式)
SHEET_NAME: '校園知識庫', // 知識庫主要儲存工作表
LOG_SHEET_NAME: '對話紀錄', // 提問對話紀錄表
REPAIR_SHEET_NAME: '設備報修登記表', // 線上報修工單表
UNANSWERED_SHEET_NAME: '待補充問題庫' // AI 未命中待補問題表
};
// ============================================================================
// 🤖 動態組裝 AI 小幫手人設 Prompt (System Instruction)
// ============================================================================
function getSystemInstruction() {
return `你是一位專業、親切且有效率的「${CONFIG.SCHOOL_NAME}${CONFIG.UNIT_NAME}智慧小幫手」(${CONFIG.ADMIN_TITLE}的 AI 分身)。
你的任務是協助${CONFIG.SCHOOL_NAME}全體教職員工快速解答校園業務、系統操作、設備排障、行政規定與相關時程。
【服務窗口與基本資訊】:
- 所屬單位:${CONFIG.SCHOOL_NAME} ${CONFIG.UNIT_NAME}
- 業務負責人:${CONFIG.ADMIN_TITLE}
- 處室位置:${CONFIG.CONTACT_LOCATION}
- 聯絡電話/分機:${CONFIG.CONTACT_PHONE}
【回答原則與排版規範(極重要)】:
1. 一律使用繁體中文(台灣教育界習慣用語,如:影片、軟體、預設、網路、帳號、研習、平板、網芳、大屏、段考、行事曆、請假、差勤)。
2. 條理分明、結論先行;涉及操作步驟或故障排障時,請用 1. 2. 3. 清楚條列。
3. 【嚴格 LINE 排版規範】:
- 嚴禁使用 Markdown 粗體星號(絕對不要輸出 **粗體** 或 *斜體* 或 ### 或 - 破折號清單)!
- 強調重點請使用【...】或「...」。
- 清單一律使用 Emoji 圖示或數字(如 📱、🟢、🏫、⏰、📝、💡、👉、📅、🏢、•、1. 2. 3.)並適度空行,方便老師在手機 LINE 上舒適閱覽。
4. 若知識庫查無特定資訊,請委婉告知老師,並主動引導至 ${CONFIG.CONTACT_LOCATION} 洽詢 ${CONFIG.ADMIN_TITLE}(${CONFIG.CONTACT_PHONE})。
5. 保持熱心、溫暖且耐心的口吻,全力支援全校師生的教學與行政工作。`;
}
// ============================================================================
// 🌐 Web 介面與 LINE Webhook 入口
// ============================================================================
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle(`${CONFIG.SCHOOL_NAME} ${CONFIG.UNIT_NAME} AI 小幫手 - 控制台`)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function doPost(e) {
try {
if (!e || !e.postData || !e.postData.contents) {
return ContentService.createTextOutput('OK').setMimeType(ContentService.MimeType.TEXT);
}
const data = JSON.parse(e.postData.contents);
if (!data.events || data.events.length === 0) {
return ContentService.createTextOutput('OK').setMimeType(ContentService.MimeType.TEXT);
}
const event = data.events[0];
if (event.type === 'message' && event.message.type === 'text') {
const userMessage = event.message.text.trim();
const replyToken = event.replyToken;
const isGroup = event.source.type === 'group' || event.source.type === 'room';
const userId = event.source.userId || 'unknown';
// 1. 立即啟動 LINE「正在輸入中...」載入動畫
if (event.source.userId) {
showLineLoading(event.source.userId);
}
// 2. 群組模式過濾:若在群組中,只有 Tag @小幫手 或提及關鍵字才回應,避免洗版
if (isGroup) {
const escapedBot = escapeRegExp(CONFIG.BOT_NAME);
const escapedAdmin = escapeRegExp(CONFIG.ADMIN_TITLE);
const triggerPattern = new RegExp(`(@小幫手|資訊小幫手|小幫手|@${escapedBot}|${escapedBot}|${escapedAdmin})`, 'i');
if (!triggerPattern.test(userMessage) && !userMessage.startsWith('#')) {
return ContentService.createTextOutput('OK');
}
}
// 3. 檢查是否為設備線上報修指令 (#報修 ...)
if (userMessage.startsWith('#報修') || userMessage.startsWith('報修 ')) {
const repairReply = handleRepairCommand(userMessage, userId);
sendLineReply(replyToken, repairReply);
logConversation(userMessage, repairReply, userId);
return ContentService.createTextOutput('OK');
}
// 4. 檢查是否為管理員指令 (#記住, #查詢, #查詢報修)
if (userMessage.startsWith('#')) {
const adminReply = handleAdminCommand(userMessage);
sendLineReply(replyToken, adminReply);
return ContentService.createTextOutput('OK');
}
// 5. 一般使用者提問:執行 RAG 知識庫比對與 Gemini 智慧回覆
const cleanPattern = new RegExp(`(@小幫手|資訊小幫手|小幫手|@${escapeRegExp(CONFIG.BOT_NAME)}|${escapeRegExp(CONFIG.BOT_NAME)}|${escapeRegExp(CONFIG.ADMIN_TITLE)})`, 'gi');
const cleanQuery = userMessage.replace(cleanPattern, '').trim();
const aiReply = generateAiResponse(cleanQuery, userId);
sendLineReply(replyToken, aiReply);
logConversation(cleanQuery, aiReply, userId);
}
return ContentService.createTextOutput(JSON.stringify({ status: 'success' })).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
console.error('doPost Error:', err);
return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: err.toString() })).setMimeType(ContentService.MimeType.JSON);
}
}
// ============================================================================
// 🛠️ 設備線上報修登記系統 (#報修)
// ============================================================================
function handleRepairCommand(text, userId) {
const content = text.replace(/^(#報修|報修)\s*/, '').trim();
if (!content) {
return `🛠️ 【${CONFIG.UNIT_NAME}・設備線上報修格式說明】\n\n` +
'請輸入:#報修 [地點/教室] [設備名稱與故障現象]\n\n' +
'👉 範例:\n' +
'• #報修 804班觸控大屏HDMI孔無反應\n' +
'• #報修 電腦教室1投影機無法開機\n' +
'• #報修 專任辦公室印表機卡紙\n\n' +
`💡 登記後${CONFIG.ADMIN_TITLE}將即時收到報修紀錄並前往排查!`;
}
const now = new Date();
const twTimeStr = Utilities.formatDate(now, 'GMT+8', 'yyyy/MM/dd HH:mm');
const repairId = 'R' + Utilities.formatDate(now, 'GMT+8', 'MMddHHmm');
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG.REPAIR_SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(CONFIG.REPAIR_SHEET_NAME);
sheet.appendRow(['報修單號', '登記時間', '報修者ID', '報修地點與故障內容', '處理狀態', '處理備註', '完成時間']);
sheet.getRange('A1:G1').setBackground('#b91c1c').setFontColor('#ffffff').setFontWeight('bold');
}
sheet.appendRow([repairId, twTimeStr, userId, content, '⏳ 待處理', '', '']);
return `🛠️ 【${CONFIG.SCHOOL_NAME}・設備報修已受理】\n\n` +
`• 報修單號:${repairId}\n` +
`• 登記時間:${twTimeStr}\n` +
`• 故障描述:${content}\n` +
`• 目前狀態:⏳ 待處理\n\n` +
`${CONFIG.ADMIN_TITLE}已收到您的報修登記,${CONFIG.UNIT_NAME}將盡速前往檢修排查!\n` +
`💡 若為上課緊急教學需求,請撥打校內專線:${CONFIG.CONTACT_PHONE}。`;
} catch (e) {
console.error('handleRepairCommand error:', e);
return `❌ 報修登記寫入失敗,請直接撥打 ${CONFIG.CONTACT_PHONE} 聯絡 ${CONFIG.ADMIN_TITLE}!`;
}
}
// ============================================================================
// 🧠 未解問題自動收集機制 (知識庫自進化)
// ============================================================================
function logUnansweredQuestion(query, reply, userId) {
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG.UNANSWERED_SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(CONFIG.UNANSWERED_SHEET_NAME);
sheet.appendRow(['記錄時間', '提問者ID', '未命中之提問問題', 'AI當次回覆', '處理狀態', '管理員補充解答']);
sheet.getRange('A1:F1').setBackground('#c2410c').setFontColor('#ffffff').setFontWeight('bold');
}
const now = new Date();
const twTimeStr = Utilities.formatDate(now, 'GMT+8', 'yyyy/MM/dd HH:mm:ss');
sheet.appendRow([twTimeStr, userId, query, reply.slice(0, 100), '🔍 待補充', '']);
} catch (e) {
console.error('logUnansweredQuestion error:', e);
}
}
// ============================================================================
// 📅 Google 公開行事曆即時查詢 (可選模組)
// ============================================================================
function getSchoolCalendarSummary(query) {
if (!CONFIG.CALENDAR_ID || CONFIG.CALENDAR_ID.trim() === '') {
return '';
}
try {
const now = new Date();
const twTime = new Date(now.getTime() + (8 * 3600 * 1000) + (now.getTimezoneOffset() * 60000));
const normQuery = normalizeChineseDate(query);
let startY = twTime.getFullYear();
let startM = twTime.getMonth();
let startD = twTime.getDate();
let startDate = new Date(startY, startM, startD, 0, 0, 0);
let endDate = new Date(startY, startM + 3, 1, 23, 59, 59);
const curDayOfWeek = twTime.getDay();
if (normQuery.includes('下週') || normQuery.includes('下禮拜') || normQuery.includes('下星期')) {
const daysUntilNextMon = (curDayOfWeek === 0 ? 1 : 8 - curDayOfWeek);
startDate = new Date(twTime.getTime() + daysUntilNextMon * 86400000);
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate.getTime() + 6 * 86400000);
endDate.setHours(23, 59, 59, 999);
} else if (normQuery.includes('這週') || normQuery.includes('本週') || normQuery.includes('這禮拜') || normQuery.includes('這星期')) {
const daysFromMon = (curDayOfWeek === 0 ? 6 : curDayOfWeek - 1);
startDate = new Date(twTime.getTime() - daysFromMon * 86400000);
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate.getTime() + 6 * 86400000);
endDate.setHours(23, 59, 59, 999);
} else if (normQuery.includes('明天')) {
startDate = new Date(twTime.getTime() + 86400000);
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate.getTime());
endDate.setHours(23, 59, 59, 999);
} else if (normQuery.includes('今天') || normQuery.includes('今日')) {
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate.getTime());
endDate.setHours(23, 59, 59, 999);
}
const startStr = formatDateStr(startDate);
const endStr = formatDateStr(endDate);
const cache = CacheService.getScriptCache();
let icsText = cache.get('SCHOOL_CAL_ICS_V1');
if (!icsText) {
const icsUrl = 'https://calendar.google.com/calendar/ical/' + encodeURIComponent(CONFIG.CALENDAR_ID) + '/public/basic.ics';
const resp = UrlFetchApp.fetch(icsUrl, { muteHttpExceptions: true });
if (resp.getResponseCode() === 200) {
icsText = resp.getContentText();
try { cache.put('SCHOOL_CAL_ICS_V1', icsText, 3600); } catch(e) {}
}
}
let eventList = [];
if (icsText) {
const vEvents = icsText.split('BEGIN:VEVENT');
for (let i = 1; i < vEvents.length; i++) {
const block = vEvents[i];
const summaryMatch = block.match(/SUMMARY:(.*?)(?:\r?\n|$)/);
const dtStartMatch = block.match(/DTSTART[^:]*:(\d{4})(\d{2})(\d{2})/);
if (summaryMatch && dtStartMatch) {
const sum = summaryMatch[1].trim();
const y = parseInt(dtStartMatch[1], 10);
const m = parseInt(dtStartMatch[2], 10) - 1;
const d = parseInt(dtStartMatch[3], 10);
const evDate = new Date(y, m, d, 0, 0, 0);
if (evDate >= startDate && evDate <= endDate) {
const dateFmt = `${y}-${String(m+1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const weekDayMap = ['日', '一', '二', '三', '四', '五', '六'];
const wStr = weekDayMap[evDate.getDay()];
eventList.push({
dateStr: dateFmt,
display: `• 【${String(m+1).padStart(2, '0')}/${String(d).padStart(2, '0')} (${wStr})】:${sum}`
});
}
}
}
}
eventList.sort((a, b) => a.dateStr.localeCompare(b.dateStr));
if (eventList.length === 0) {
return `【校園行事曆】:在 ${startStr} 至 ${endStr} 期間查無特定登記活動${CONFIG.CALENDAR_WEB_URL ? '(可至線上日曆 ' + CONFIG.CALENDAR_WEB_URL + ' 查看)' : ''}。`;
}
return `📅 【${CONFIG.SCHOOL_NAME}行事曆即時明細 (${startStr} ~ ${endStr},共 ${eventList.length} 項)】:\n` +
eventList.map(e => e.display).join('\n') +
(CONFIG.CALENDAR_WEB_URL ? `\n\n【完整線上行事曆】:${CONFIG.CALENDAR_WEB_URL}` : '');
} catch(e) {
console.error('getSchoolCalendarSummary error:', e);
return '';
}
}
// ============================================================================
// 📱 線上設備/場地即時借用系統查詢 (可選模組)
// ============================================================================
function getLiveBookingSummary(query) {
if (!CONFIG.BOOKING_API_URL || CONFIG.BOOKING_API_URL.trim() === '') {
return '';
}
try {
const now = new Date();
const twTime = new Date(now.getTime() + (8 * 3600 * 1000) + (now.getTimezoneOffset() * 60000));
const targetDate = formatDateStr(twTime);
const apiUrl = `${CONFIG.BOOKING_API_URL}?day=${targetDate}`;
const resp = UrlFetchApp.fetch(apiUrl, { muteHttpExceptions: true });
if (resp.getResponseCode() !== 200) {
return '';
}
const data = JSON.parse(resp.getContentText());
const items = data.bookings || data.tablets || [];
if (items.length === 0) {
return `【${targetDate} 線上借用狀況】:當日目前無任何設備或場地借用登記記錄。`;
}
let summary = `⚡ 【${targetDate} 設備/場地借用登記明細 (${items.length} 筆)】:\n`;
items.forEach(t => {
summary += `• 【${t.item || t.name}】:借用人「${t.teacher || t.user}」,節次/時段:${(t.periods || []).join('、')}\n`;
});
if (CONFIG.BOOKING_WEB_URL) {
summary += `\n【線上借用登記前台】:${CONFIG.BOOKING_WEB_URL}`;
}
return summary;
} catch (e) {
console.error('getLiveBookingSummary error:', e);
return '';
}
}
// ============================================================================
// 📚 知識庫管理核心函式 (零快取即時讀取)
// ============================================================================
function getKnowledgeBaseData() {
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sheet) {
initKnowledgeBaseSheet();
sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
}
const values = sheet.getDataRange().getValues();
if (values.length <= 1) return [];
const list = [];
for (let i = 1; i < values.length; i++) {
const row = values[i];
const cat = (row[0] || '').toString().trim();
const title = (row[1] || '').toString().trim();
const content = (row[2] || '').toString().trim();
const status = (row[3] || '').toString().trim();
if ((cat || title) && content && status !== '停用' && status !== 'false') {
list.push({
category: cat || '一般校務業務',
title: title || cat,
content: content
});
}
}
return list;
} catch (e) {
console.warn('getKnowledgeBaseData error:', e);
return [];
}
}
function handleAdminCommand(text) {
const parts = text.split(' ');
const cmd = parts[0];
if (cmd === '#查詢報修') {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(CONFIG.REPAIR_SHEET_NAME);
if (!sheet) return '📋 目前尚無任何報修登記紀錄!';
const values = sheet.getDataRange().getValues();
if (values.length <= 1) return '📋 目前尚無任何報修登記紀錄!';
let pending = [];
for (let i = 1; i < values.length; i++) {
if (values[i][4] === '⏳ 待處理' || values[i][4] === '待處理') {
pending.push(`• [${values[i][0]}] ${values[i][1]}:${values[i][3]}`);
}
}
if (pending.length === 0) return '🎉 太棒了!目前所有設備報修皆已處理完成,無待辦工單!';
return `🛠️ 【待處理設備報修清單 (${pending.length} 筆)】:\n` + pending.slice(0, 8).join('\n');
}
if (cmd === '#查詢') {
const kw = parts.slice(1).join(' ');
if (!kw) return '請輸入要查詢的關鍵字,例如:#查詢 網路';
const list = getKnowledgeBaseData().filter(k => k.title.includes(kw) || k.content.includes(kw) || k.category.includes(kw));
if (list.length === 0) return `🔍 查無包含「${kw}」的知識庫項目。`;
return `🔍 找到 ${list.length} 筆資料:\n` +
list.map(x => `• [${x.category}] ${x.title}:${x.content.slice(0, 40)}...`).join('\n');
}
if (cmd === '#記住') {
let content = parts.slice(1).join(' ');
if (CONFIG.ADMIN_PASSWORD && parts[1] === CONFIG.ADMIN_PASSWORD) {
content = parts.slice(2).join(' ');
} else if (CONFIG.ADMIN_PASSWORD) {
return '❌ 密碼錯誤,無法寫入知識庫。';
}
if (!content) return '❌ 請提供要記住的內容,例如:#記住 801班大屏觸控線已更換';
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
const title = content.slice(0, 15);
sheet.appendRow(['臨時備忘', title, content, '啟用', new Date()]);
return `✅ 已成功寫入校園知識庫!\n【分類】臨時備忘\n【項目】${title}\n【內容】${content}`;
}
return '💡 支援快速指令:\n• #報修 [地點與問題]\n• #查詢報修\n• #查詢 [關鍵字]\n• #記住 [內容]';
}
// ============================================================================
// 🧠 RAG 知識庫比對與 Gemini AI 生成 (ModelService 動態直連)
// ============================================================================
function generateAiResponse(userQuery, userId) {
if (!CONFIG.GEMINI_API_KEY || CONFIG.GEMINI_API_KEY.includes('填入您的')) {
return '⚠️ 系統提示:尚未在 Code.gs 中設定 GEMINI_API_KEY,請先前往 Google AI Studio 取得免費金鑰!';
}
const kbData = getKnowledgeBaseData();
const contextText = kbData.map(function(item) {
return '【分類:' + item.category + '】項目:' + item.title + '\n內容:' + item.content;
}).join('\n\n');
// 1. 判斷是否需要擴充模組資訊(借用查詢或行事曆)
let liveBookingContext = '';
if (CONFIG.BOOKING_API_URL && /(借|平板|車|這節|誰借|第.節|教室借用|預約|預定)/i.test(userQuery)) {
liveBookingContext = getLiveBookingSummary(userQuery);
}
let liveCalendarContext = '';
if (CONFIG.CALENDAR_ID && /(行事曆|段考|考試|月考|開學|放假|活動|時程|下週|這週|本週|明天|今天)/i.test(userQuery)) {
liveCalendarContext = getSchoolCalendarSummary(userQuery);
}
const prompt = getSystemInstruction() + '\n\n' +
'以下是「' + CONFIG.SCHOOL_NAME + ' ' + CONFIG.UNIT_NAME + '業務知識庫」:\n' +
'--------------------------------\n' +
contextText + '\n' +
'--------------------------------\n' +
(liveBookingContext ? ('\n【⚡ 即時連線借用系統資訊】:\n' + liveBookingContext + '\n--------------------------------\n') : '') +
(liveCalendarContext ? ('\n【📅 即時連線行事曆資訊】:\n' + liveCalendarContext + '\n--------------------------------\n') : '') +
'\n同仁/老師的提問是:「' + userQuery + '」\n\n' +
'請依據上述知識庫、即時資料與回答規範,給出條理清晰、溫暖親切且正確的解答:';
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2 }
};
const models = getAuthorizedGoogleModels(CONFIG.GEMINI_API_KEY);
if (models.length === 0) {
return '⚠️ Google API 金鑰異常:無法從 Google 取得可用模型,請確認 API Key 是否在 Google AI Studio 正常啟用!';
}
let lastError = '';
for (let i = 0; i < models.length; i++) {
const model = models[i];
try {
const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + model + ':generateContent?key=' + CONFIG.GEMINI_API_KEY;
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const resJson = JSON.parse(response.getContentText());
if (resJson.candidates && resJson.candidates[0] && resJson.candidates[0].content && resJson.candidates[0].content.parts && resJson.candidates[0].content.parts[0].text) {
const finalReply = sanitizeLineFormat(resJson.candidates[0].content.parts[0].text);
// 🧠 若 AI 表示查無資料,自動收集進待補充清單
if (finalReply.includes('小幫手目前沒有這項特定資料') || finalReply.includes('洽詢') && finalReply.includes(CONFIG.ADMIN_TITLE)) {
logUnansweredQuestion(userQuery, finalReply, userId || 'anonymous');
}
return finalReply;
} else if (resJson.error) {
lastError = '[' + model + '] ' + (resJson.error.message || JSON.stringify(resJson.error));
}
} catch (e) {
lastError = '[' + model + '] 連線失敗: ' + e.toString();
}
}
if (lastError.indexOf('RESOURCE_EXHAUSTED') !== -1 || lastError.indexOf('429') !== -1) {
return '⚠️ 系統提示:目前連線頻率較高,請稍候 3 秒後再次提問!';
}
return '⚠️ 抱歉,AI 服務連線稍有延遲,請稍候片刻再試一次,或直接聯繫 ' + CONFIG.ADMIN_TITLE + '(' + CONFIG.CONTACT_PHONE + ')。';
}
function getAuthorizedGoogleModels(apiKey) {
const cache = CacheService.getScriptCache();
const cached = cache.get('CAMPUS_GEMINI_MODELS_V1');
if (cached) {
try { return JSON.parse(cached); } catch(e) {}
}
try {
const url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' + apiKey;
const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
const data = JSON.parse(resp.getContentText());
if (data.models && data.models.length > 0) {
const valid = [];
for (let i = 0; i < data.models.length; i++) {
const m = data.models[i];
if (m.supportedGenerationMethods && m.supportedGenerationMethods.indexOf('generateContent') !== -1) {
valid.push(m.name.replace('models/', ''));
}
}
valid.sort(function(a, b) {
if (a.indexOf('flash') !== -1 && b.indexOf('flash') === -1) return -1;
if (a.indexOf('flash') === -1 && b.indexOf('flash') !== -1) return 1;
return 0;
});
if (valid.length > 0) {
cache.put('CAMPUS_GEMINI_MODELS_V1', JSON.stringify(valid), 86400);
return valid;
}
}
} catch (e) {
console.warn('getAuthorizedGoogleModels error:', e);
}
return ['gemini-2.5-flash', 'gemini-1.5-flash', 'gemini-1.5-flash-latest'];
}
// ============================================================================
// 🚀 工具函式庫 (LINE 訊息傳送、排版美化、日期格式化)
// ============================================================================
function sendLineReply(replyToken, messageText) {
if (!CONFIG.LINE_ACCESS_TOKEN || CONFIG.LINE_ACCESS_TOKEN.includes('填入您的')) {
console.warn('LINE_ACCESS_TOKEN 未設定');
return;
}
const url = 'https://api.line.me/v2/bot/message/reply';
UrlFetchApp.fetch(url, {
method: 'post',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + CONFIG.LINE_ACCESS_TOKEN
},
payload: JSON.stringify({
replyToken: replyToken,
messages: [{ type: 'text', text: messageText }]
}),
muteHttpExceptions: true
});
}
function showLineLoading(chatId) {
if (!chatId || !CONFIG.LINE_ACCESS_TOKEN || CONFIG.LINE_ACCESS_TOKEN.includes('填入您的')) return;
try {
UrlFetchApp.fetch('https://api.line.me/v2/bot/chat/loading/start', {
method: 'post',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + CONFIG.LINE_ACCESS_TOKEN
},
payload: JSON.stringify({ chatId: chatId, loadingSeconds: 20 }),
muteHttpExceptions: true
});
} catch (e) {}
}
function logConversation(q, a, user) {
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG.LOG_SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(CONFIG.LOG_SHEET_NAME);
sheet.appendRow(['時間', '提問者ID', '提問內容', 'AI回覆']);
sheet.getRange('A1:D1').setBackground('#0f766e').setFontColor('#ffffff').setFontWeight('bold');
}
sheet.appendRow([new Date(), user, q, a]);
} catch (e) {}
}
function sanitizeLineFormat(text) {
if (!text) return '';
return text
.replace(/\*\*(.*?)\*\*/g, '【$1】')
.replace(/^\s*[\*\-]\s*([📱🟢🏫⏰📝💡👉📅🏢•【「])/gm, '$1')
.replace(/^\s*[\*\-]\s+/gm, '• ')
.replace(/\*(.*?)\*/g, '$1')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function normalizeChineseDate(text) {
if (!text) return '';
let str = text;
const cnNums = [
{ cn: '三十一', num: '31' }, { cn: '三十', num: '30' }, { cn: '二十九', num: '29' },
{ cn: '二十八', num: '28' }, { cn: '二十七', num: '27' }, { cn: '二十六', num: '26' },
{ cn: '二十五', num: '25' }, { cn: '二十四', num: '24' }, { cn: '二十三', num: '23' },
{ cn: '二十二', num: '22' }, { cn: '二十一', num: '21' }, { cn: '二十', num: '20' },
{ cn: '十九', num: '19' }, { cn: '十八', num: '18' }, { cn: '十七', num: '17' },
{ cn: '十六', num: '16' }, { cn: '十五', num: '15' }, { cn: '十四', num: '14' },
{ cn: '十三', num: '13' }, { cn: '十二', num: '12' }, { cn: '十一', num: '11' },
{ cn: '十', num: '10' }, { cn: '九', num: '9' }, { cn: '八', num: '8' },
{ cn: '七', num: '7' }, { cn: '六', num: '6' }, { cn: '五', num: '5' },
{ cn: '四', num: '4' }, { cn: '三', num: '3' }, { cn: '二', num: '2' }, { cn: '一', num: '1' }
];
for (let i = 0; i < cnNums.length; i++) {
const item = cnNums[i];
str = str.split(item.cn + '月').join(item.num + '月');
str = str.split(item.cn + '日').join(item.num + '日');
str = str.split(item.cn + '號').join(item.num + '號');
}
return str;
}
function formatDateStr(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ============================================================================
// 🩺 自我診斷與連線檢測 (可在 Apps Script 介面直接選取執行 Run)
// ============================================================================
function testSelfCheck() {
console.log('=== 開始執行校園 AI 小幫手自我檢測 ===');
console.log('1. 學校名稱: ' + CONFIG.SCHOOL_NAME);
console.log('2. 服務單位: ' + CONFIG.UNIT_NAME);
console.log('3. 管理者稱呼: ' + CONFIG.ADMIN_TITLE);
console.log('4. GEMINI_API_KEY 狀態: ' + (CONFIG.GEMINI_API_KEY && !CONFIG.GEMINI_API_KEY.includes('填入') ? '✅ 已設定' : '❌ 未設定或為預設值'));
console.log('5. LINE_ACCESS_TOKEN 狀態: ' + (CONFIG.LINE_ACCESS_TOKEN && !CONFIG.LINE_ACCESS_TOKEN.includes('填入') ? '✅ 已設定' : '❌ 未設定或為預設值'));
if (CONFIG.GEMINI_API_KEY && !CONFIG.GEMINI_API_KEY.includes('填入')) {
console.log('6. 正在測試 Gemini API 回應...');
const reply = generateAiResponse('請問校園無線網路如何連線?', 'test_user');
console.log('--- AI 回覆測試結果 ---\n' + reply);
}
console.log('=== 檢測完畢 ===');
}
// ============================================================================
// 📦 一鍵初始化知識庫 (填入 12 大台灣校園通用標準知識題庫)
// ============================================================================
function initKnowledgeBaseSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(CONFIG.SHEET_NAME);
} else {
sheet.clear();
}
sheet.appendRow(['分類', '標題 / 業務項目', '詳細內容與設定步驟', '狀態', '更新時間']);
sheet.getRange('A1:E1').setBackground('#0f766e').setFontColor('#ffffff').setFontWeight('bold');
const defaultKnowledge = [
[
"校園網路與帳號",
"校園無線網路 (Wi-Fi) 連線與設定指南",
`1. 請在裝置上搜尋校園無線網路基地台名稱(SSID):通常為各縣市教育專用網路(如 eduroam 或縣市教育網路)。
2. 驗證身分:
• 帳號:輸入您的教育雲端帳號(OpenID 完整帳號或縣市公務信箱帳號)。
• 密碼:輸入教育雲端帳號之密碼。
3. 若跳出憑證信任視窗,請點選「信任」或「繼續」。
4. 漫遊連線:在全台灣各大專院校及中小學皆可透過「eduroam」跨校自動漫遊連線上網。
💡 若多次嘗試仍無法連線,請攜帶筆電至 ${CONFIG.CONTACT_LOCATION} 洽詢 ${CONFIG.ADMIN_TITLE}(${CONFIG.CONTACT_PHONE})。`,
"啟用",
new Date()
],
[
"校園網路與帳號",
"教育部教育雲端帳號 (OpenID) 登入與密碼重設",
`1. 教育雲端帳號為全台灣教師通用的單一簽入 (SSO) 帳號,可用於登入因材網、磨課師、Cool English 等各大教學平台。
2. 官方登入入口:https://oidc.tanet.edu.tw
3. 忘記密碼處理方式:
• 點擊登入頁面的「忘記密碼」,透過當初預留的公務備援信箱重設。
• 或連繫校內 ${CONFIG.ADMIN_TITLE} 協助進入縣市教育局處管理後台進行密碼重設。`,
"啟用",
new Date()
],
[
"教室設備簡易排障",
"觸控大屏 / 教室投影機無畫面無聲音排查 3 步驟",
`1. 【檢查實體線路】:
• 確認筆電與大屏/投影機間的 HDMI 訊號線已兩端插緊,未有鬆脫或凹折。
• 若使用 Type-C 轉接頭,請嘗試拔出轉接頭重新插緊。
2. 【檢查投影訊號源輸入 (Source)】:
• 按大屏正面面板或投影機遙控器上的「Input / Source」鍵,切換至對應的 HDMI 孔位(通常為 HDMI 1 或 前置 HDMI)。
3. 【Windows 投影快捷鍵】:
• 鍵盤同時按下「Win 鍵 + P」,右側選單選擇「同步顯示(Duplicate)」或「延伸(Extend)」。
💡 若排查後仍無訊號,請在 LINE 輸入「#報修 [班級教室] 大屏無畫面」即時開單通報!`,
"啟用",
new Date()
],
[
"教室設備簡易排障",
"筆記型電腦無線投影至觸控大屏操作指南",
`1. 確認連線同網段:確保筆記型電腦或平板已連上校園內部無線網路(與大屏在相同 Wi-Fi 網段)。
2. 啟動大屏投影程式:點開大屏桌面內建之無線投影應用程式(如 vCast、ViewBoard Cast 或 ScreenShare)。
3. 筆電端連線:
• Windows:按 Win+K 開啟「投放」,點選螢幕上顯示的大屏名稱,輸入配對碼即可投影。
• Mac / iPad:點選控制中心的「螢幕鏡像輸出」,選取教室大屏並輸入畫面上的 4 位數驗證碼。`,
"啟用",
new Date()
],
[
"印表機與列印",
"各辦公室公務印表機安裝與網路連線指引",
`1. 確認印表機 IP 位址:請查看各辦公室印表機機身張貼之 IP 標籤,或向 ${CONFIG.ADMIN_TITLE} 索取辦公室印表機分配清單。
2. Windows 安裝步驟:
• 開啟「設定」➔「藍牙與裝置」➔「印表機與掃描器」➔ 點選「新增裝置」➔ 選擇「手動新增」。
• 選擇「使用 TCP/IP 位址或主機名稱新增印表機」,輸入該印表機之 IP 位址並完成驅動安裝。
3. 列印計費或密碼控管:
• 若印表機設有列印控管碼,請進入「列印喜好設定」➔「工作處理/用戶驗證」輸入您的個人密碼。
💡 若遇印表機卡紙,請先拉開對應紙匣依箭頭方向平緩拉出夾紙,請勿硬扯避免滾輪受損。`,
"啟用",
new Date()
],
[
"數位教學與軟體",
"Canva 教育版 (Canva for Education) 教師免費認證申請",
`1. 教育部與 Canva 合作提供全台編制內中小學教師免費使用 Canva 專業教育版功能。
2. 申請網址:https://www.canva.com/edu-signup/
3. 申請流程:
• 使用學校提供的 Google 公務帳號或教育雲帳號登入註冊。
• 點選「免費獲取教育版認證」,上傳本學期在職證明書或聘書影本(需拍清楚姓名與學校全銜)。
• 通常 24-48 小時內審核通過,即可解鎖所有字型、範本與教育素材,並可建立學生班級空間。`,
"啟用",
new Date()
],
[
"數位教學與軟體",
"Google Classroom 班級教學平台建立指引",
`1. 登入官方平台:https://classroom.google.com (請務必使用學校公務教育信箱登入,勿使用私人 gmail)。
2. 建立課程:點選右上角「+」➔「建立課程」,輸入學年度與科目班級名稱。
3. 邀請學生加入:
• 將課程代碼投影於大螢幕,讓學生自行輸入代碼加入。
• 或在「成員」頁籤貼上整班學生公務帳號清單一次寄發邀請。`,
"啟用",
new Date()
],
[
"數位研習增能管考",
"教育部推動中小學數位學習精進方案增能研習 (A1/A2/A3) 規範",
`全體編制內教師依法規須完成以下數位增能研習時數:
1. 【A1 數位學習工作坊 (一)】:3 小時,涵蓋科技輔助自主學習理念。
2. 【A2 數位學習工作坊 (二)】:3 小時,各數位學習平台操作與課堂融入(如因材網、學習吧、均一等)。
3. 【A3 數位素養增能研習】:3 小時,網路識讀、數位隱私與生成式 AI 素養。
💡 查詢已獲時數:請登入「全國教師在職進修資訊網」查詢個人研習履歷紀錄。`,
"啟用",
new Date()
],
[
"數位研習增能管考",
"教育部「edu 磨課師+」線上數位研習修課與時數認證指引",
`1. 線上自學平台入口:https://moocs.moe.edu.tw
2. 登入身分:點選「教育雲端帳號登入(OpenID)」,登入後請先檢查個人資料中的「身分證字號」是否正確。
3. 搜尋課程:於課程列表搜尋「A3 數位素養」或指定專業研習名稱進行修課。
4. 時數核發機制:課程觀看進度滿 100% 且通過課後測驗後,系統將於次月自動將時數匯入「全國教師在職進修網」,無需人工送件。`,
"啟用",
new Date()
],
[
"校務行政與差勤",
"Web 版差勤請假系統校外連線 (VPN) 設定指引",
`1. 各縣市教育局處規定差勤系統通常設置於教育局防火牆內,若在校外請假需透過 VPN 安全連線。
2. 電腦版連線:請至各縣市教育網路中心下載專用 VPN 用戶端程式(如 SSL-VPN),安裝後輸入公務帳密完成連線。
3. 手機版連線:下載官方指定 VPN App,設定閘道伺服器位址與通訊埠(Port),連線成功後再開啟差勤請假系統。
💡 若於休假期間遇緊急排代需求,請先電話通報教務處教學組以利即時調課。`,
"啟用",
new Date()
],
[
"校園資安與個資",
"校園資通安全防護原則與社交工程演練防範",
`1. 【嚴禁私設未授權 AP 熱點】:依資通安全法規,嚴禁私自於教室實體網路孔私接家用 Wi-Fi 分享器,避免成為資安入侵破口。
2. 【防範社交工程釣魚郵件】:
• 遇要求「立即變更密碼」、「信箱配額已滿」、「請點擊此連結核對人事資料」等信件,請勿點擊連結或下載附件。
• 請留意寄件者真實完整 email 地址,公私帳號嚴格分流。
3. 【密碼安全性要求】:公務系統密碼請至少每 6 個月更換一次,並包含英數大小寫與符號。`,
"啟用",
new Date()
],
[
"設備報修與耗材",
"校園設備故障線上報修與公務耗材領用流程",
`1. 【線上極速報修】:
• 直接在本 LINE 對話框輸入:「#報修 [教室或地點] [設備名稱與故障現象]」
• 範例:#報修 702班投影機遙控器遺失
• 系統將自動產生報修單號並即時通知 ${CONFIG.ADMIN_TITLE} 前往檢修。
2. 【查詢待處理工單】:輸入「#查詢報修」即可查看目前尚未結案之報修紀錄。
3. 【印表機碳粉與公務耗材】:請至 ${CONFIG.CONTACT_LOCATION} 填寫領用登記表領取。`,
"啟用",
new Date()
]
];
defaultKnowledge.forEach(function(row) {
sheet.appendRow(row);
});
sheet.autoResizeColumns(1, 5);
console.log('🎉 校園通用知識庫初始化完成!已填入 ' + defaultKnowledge.length + ' 筆標準校園題庫。');
} 貼到 Apps Script 的 Code.gs 檔案
5. 貼上前端(index.html)
把前端程式貼到 Apps Script 的 index.html。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>光明國民中學 教務處資訊組 AI 智慧小幫手 - 視覺化控制台</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans TC", sans-serif; }
.code-scroll::-webkit-scrollbar { height: 6px; width: 6px; }
.code-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
</style>
</head>
<body class="bg-slate-100 text-slate-800 min-h-screen">
<!-- 頂部導航 -->
<header class="bg-teal-900 text-white shadow-md">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-teal-500 flex items-center justify-center text-xl font-bold">
<i class="fa-solid fa-robot"></i>
</div>
<div>
<h1 class="text-lg font-bold">光明國民中學 校園資訊 AI 小幫手</h1>
<p class="text-xs text-teal-200">教務處資訊組・LINE Bot 智慧知識庫視覺化管理後台</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="px-3 py-1 bg-teal-800 border border-teal-600 rounded-full text-xs text-teal-200">
<i class="fa-solid fa-circle text-emerald-400 text-[10px] mr-1"></i>Gemini RAG 運行中
</span>
</div>
</div>
</header>
<!-- 主內容區 -->
<main class="max-w-6xl mx-auto px-4 py-8 space-y-6">
<!-- 快捷分類標籤與搜尋 -->
<div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm space-y-4">
<div class="flex flex-col sm:flex-row items-center justify-between gap-3">
<div class="relative w-full sm:w-80">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-3 text-slate-400 text-sm"></i>
<input type="text" id="searchInput" placeholder="搜尋業務、網路、帳號、研習、排障..."
class="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-xl focus:ring-2 focus:ring-teal-500 focus:outline-none"
oninput="filterCards()">
</div>
<div class="flex flex-wrap gap-1.5 text-xs">
<button onclick="filterCategory('ALL')" class="cat-btn px-3 py-1.5 rounded-lg bg-teal-700 text-white font-medium">全部</button>
<button onclick="filterCategory('校園網路與帳號')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">網路帳號</button>
<button onclick="filterCategory('教室設備簡易排障')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">設備排障</button>
<button onclick="filterCategory('印表機與列印')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">印表機</button>
<button onclick="filterCategory('數位教學與軟體')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">教學軟體</button>
<button onclick="filterCategory('數位研習增能管考')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">數位研習</button>
<button onclick="filterCategory('校務行政與差勤')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">差勤行政</button>
<button onclick="filterCategory('校園資安與個資')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">資安個資</button>
<button onclick="filterCategory('設備報修與耗材')" class="cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200">報修耗材</button>
</div>
</div>
</div>
<!-- 知識庫卡片展示區 -->
<div id="loading" class="text-center py-12 text-slate-400">
<i class="fa-solid fa-spinner fa-spin text-3xl mb-3 text-teal-600"></i>
<p class="text-sm">正在載入試算表知識庫資料...</p>
</div>
<div id="cardGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 hidden"></div>
<div id="emptyState" class="text-center py-16 bg-white rounded-2xl border border-slate-200 shadow-sm hidden">
<i class="fa-solid fa-folder-open text-4xl text-slate-300 mb-2"></i>
<p class="text-slate-500 text-sm">找不到符合搜尋條件的知識庫項目</p>
</div>
</main>
<script>
let rawData = [];
let currentCategory = 'ALL';
window.onload = function() {
google.script.run
.withSuccessHandler(renderData)
.withFailureHandler(function(err) {
document.getElementById('loading').innerHTML =
'<p class="text-rose-500 font-medium">載入失敗:' + err + '</p>';
})
.getKnowledgeBaseData();
};
function renderData(data) {
rawData = data || [];
document.getElementById('loading').classList.add('hidden');
document.getElementById('cardGrid').classList.remove('hidden');
filterCards();
}
function filterCategory(cat) {
currentCategory = cat;
document.querySelectorAll('.cat-btn').forEach(btn => {
if (btn.innerText === cat || (cat === 'ALL' && btn.innerText === '全部') || btn.getAttribute('onclick').includes(cat)) {
btn.className = 'cat-btn px-3 py-1.5 rounded-lg bg-teal-700 text-white font-medium';
} else {
btn.className = 'cat-btn px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200';
}
});
filterCards();
}
function filterCards() {
const kw = document.getElementById('searchInput').value.trim().toLowerCase();
const grid = document.getElementById('cardGrid');
grid.innerHTML = '';
let matched = rawData.filter(item => {
const matchCat = currentCategory === 'ALL' || item.category.includes(currentCategory);
const matchKw = !kw || item.title.toLowerCase().includes(kw) || item.content.toLowerCase().includes(kw);
return matchCat && matchKw;
});
if (matched.length === 0) {
document.getElementById('emptyState').classList.remove('hidden');
return;
}
document.getElementById('emptyState').classList.add('hidden');
matched.forEach(item => {
const card = document.createElement('div');
card.className = 'bg-white rounded-2xl border border-slate-200 p-5 shadow-sm hover:shadow-md transition-shadow flex flex-col justify-between';
card.innerHTML = `
<div>
<div class="flex items-center justify-between mb-2">
<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-teal-50 text-teal-700 border border-teal-200">
${escapeHtml(item.category)}
</span>
<span class="text-xs text-emerald-600 flex items-center gap-1 font-medium">
<i class="fa-solid fa-circle text-[8px]"></i> 啟用中
</span>
</div>
<h3 class="font-bold text-slate-800 text-base mb-2">${escapeHtml(item.title)}</h3>
<div class="text-xs text-slate-600 leading-relaxed whitespace-pre-line bg-slate-50 p-3 rounded-xl border border-slate-100 max-h-48 overflow-y-auto code-scroll">
${escapeHtml(item.content)}
</div>
</div>
`;
grid.appendChild(card);
});
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案
6. 部署
部署成 Web App 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論