0. 作品介紹

作者 陳乃誠 竹光國中

告別每年招生迎新季電話接不完、家長群組每天被問 800 次「報到要帶什麼?」、「制服去哪買?」、「作息幾點放學?」的重複輪迴! Google 試算表就是知識大腦,隨改隨生效;整合在線提問留言簿與未解問題自進化庫,10 分鐘為學校建立 24 小時親善 AI 導覽員。


70 1 0
辦公
封面圖 封面

2. 資料表規則

請照下面規則建立分頁與欄位。

第1分頁: 新生與校園知識庫 A 欄: 問題分類 B 欄: 標題 / 主題 C 欄: 詳細說明內容與常見問答 D 欄: 啟用狀態 E 欄: 最後更新時間

📄 解析結果(自動表格)

分頁:新生與校園知識庫
A 欄 B 欄 C 欄 D 欄 E 欄
問題分類 標題 / 主題 詳細說明內容與常見問答 啟用狀態 最後更新時間

3. 開啟 Apps Script

從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。

  1. 試算表上方選單:擴充功能 → Apps Script
  2. 刪掉預設的程式碼(或全部選取覆蓋)
  3. 保留/建立檔案:
    • Code.gs
    • index.html

4. 貼上後端(Code.gs)

把後端程式貼到 Apps Script 的 Code.gs。

