iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Modern Web

Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延系列 第 25

【Day 25】— 入門 JavaScript 網頁架設:AI 語錄清單

  • 分享至 

  • xImage
  •  

摘要
承接 Day 24 的即時生成,今天把 Gemini 回覆存進本機,做出「語錄清單」:支援收藏、排序、搜尋、刪除、複製。
透過 localStorage 陣列與清單渲染,讓暖心語錄不只即時出現,更能持續回顧與整理。

為什麼要做「語錄清單」?

  • 留存動能:好的句子值得被保存,低潮時翻閱能迅速回溫。
  • 資料練功:練習陣列新增/刪除、排序/搜尋、渲染與事件委派。
  • 體驗升級:AI 不只「當下」回應,還能形成你的專屬語錄庫。

學習重點

  • localStorage 陣列處理:read/write/add 與安全解析。
  • 清單渲染:以條件(搜尋、排序)過濾後再渲染。
  • 互動操作:收藏、刪除、複製到剪貼簿。

核心流程

  1. 資料模型(語錄清單)
QuoteItem = { id, text, task?, reason?, source: 'gemini'|'manual', createdAt }
  1. 收藏時機
    使用者按「下次吧…」或「我有點累…」→ 取得 genQuote() 的文字 → 自動加入語錄清單。
  2. 呈現
    歷史頁新增「語錄清單」子區塊:搜尋框 + 排序下拉 + 列表(條列文字+「複製」與「刪除」)。

實作

假設已具備:

  • Day 4–5 → readRecords / writeRecords / addRecord(紀錄存取基礎)
  • Day 12 → showPage()(頁面切換骨架)
  • Day 13 → 精神引導區(#btnLater、#feedbackMsg)、避免預設 submit 刷新
  • Day 20–21 → 歷史頁清單與統計圖表(渲染流程已建立)
  • Day 22 → 匯出工具(triggerDownload 等)
  • Day 23 → postJSONWithTimeout、ngrok 後端(已有 fetch 範式)
  • Day 24 → genQuote(payload)(Gemini 代理呼叫,會在本日整合收藏)

註:若你的命名略有不同,對應到相同職責即可。

  1. HTML:在歷史頁新增「語錄清單」區塊
    放在 #historySection 內、圖表區塊之後、任務回顧之前或之後皆可(以下示例插在任務回顧前)。
    只貼新增片段。
<!-- [Day25-NEW] 語錄清單 -->
<section id="quoteWallSection" aria-labelledby="quote-wall-title" style="margin-top:1rem;">
  <h3 id="quote-wall-title">AI 語錄清單</h3>

  <div style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
    <label for="quoteSearchInput">搜尋:</label>
    <input id="quoteSearchInput" type="search" placeholder="輸入關鍵字(語錄 / 任務 / 原因)" style="min-width:240px;" />

    <label for="quoteSortSelect">排序:</label>
    <select id="quoteSortSelect">
      <option value="time_desc">依新增時間(新→舊)</option>
      <option value="time_asc">依新增時間(舊→新)</option>
      <option value="alpha_asc">依語錄字母/筆畫(A→Z)</option>
      <option value="alpha_desc">依語錄字母/筆畫(Z→A)</option>
    </select>

    <!-- 可選:匯出語錄清單 -->
    <button id="btnExportQuoteJSON" type="button">匯出語錄(JSON)</button>
  </div>

  <ul id="quoteList" aria-live="polite" style="margin-top:.5rem;"></ul>
  <p id="quoteWallFeedback" class="muted" aria-live="polite"></p>
</section>
  1. JS:語錄清單的資料處理 + 渲染 + 事件
    以下片段請加入你的 script.js(放在 Day 22/23 之後較清楚)。
// ===== [Day25-NEW] 語錄清單:常數、節點 =====
const QUOTE_WALL_KEY = 'quote_wall'; // Array<QuoteItem>
const quoteList = document.getElementById('quoteList');
const quoteSearchInput = document.getElementById('quoteSearchInput');
const quoteSortSelect = document.getElementById('quoteSortSelect');
const quoteWallFeedback = document.getElementById('quoteWallFeedback');
const btnExportQuoteJSON = document.getElementById('btnExportQuoteJSON');

// ===== [Day25-NEW] 語錄清單:存取工具 =====
function readQuoteWall() {
  try { return JSON.parse(localStorage.getItem(QUOTE_WALL_KEY)) || []; }
  catch { return []; }
}
function writeQuoteWall(list) {
  localStorage.setItem(QUOTE_WALL_KEY, JSON.stringify(Array.isArray(list) ? list : []));
}
function addQuoteItem({ text, task = '', reason = '', source = 'gemini' }) {
  const list = readQuoteWall();
  list.push({
    id: crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()),
    text: String(text || '').trim(),
    task: String(task || ''),
    reason: String(reason || ''),
    source,
    createdAt: Date.now()
  });
  writeQuoteWall(list);
  return list[list.length - 1];
}