Code.gs(後端)
/**
 * ============================================================================
 * 🏫 全國校園【校園導覽與新生家長指南】LINE Bot AI 智慧小幫手・通用開源範本
 * ============================================================================
 * 專為全台各級學校(國小、國中、高中職)新生入學報到、家長日、校園開放參訪、
 * 以及向社區民眾與陌生訪客介紹學校打造的 24 小時智慧親善 AI 助理。
 * 
 * 核心特色:
 * 1. ⚡ Google 試算表 RAG 知識庫大腦:行政與註冊組同仁隨時更新問答,隨改隨生效,零技術門檻!
 * 2. 💌 在線家長提問留言簿(支援「#提問」、「#留言」、「#諮詢」自動生成諮詢單號並寫入試算表)
 * 3. 🛠️ 兼容設備線上報修系統(支援「#報修 地點與問題」自動化登記,校內同仁亦可使用)
 * 4. 🧠 未解問題自進化收集庫(遇冷門或特例問題自動記下,處室後續補填答案,年年累積學校知識)
 * 5. 📱 LINE 群組與私訊雙模式(私訊即時秒回;家長班群需 @小幫手 提問,杜絕洗版干擾)
 * 6. ⏳ LINE 原生「正在輸入中...」載入中動畫(防止家長重複按鍵)
 * 7. 📅 可選擴充:Google 公開行事曆即時查詢(開學日、報到日、段考、親師座談會等)
 * 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: '校園迎新與導覽小幫手',                   // 機器人稱呼
  ADMIN_TITLE: '入學諮詢老師',                       // 諮詢代表稱呼(例:入學諮詢老師、註冊組長)
  CONTACT_PHONE: '總機 (03)534-5678 / 新生諮詢分機 112', // 諮詢專線或總機
  CONTACT_LOCATION: '行政大樓 1 樓 教務處',          // 辦公室位置
  SCHOOL_ADDRESS: '新竹市北區竹光路 99 號',          // 學校地址與大眾交通備註
  OFFICIAL_WEB_URL: 'https://example.school.edu.tw', // 學校官網或新生專區網址

  // --------------------------------------------------------------------------
  // 3. 可選擴充模組 (若無需求請維持預設空字串 '',系統將自動略過不報錯)
  // --------------------------------------------------------------------------
  // [擴充 A] Google 公開行事曆 (填入行事曆 ID 即可查詢新生報到日、親師日、段考時程)
  CALENDAR_ID: '', // 例: your_school@group.calendar.google.com
  CALENDAR_WEB_URL: '', // 線上行事曆瀏覽網址

  // [擴充 B] 線上校園設備/場地借用 API (若校內有獨立預約借用系統可填入)
  BOOKING_API_URL: '', 
  BOOKING_WEB_URL: '', 

  // --------------------------------------------------------------------------
  // 4. 管理後台與工作表設定
  // --------------------------------------------------------------------------
  ADMIN_PASSWORD: '',                      // 手機 LINE 寫入知識庫密碼(留空即為免密碼模式)
  SHEET_NAME: '新生與校園知識庫',            // 知識庫主要儲存工作表
  LOG_SHEET_NAME: '家長訪客對話紀錄',        // 提問對話紀錄表
  INQUIRY_SHEET_NAME: '家長提問留言簿',      // 家長在線諮詢留言表 (#提問 #留言)
  REPAIR_SHEET_NAME: '設備報修登記表',       // 設備線上報修表 (#報修)
  UNANSWERED_SHEET_NAME: '待補充問題庫'     // AI 未命中待補問題表
};

// ============================================================================
// 🤖 動態組裝 AI 導覽小幫手人設 Prompt (System Instruction)
// ============================================================================
function getSystemInstruction() {
  return `你是一位專業、熱情、親切且富有同理心的「${CONFIG.SCHOOL_NAME}校園迎新與導覽 AI 小幫手」(代表${CONFIG.SCHOOL_NAME} ${CONFIG.UNIT_NAME}的校園親善大使與${CONFIG.ADMIN_TITLE})。
你的主要任務是協助新生家長、新生同學、轉學生家庭、社區民眾與校外參訪貴賓,全面了解本校的辦學理念、特色課程、新生入學報到、作息服裝、營養午餐、特色社團、課後照顧與校園生活指引。

【學校基本資訊與服務窗口】:
- 學校全名:${CONFIG.SCHOOL_NAME}
- 諮詢單位:${CONFIG.UNIT_NAME}
- 諮詢窗口:${CONFIG.ADMIN_TITLE}
- 學校地址:${CONFIG.SCHOOL_ADDRESS}
- 處室位置:${CONFIG.CONTACT_LOCATION}
- 諮詢電話/分機:${CONFIG.CONTACT_PHONE}
${CONFIG.OFFICIAL_WEB_URL ? '- 學校官網/專區:' + CONFIG.OFFICIAL_WEB_URL : ''}

【回答原則與排版規範(極重要)】:
1. 一律使用台灣繁體中文,語氣親切溫暖、誠懇耐心、條理分明。多站在新生家長初次接觸學校的心情,給予具體的指引與安心感,杜絕官僚生硬的公文用語。
2. 常用台灣校園生活詞彙:新生、報到、學區、戶籍、制服、運動服、繡學號、營養午餐、安心午餐券、課後輔導、第八節、學習扶助、多元社團、校隊、會考、段考、大屏幕、平板、聯絡簿、家長接送區、YouBike。
3. 【嚴格 LINE 排版規範】:
   - 嚴禁使用 Markdown 粗體星號(絕對不要輸出 **粗體** 或 *斜體* 或 ### 標題 或 - 破折號清單)!
   - 強調重點請使用【...】或「...」。
   - 清單一律使用 Emoji 圖示或數字(如 🏫、🎒、🍱、🚌、⏰、📝、💡、👉、📅、🏢、•、1. 2. 3.)並適度空行,方便家長在手機 LINE 上舒適閱覽。
4. 若涉及各處室權責,請清楚提示洽詢分機:
   - 新生報到、戶籍學區、轉學、成績證明 ➔ 教務處註冊組
   - 服裝儀容、作息、請假、交通接送、社團校隊 ➔ 學務處
   - 學雜費減免、校園場地借用 ➔ 總務處
   - 特殊教育、身心適應、資優鑑輔 ➔ 輔導室
5. 若知識庫內查無特定答案(如極特殊的個別戶籍轉移案例或雙胞胎跨學區問題),請委婉引導家長可於上班時間致電 ${CONFIG.CONTACT_PHONE} 洽詢 ${CONFIG.ADMIN_TITLE},或告知家長可直接輸入「#提問 [問題內容]」進行在線諮詢登記,處室將會查覆!`;
}

// ============================================================================
// 🌐 Web 介面與 LINE Webhook 入口
// ============================================================================

function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle(`${CONFIG.SCHOOL_NAME} ${CONFIG.BOT_NAME} - 控制台`)
    .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 (/^(#提問|提問|#留言|留言|#諮詢|諮詢)\s*/.test(userMessage)) {
        const inquiryReply = handleInquiryCommand(userMessage, userId);
        sendLineReply(replyToken, inquiryReply);
        logConversation(userMessage, inquiryReply, userId);
        return ContentService.createTextOutput('OK');
      }

      // 4. 檢查是否為設備線上報修指令 (#報修 ...)
      if (userMessage.startsWith('#報修') || userMessage.startsWith('報修 ')) {
        const repairReply = handleRepairCommand(userMessage, userId);
        sendLineReply(replyToken, repairReply);
        logConversation(userMessage, repairReply, userId);
        return ContentService.createTextOutput('OK');
      }

      // 5. 檢查是否為管理員/查詢指令 (#記住, #查詢, #查詢提問, #查詢報修)
      if (userMessage.startsWith('#')) {
        const adminReply = handleAdminCommand(userMessage);
        sendLineReply(replyToken, adminReply);
        return ContentService.createTextOutput('OK');
      }

      // 6. 一般使用者提問:執行 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 handleInquiryCommand(text, userId) {
  const content = text.replace(/^(#提問|提問|#留言|留言|#諮詢|諮詢)\s*/, '').trim();
  if (!content) {
    return `💌 【${CONFIG.SCHOOL_NAME}・新生家長與訪客在線諮詢留言】\n\n` +
      '若有特殊問題或需要處室專人查覆,請依以下格式輸入:\n' +
      '👉 #提問 [您的問題或諮詢內容]\n\n' +
      '💡 範例:\n' +
      '• #提問 請問雙胞胎新生可以申請編入同班嗎?\n' +
      '• #提問 請問外縣市遷入戶籍報到期限到何時?\n' +
      '• #提問 假日想借用體育館辦理活動,請問如何申請?\n\n' +
      `系統將自動建立諮詢單號,並同步通知【${CONFIG.UNIT_NAME}】查覆!`;
  }

  const now = new Date();
  const twTimeStr = Utilities.formatDate(now, 'GMT+8', 'yyyy/MM/dd HH:mm');
  const inquiryId = 'Q' + Utilities.formatDate(now, 'GMT+8', 'MMddHHmm');

  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    let sheet = ss.getSheetByName(CONFIG.INQUIRY_SHEET_NAME);
    if (!sheet) {
      sheet = ss.insertSheet(CONFIG.INQUIRY_SHEET_NAME);
      sheet.appendRow(['諮詢單號', '登記時間', '提問者ID', '提問留言內容', '處理狀態', '處室回覆備註', '回覆時間']);
      sheet.getRange('A1:G1').setBackground('#0f766e').setFontColor('#ffffff').setFontWeight('bold');
    }

    sheet.appendRow([inquiryId, twTimeStr, userId, content, '⏳ 待處室查覆', '', '']);
    return `💌 【${CONFIG.SCHOOL_NAME}・諮詢登記已受理】\n\n` +
      `• 諮詢單號:${inquiryId}\n` +
      `• 登記時間:${twTimeStr}\n` +
      `• 提問內容:${content}\n\n` +
      `您的問題已同步記錄至【${CONFIG.INQUIRY_SHEET_NAME}】!\n` +
      `【${CONFIG.UNIT_NAME}】老師將儘速查明,您亦可於上班時間致電:${CONFIG.CONTACT_PHONE} 洽詢。感謝您對學校的支持!`;
  } catch (err) {
    console.error('handleInquiryCommand error:', err);
    return `⚠️ 抱歉,留言登記暫時發生異常,請直接致電 ${CONFIG.UNIT_NAME}(${CONFIG.CONTACT_PHONE})。`;
  }
}

// ============================================================================
// 🛠️ 設備線上報修登記系統 (#報修)
// ============================================================================

function handleRepairCommand(text, userId) {
  const content = text.replace(/^(#報修|報修)\s*/, '').trim();
  if (!content) {
    return `🛠️ 【${CONFIG.SCHOOL_NAME}・設備線上報修格式說明】\n\n` +
      '請輸入:#報修 [地點/教室] [設備名稱與故障現象]\n\n' +
      '👉 範例:\n' +
      '• #報修 701教室觸控大屏無聲音\n' +
      '• #報修 活動中心羽球場燈具不亮\n\n' +
      `💡 登記後將即時寫入系統工單表並前往排查!`;
  }

  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('#0f766e').setFontColor('#ffffff').setFontWeight('bold');
    }

    sheet.appendRow([repairId, twTimeStr, userId, content, '⏳ 待處理', '', '']);
    return `🛠️ 【${CONFIG.SCHOOL_NAME}・設備報修受理通知】\n\n` +
      `• 工單編號:${repairId}\n` +
      `• 登記時間:${twTimeStr}\n` +
      `• 故障內容:${content}\n\n` +
      `已成功寫入「${CONFIG.REPAIR_SHEET_NAME}」,管理人員將儘速派員檢修!`;
  } catch (err) {
    console.error('handleRepairCommand error:', err);
    return `⚠️ 抱歉,線上報修登記失敗,請直接聯繫總務處或 ${CONFIG.ADMIN_TITLE}(${CONFIG.CONTACT_PHONE})。`;
  }
}