// ===== [Day25-NEW] 語錄清單:篩選 + 排序 =====
function applyQuoteFilterSort(list) {
  let arr = Array.isArray(list) ? list.slice() : [];
  const q = (quoteSearchInput?.value || '').trim().toLowerCase();

  if (q) {
    arr = arr.filter(item => {
      const hay = [item.text, item.task, item.reason].join(' ').toLowerCase();
      return hay.includes(q);
    });
  }

  const sort = quoteSortSelect?.value || 'time_desc';
  switch (sort) {
    case 'time_asc':
      arr.sort((a,b)=> a.createdAt - b.createdAt);
      break;
    case 'alpha_asc':
      arr.sort((a,b)=> (a.text||'').localeCompare(b.text||''));
      break;
    case 'alpha_desc':
      arr.sort((a,b)=> (b.text||'').localeCompare(a.text||''));
      break;
    case 'time_desc':
    default:
      arr.sort((a,b)=> b.createdAt - a.createdAt);
      break;
  }
  return arr;
}

// ===== [Day25-NEW] 語錄清單:渲染 =====
function renderQuoteWall() {
  const raw = readQuoteWall();
  const list = applyQuoteFilterSort(raw);

  if (!quoteList) return;
  if (!list.length) {
    quoteList.innerHTML = '<li class="muted">尚無收藏語錄。先在表單按「下次吧…」或條件分支按「我有點累…」,AI 會回一句話並自動收藏。</li>';
    return;
  }

  quoteList.innerHTML = list.map(item => `
    <li data-id="${item.id}">
      <div>
        「${escapeHTML(item.text)}」
        <small class="muted">(${new Date(item.createdAt).toLocaleString()}|來源:${item.source})</small>
      </div>
      <div class="muted">
        ${item.task ? `任務:${escapeHTML(item.task)} ` : ''}
        ${item.reason ? `原因:${escapeHTML(item.reason)} ` : ''}
      </div>
      <div style="margin-top:.25rem;">
        <button class="btn-copy"   type="button">複製</button>
        <button class="btn-remove" type="button">刪除</button>
      </div>
    </li>
  `).join('');
}

// 小工具:安全轉義(避免語錄裡出現引號/符號時破版)
function escapeHTML(s='') {
  return String(s)
    .replaceAll('&','&amp;')
    .replaceAll('<','&lt;')
    .replaceAll('>','&gt;')
    .replaceAll('"','&quot;')
    .replaceAll("'",'&#39;');
}

// ===== [Day25-NEW] 語錄清單:事件委派(複製 / 刪除) =====
quoteList?.addEventListener('click', async (e) => {
  const li = e.target.closest('li[data-id]');
  if (!li) return;
  const id = li.getAttribute('data-id');
  const list = readQuoteWall();
  const item = list.find(x => x.id === id);
  if (!item) return;

  if (e.target.closest('.btn-copy')) {
    try {
      await navigator.clipboard.writeText(item.text);
      if (quoteWallFeedback) quoteWallFeedback.textContent = '已複製到剪貼簿 ✅';
      setTimeout(()=> quoteWallFeedback && (quoteWallFeedback.textContent = ''), 1500);
    } catch {
      prompt('複製失敗,手動複製以下內容:', item.text);
    }
    return;
  }

  if (e.target.closest('.btn-remove')) {
    if (!confirm('確定要刪除這句語錄嗎?')) return;
    const kept = list.filter(x => x.id !== id);
    writeQuoteWall(kept);
    renderQuoteWall();
  }
});

// ===== [Day25-NEW] 語錄清單:搜尋 / 排序器 =====
quoteSearchInput?.addEventListener('input', () => renderQuoteWall());
quoteSortSelect?.addEventListener('change', () => renderQuoteWall());