// ============================================================================
// 📅 Google 公開行事曆查詢 (可選模組)
// ============================================================================

function getSchoolCalendarSummary(query) {
  if (!CONFIG.CALENDAR_ID || CONFIG.CALENDAR_ID.trim() === '') {
    return '';
  }

  try {
    const calendar = CalendarApp.getCalendarById(CONFIG.CALENDAR_ID.trim());
    if (!calendar) return '';

    const now = new Date();
    const twNow = new Date(now.getTime() + (8 * 3600 * 1000) + (now.getTimezoneOffset() * 60000));
    
    let daysAhead = 7;
    let daysPast = 1;

    if (query.indexOf('這學期') !== -1 || query.indexOf('整學期') !== -1 || query.indexOf('月曆') !== -1) {
      daysAhead = 60;
    } else if (query.indexOf('下個月') !== -1 || query.indexOf('這個月') !== -1 || query.indexOf('本月') !== -1) {
      daysAhead = 30;
    } else if (query.indexOf('下週') !== -1 || query.indexOf('段考') !== -1 || query.indexOf('考試') !== -1 || query.indexOf('報到') !== -1) {
      daysAhead = 14;
    }

    const startTw = new Date(twNow.getTime() - (daysPast * 24 * 3600 * 1000));
    const endTw = new Date(twNow.getTime() + (daysAhead * 24 * 3600 * 1000));

    const events = calendar.getEvents(startTw, endTw);
    if (!events || events.length === 0) return '';

    const startStr = formatDateStr(startTw);
    const endStr = formatDateStr(endTw);
    const eventList = [];

    for (let i = 0; i < events.length; i++) {
      const e = events[i];
      const sum = e.getTitle().trim();
      if (!sum) continue;

      if (e.isAllDayEvent()) {
        const sTime = new Date(e.getStartTime().getTime() + (8 * 3600 * 1000) + (e.getStartTime().getTimezoneOffset() * 60000));
        const dateFmt = formatDateStr(sTime);
        const wStr = ['日', '一', '二', '三', '四', '五', '六'][sTime.getDay()];
        eventList.push({
          dateStr: dateFmt,
          display: `• 【${String(sTime.getMonth()+1).padStart(2, '0')}/${String(sTime.getDate()).padStart(2, '0')} (${wStr})】:${sum}`
        });
      } else {
        const s = new Date(e.getStartTime().getTime() + (8 * 3600 * 1000) + (e.getStartTime().getTimezoneOffset() * 60000));
        const dateFmt = formatDateStr(s);
        const wStr = ['日', '一', '二', '三', '四', '五', '六'][s.getDay()];
        const timeFmt = `${String(s.getHours()).padStart(2, '0')}:${String(s.getMinutes()).padStart(2, '0')}`;
        eventList.push({
          dateStr: dateFmt,
          display: `• 【${String(s.getMonth()+1).padStart(2, '0')}/${String(s.getDate()).padStart(2, '0')} (${wStr}) ${timeFmt}】:${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 === '#查詢提問' || cmd === '#查詢留言') {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName(CONFIG.INQUIRY_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] === '待處理' || 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 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 '❌ 請提供要記住的內容,例如:#記住 新生制服增列週六現場補購時段';

    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• #查詢報修\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 liveCalendarContext = '';
  if (CONFIG.CALENDAR_ID && /(行事曆|日程|報到日|開學|放假|家長日|親師日|活動|時程|下週|這週|本週|明天|今天)/i.test(userQuery)) {
    liveCalendarContext = getSchoolCalendarSummary(userQuery);
  }

  let liveBookingContext = '';
  if (CONFIG.BOOKING_API_URL && /(借|場地|體育館|會議室|活動中心|租借|預約)/i.test(userQuery)) {
    liveBookingContext = getLiveBookingSummary(userQuery);
  }

  const prompt = getSystemInstruction() + '\n\n' +
    '以下是「' + CONFIG.SCHOOL_NAME + ' ' + CONFIG.UNIT_NAME + '入學指南與學校介紹知識庫」:\n' +
    '--------------------------------\n' +
    contextText + '\n' +
    '--------------------------------\n' +
    (liveCalendarContext ? ('\n【📅 即時連線校園重要日程】:\n' + liveCalendarContext + '\n--------------------------------\n') : '') +
    (liveBookingContext ? ('\n【⚡ 即時場地登記資訊】:\n' + liveBookingContext + '\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.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: 15
      }),
      muteHttpExceptions: true
    });
  } catch (e) {
    console.warn('showLineLoading failed:', e);
  }
}

function sanitizeLineFormat(text) {
  if (!text) return '';
  return text
    .replace(/\*\*(.*?)\*\*/g, '【$1】')
    .replace(/\*(.*?)\*/g, '$1')
    .replace(/^#+\s+/gm, '')
    .replace(/^-\s+/gm, '• ')
    .trim();
}

function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function formatDateStr(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${y}-${m}-${d}`;
}

function logConversation(query, reply, userId) {
  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(), userId, query, reply.slice(0, 150)]);
  } catch (e) {
    console.warn('logConversation error:', e);
  }
}

function logUnansweredQuestion(query, aiReply, 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('#b91c1c').setFontColor('#ffffff').setFontWeight('bold');
    }
    sheet.appendRow([new Date(), userId, query, aiReply.slice(0, 80), '', '⏳ 待處室補充']);
  } catch (e) {
    console.warn('logUnansweredQuestion error:', e);
  }
}

// ============================================================================
// 🚀 一鍵初始化函式:建立 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 = [
    [
      "新生報到入學",
      "115學年度新生報到日期、線上報到網址與必備驗證文件",
      `1. 【報到時程】:
   • 線上報到:每年 5 月中旬至 6 月初(請至縣市教育局新生報到系統辦理)。
   • 現場審驗:指定週六上午 08:30 ~ 11:30(請留意學校官網最新公告)。
2. 【必備攜帶文件】:
   • 國小畢業證書正本(現場繳交由國中端收執)。
   • 全戶戶口名簿正本與影本(學生及家長戶籍需在所屬學區內)。
   • 新生入學基本資料調查表(可先線上填寫或現場填寫)。
   • 特殊身分證明文件(低收、中低收、身心障礙手冊或原住民身分證明,供減免教科書與各項學雜費)。
3. 【雙胞胎編班申請】:若為雙胞胎或多胞胎新生,可於報到現場填具「同班或不同班需求切結書」。
💡 如有戶籍移轉或額滿學校分發疑問,請洽【${CONFIG.UNIT_NAME}】(${CONFIG.CONTACT_PHONE})。`,
      "啟用",
      new Date()
    ],
    [
      "服裝作息餐食",
      "制服運動服購買管道、試穿套量時間與學號繡製規定",
      `1. 【購買方式】:
   • 學校合作社或指定廠商於暑假期間(新生訓練日及返校日)在活動中心提供現場套量試穿與販售服務。
   • 開學後若需加購,可於放學時段至校門周邊特約學生服飾行選購。
2. 【學號繡製規範】:
   • 學號繡製於制服及運動外套左胸口袋上方(男綠女紅或依各學年度指定色碼,入學簡章會附詳細色票)。
   • 運動服排汗衫免繡姓名,僅需繡 5 碼學號(前兩碼為入學學年度,後三碼為班級座號)。
3. 【穿著原則】:配合體育課當日穿著運動服;其餘平日著標準校服,週五為班服日或便服日(依學務處規定)。`,
      "啟用",
      new Date()
    ],
    [
      "服裝作息餐食",
      "學生一日作息時間表、上學到校與放學時程",
      `1. 【每日作息時間表】:
   • 07:30 ~ 07:50:晨間到校時間(為維護校園安全,請勿早於 07:15 到校,導護老師於 07:20 就位)。
   • 07:50 ~ 08:25:晨讀時間/全校晨會週會/導師時間。
   • 08:30 ~ 12:00:上午第 1 至 4 節正式課程(每節 45 分鐘,下課 10 分鐘)。
   • 12:00 ~ 12:35:營養午餐用餐時間。
   • 12:35 ~ 13:10:全校午休靜息時間。
   • 13:20 ~ 15:55:下午第 5 至 7 節課程。
   • 15:55 ~ 16:10:校園環境整潔與掃除時間。
   • 16:10:第一階段放學(未參加課後輔導之學生此時離校)。
   • 16:20 ~ 17:05:第八節課藝輔導活動,17:10 最終放學。`,
      "啟用",
      new Date()
    ],
    [
      "服裝作息餐食",
      "校園營養午餐收費標準、每月菜單查詢與素食/特殊飲食申請",
      `1. 【供餐品質】:通過 HACCP 認證之校園廚房與優良公辦民營廠商每日熱騰騰供應,嚴格落實「三章一Q」在地優質溯源食材。
2. 【每餐費用】:每餐約 50 ~ 60 元(依各縣市教育局補助政策及學期實際供餐天數一次計收於學期三聯單)。
3. 【每日菜單查詢】:家長可隨時至教育部「校園食材登錄平臺」搜尋本校,即時查看每日食材來源、營養成分與熱量標示。
4. 【特殊飲食與素食】:開學前填報飲食調查表,可全學期安排蔬食專用供餐;具蛋奶過敏或特殊體質學生可個別登記備註。
5. 【安心午餐券補助】:低收入戶、中低收入戶或家庭突遭變故之弱勢學生,由學校全額補助午餐費,並於寒暑假提供安心午餐券。`,
      "啟用",
      new Date()
    ],
    [
      "交通校園導覽",
      "學校地址、大眾運輸公車路線與家長汽機車接送區指引",
      `1. 【學校地址】:${CONFIG.SCHOOL_ADDRESS}。
2. 【大眾公車與交通】:
   • 搭乘市區公車至本校校門周邊站點下車,步行約 2-5 分鐘即可抵達校門口。
   • 校門口設有公共自行車 (YouBike) 租賃站,方便學生與訪客騎乘抵校。
3. 【家長接送區(交通安全規範)】:
   • 汽車接送區:設於校門外兩側黃線路段,實施「即停即走(臨停限 30 秒)」,請勿熄火併排或在路口大迴轉。
   • 機車接送區:設於校門人行道指定家長等候區,請家長戴妥安全帽並減速慢行。
4. 【校內停車】:上下學尖峰時段為維護學童安全,禁止家長私家車輛駛入校園(領有身障專用證或公務洽公者除外)。`,
      "啟用",
      new Date()
    ],
    [
      "特色社團課程",
      "學校多元社團活動、特色校隊與選修課程一覽",
      `1. 【多元社團開設】:本校每學期開設超過 20+ 個多元社團,涵蓋學術研究、藝術人文、音樂舞蹈、體育競技與康樂服務。
2. 【特色亮點社團與校隊】:
   • 管樂團/弦樂團:屢獲全縣及全國音樂比賽特優,提供初階樂器培訓與大型合奏訓練。
   • 體育校隊(籃球、羽球、田徑、射箭等):聘請專業教練培訓,定期代表學校征戰錦標賽。
   • 創客機器人與程式設計社:結合 Arduino、Micro:bit 與 AI 生成專案,歷年科展成果斐然。
3. 【選社機制】:新生入學後於 9 月中旬舉辦社團嘉年華,全採線上志願選填,電腦隨機分發保障每位學生權益。`,
      "啟用",
      new Date()
    ],
    [
      "特色社團課程",
      "課後學藝輔導(第八節)、學習扶助補救教學與晚自習實施辦法",
      `1. 【第八節課藝活動】:
   • 秉持「自由參加、學生自願、家長同意」原則,於學期初發放家長意願調查表。
   • 著重國英數社自等核心科目之深究與跨領域素養統整,輔以加深加廣學習。
2. 【教育部學習扶助專案(補救教學)】:
   • 針對各科段考或診斷測驗未達精熟標準之學生,由學校免費開設小班制個別化扶助課程。
   • 師資專任輔導,教材針對弱點重點突破,學費全由教育部全額補助。
3. 【九年級會考衝刺晚自習】:
   • 提供安靜舒適有空調之自修教室,安排輪值行政組長與教師進駐維持秩序,協助學生專注備戰大考。`,
      "啟用",
      new Date()
    ],
    [
      "新生報到入學",
      "學校辦學願景、教育核心理念與歷年升學/競賽亮點",
      `1. 【辦學理念】:落實 108 課綱「自發、互動、共好」核心精神,培育具備閱讀思辨、科技創新與品格涵養的世界公民。
2. 【三大核心特色】:
   • 【雙語國際教育】:引進外籍英語教師協同教學,推動跨國姐妹校文化交流與線上視訊互動。
   • 【智慧科技校園】:生生有平板輔助教學,每班皆配置高畫質觸控大屏與高速無線網路。
   • 【全人藝術品格】:重視美感素養與情緒教育,親師生氛圍溫暖包容。
3. 【升學與競賽成果】:歷屆畢業生在國中教育會考、各科展覽競賽、語文競賽與各類體育賽事表現亮眼,多元適性展能!`,
      "啟用",
      new Date()
    ],
    [
      "校園生活規定",
      "學生在校攜帶手機與智慧手錶規範、生生用平板教學管理",
      `1. 【手機攜帶規定】:
   • 為維護課堂專注與視力健康,學生攜帶手機入校須由家長填寫「手機攜帶申請書」。
   • 上學抵校後,手機由班級幹部統一收齊鎖入「班級專用養機場(防潮鎖櫃)」,放學後統一發還。
   • 在校期間如遇突發緊急事項,可經師長同意使用或使用處室公用電話聯繫家長。
2. 【智慧手錶管理】:禁止於段考、競試期間佩戴具上網、通訊或錄音功能之智慧手錶。
3. 【平板教學規範】:教學用平板由學校統籌管理,已安裝教育部教育白名單過濾系統,禁止安裝非學習用途之遊戲或社群軟體。`,
      "啟用",
      new Date()
    ],
    [
      "校園生活規定",
      "學生請假手續(病假/事假)、差勤回報與親師溝通管道",
      `1. 【請假管道】:
   • 當日臨時病假:請家長於早上 07:30 ~ 08:00 前致電學務處請假專線或透過班級通訊群組告知導師。
   • 事假手續:請於 3 天前由家長於聯絡簿填寫並檢附相關事由證明,送交學務處完成請假單簽核。
   • 病假手續:連續請假 3 天以上須檢附就醫收據或合格醫療院所診斷證明書。
2. 【親師溝通管道】:
   • 每日家庭聯絡簿(親師每日雙向留言交流)。
   • 每學期初辦理之「學校日 / 全校親師座談會」,面對面了解導師經營方針與科任教學進度。
   • 學校官方網站「最新消息」與本官方 LINE 帳號即時發布各項重大校務通知。`,
      "啟用",
      new Date()
    ],
    [
      "交通校園導覽",
      "校園運動場地假日開放時間、體育館/會議室場地借用流程(社區訪客專用)",
      `1. 【戶外操場開放時間】:
   • 上課日:上午 06:00 ~ 07:00、放學後 17:30 ~ 19:00(學生在校上課時間不對外開放)。
   • 國定例假日與寒暑假:06:00 ~ 18:00 免費開放社區民眾運動休閒。
   • 進入校園請保持環境整潔,全面禁菸、禁嚼檳榔、禁止遛狗及各類動力車輛進入 PU 跑道。
2. 【室內活動中心/體育館/會議室租借】:
   • 依公有學校場地使用管理辦法收費標準計收水電費及維護保證金。
   • 申請程序:請於使用前 14 天向總務處提出書面申請表,審核通過繳費後始得使用。
   💡 場地租借法規與檔期查詢請洽總務處事務組(${CONFIG.CONTACT_PHONE})。`,
      "啟用",
      new Date()
    ],
    [
      "處室窗口分機",
      "全校各處室核心業務一覽、分機號碼與緊急校安電話",
      `1. 【總機電話】:${CONFIG.CONTACT_PHONE}
2. 【各處室窗口業務分機】:
   • 【教務處】:課程編排、段考競試(教學組 分機 111);新生報到、轉學學籍、成績證明、減免補助(註冊組 分機 112);圖書借閱、教科書(設備組 分機 113);校園網路、線上帳號(資訊組 分機 114)。
   • 【學務處】:生活常規、請假獎懲(生教組 分機 121);社團活動、畢業旅行(訓育組 分機 122);營養午餐、健康中心(衛生組 分機 123);體育競賽、校隊培訓(體育組 分機 124)。
   • 【總務處】:學雜費繳納(出納組 分機 131);場地租借、校舍維護(事務組 分機 132)。
   • 【輔導室】:個別心理諮商、親職教育(輔導組 分機 141);身心障礙特殊教育、資優鑑輔(特教組 分機 142)。
3. 【24小時校安緊急專線】:生輔組校安值勤專線(重大突發事件通報專用)。`,
      "啟用",
      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。

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>光明國民中學 校園迎新與導覽小幫手 - 知識庫與諮詢管理後台</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-graduation-cap"></i>
        </div>
        <div>
          <h1 class="text-lg font-bold">光明國民中學 校園迎新與導覽小幫手</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>
        </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.includes(cat) || (cat === 'ALL' && btn.innerText === '全部') || (btn.getAttribute('onclick') && 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 font-medium';
        }
      });
      filterCards();
    }

    function filterCards() {
      const kw = document.getElementById('searchInput').value.trim().toLowerCase();
      const grid = document.getElementById('cardGrid');
      const empty = document.getElementById('emptyState');
      grid.innerHTML = '';

      const filtered = 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 (filtered.length === 0) {
        empty.classList.remove('hidden');
        return;
      }
      empty.classList.add('hidden');

      filtered.forEach(item => {
        const card = document.createElement('div');
        card.className = "bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex flex-col justify-between hover:border-teal-400 transition-all";
        card.innerHTML = `
          <div class="space-y-3">
            <span class="inline-block px-2.5 py-1 rounded-md text-xs font-semibold bg-teal-50 text-teal-700 border border-teal-200">
              ${escapeHtml(item.category)}
            </span>
            <h3 class="font-bold text-slate-900 text-base leading-snug">${escapeHtml(item.title)}</h3>
            <div class="text-xs text-slate-600 whitespace-pre-line leading-relaxed code-scroll max-h-48 overflow-y-auto bg-slate-50 p-3 rounded-xl border border-slate-100">
              ${escapeHtml(item.content)}
            </div>
          </div>
          <div class="pt-4 mt-3 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-400">
            <span><i class="fa-solid fa-check text-emerald-500 mr-1"></i>試算表即時連動</span>
            <span class="text-teal-600 font-medium">Gemini RAG</span>
          </div>
        `;
        grid.appendChild(card);
      });
    }

    function escapeHtml(str) {
      if (!str) return '';
      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
    }
  </script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

部署成 Web App 後,就會拿到可分享網址。

  1. Apps Script 右上:部署 → 新部署
  2. 類型選:Web app
  3. 執行身分:你
  4. 存取權限:任何人
  5. 完成後複製網址

💬 評論區

老師們的交流與提問

載入評論中...