// ===== [Day25-NEW] 語錄匯出(可選) =====
btnExportQuoteJSON?.addEventListener('click', () => {
  const list = readQuoteWall();
  if (!list.length) { 
    quoteWallFeedback.textContent = '目前沒有可匯出的語錄。';
    return;
  }
  const pretty = JSON.stringify(list, null, 2);
  triggerDownload('quote-wall.json', 'application/json;charset=utf-8', pretty);
  quoteWallFeedback.textContent = `已匯出 ${list.length} 則語錄(JSON)。`;
  setTimeout(()=> quoteWallFeedback && (quoteWallFeedback.textContent = ''), 1500);
});
  1. JS:把 Day 24 的 Gemini 回覆 自動加入語錄清單
    直接在你既有的 btnLater / btnLater_2 事件中 補 1 行 addQuoteItem(...)。
    下列片段僅示範修改的行;其餘保留你的原始碼。
// [Day13-CHANGED][Day25-NEW] 「下次吧…」:生成後自動收藏
btnLater?.addEventListener('click', async ()=>{ 
  const { task, reason } = getCurrentTaskAndReason();
  feedback.textContent = '想一想…(AI 正在回覆)';
  try {
    const line = await genQuote({ task, reason, tone: 'gentle' });
    feedback.textContent = line || '先緩一下也很好,等精神回來再開始。';

    // [Day25-NEW] 自動收藏到語錄清單
    if (line) addQuoteItem({ text: line, task, reason, source: 'gemini' });
  } catch (err) {
    feedback.textContent = '沒關係,你隨時都能回來開始。我會一直在這等你 😊';
  }
});

// [Day15-CHANGED][Day25-NEW] 「我有點累…」:生成後自動收藏
btnLater_2?.addEventListener('click', async () => {
  const { task, reason } = getCurrentTaskAndReason();
  decisionFeedback.textContent = '想一想…(AI 正在回覆)';
  try {
    const line = await genQuote({ task, reason, tone: 'reassuring' });
    decisionFeedback.textContent = line || '你已經很努力了,補個眠就是前進。';

    // [Day25-NEW] 自動收藏
    if (line) addQuoteItem({ text: line, task, reason, source: 'gemini' });

  } catch {
    decisionFeedback.textContent = '別擔心,你隨時都能回來,我一直都在 ✨';
  }
});
  1. JS:在切到歷史頁時,一併渲染語錄清單
    你原本 showPage('history') 已會呼叫圖表與 renderHistory();在同一處加上 renderQuoteWall()。
// [Day25-CHANGED] showPage('history') 增補:語錄清單渲染
if (page === 'history') {
  if (historySection) {
    historySection.hidden = false;

    renderMoodChartsAll().finally(() => {
      if (typeof renderHistory === 'function') renderHistory();
      // [Day25-NEW] 語錄清單也要一起渲染
      if (typeof renderQuoteWall === 'function') renderQuoteWall();
    });

    focusOrFallback(historySection, 'history-title');
  }
}

驗證

  1. 表單輸入任務與原因 → 按「下次吧…」或在條件分支按「我有點累…」
  2. 頁面出現暖心語錄
  3. 切到「歷史回顧」→ 語錄清單應出現剛剛的句子
  4. 在語錄清單搜尋框輸入關鍵字(任務/原因/語錄) → 列表即時過濾
  5. 切換排序(時間新→舊 / 舊→新 / 字母 A→Z / Z→A) → 順序改變
  6. 按「複製」→ 該句成功複製(或出現手動複製的備援)
  7. 按「刪除」→ 該句從語錄清單移除
  8. 按「匯出語錄(JSON)」→ 下載語錄檔案,格式正確

常見錯誤 & 排查

  1. 語錄清單沒有顯示新句子
    是否有在 btnLater / btnLater_2 的 try 區塊內呼叫 addQuoteItem(...)?
  2. genQuote() 失敗時不會收藏
    先檢查 Day 24 的後端代理與 ngrok 是否正常。
  3. 搜尋無反應
    事件是否綁在 quoteSearchInput 的 input?請確認 ID 與綁定程式一致。
  4. 排序不改變
    quoteSortSelect 的值是否與 applyQuoteFilterSort() switch-case 對應?
  5. 複製失敗
    某些瀏覽器/環境限制剪貼簿 API;已內建 prompt() 備援。
  6. JSON 壞檔或語法錯誤
    任一存取解析失敗都會回傳 [],可先在 Console 檢查 readQuoteWall() 的回傳。

安全與實務提醒

  • 語錄清單儲存在瀏覽器本機(localStorage),清除瀏覽器資料會一併移除。
  • 若想跨裝置同步,可延續 Day 23 的後端備份做「語錄清單備份」API(結構類似 /backup)。
  • 若語錄清單可能含個資或敏感資訊,需加入隱私提醒與匯出前遮罩(選擇性)。

上一篇
【Day 24】— 入門 JavaScript 網頁架設:串接 Gemini 生成語錄
系列文
Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言