iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

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

【Day 13】- 入門 JavaScript 網頁架設:互動引導與歷史回顧切換

  • 分享至 

  • xImage
  •  

摘要
Day 12 我們完成了「進場畫面」,讓使用者先感受到共鳴,再進入表單。
經過 Day 4-12 ,你已經完整實現 Day 4 提到的最小可行產品(MVP)!以下會放加了 Day 13 功能的 MVP 完整程式碼。
今天要再進一步:

  1. 表單送出後才出現「精神引導選擇」,使用者能得到不同的情感回饋,銜接隔天的精神狀態小測驗。
  2. 新增「歷史回顧」按鈕,點擊後才切換到歷史區塊,避免初次進場畫面過於複雜。

為什麼要加互動引導與歷史回顧切換?

  1. 精神引導

    • 動機時機:等使用者真的輸入並送出紀錄,再給予「當然!」或「下次吧…」的選擇,能讓互動更自然。
    • 程式學習:練習表單 submit 事件、preventDefault()、條件邏輯控制顯示時機。
  2. 歷史回顧獨立切換

    • 降低干擾:若初始畫面同時顯示表單+歷史,會讓新手使用者分心。
    • 專注分區:表單負責輸入、歷史負責回顧,單一頁面只專心一件事。
    • 程式學習:延續 Day 12 的「顯示/隱藏」,多練一次狀態切換與 renderHistory() 的呼叫。

學習重點

  • Submit 事件處理:監聽表單提交,決定何時顯示精神引導。
  • 條件邏輯:不同選擇 → 不同回饋文案。
  • DOM 切換延伸:除了 intro ↔ form,現在再多一個 form ↔ history。
  • 使用者體驗設計:把「紀錄」與「回顧」分開,操作更直觀。

為何要用 preventDefault()?

  • 預設的 <form> submit 會整頁重新整理(或導向 action),導致 UI 狀態與 JS 記憶(如暫存、焦點)被清空。
  • 在本專案中,我們以 JS 處理新增紀錄與顯示回饋,因此需要攔截預設行為以避免刷新。

hidden vs display:none 差異:

  • element.hidden 是 HTML 標準屬性,瀏覽器預設以 CSS 規則 [hidden]{display:none} 套用(可讀性高、語意清晰)。
  • display:none 是純樣式層級控制(需自行維護 class 或 style)。
  • 實務:本專案以 hidden 切換主/副區塊,更直覺;若要做動畫,可改以 classList + CSS 切換 display/opacity/visibility。

核心流程

  1. 填寫並 submit → 顯示精神引導選擇(Day 13 新增)
  2. 使用者選擇 → 顯示不同訊息
  3. 按「歷史回顧」→ 隱藏表單、顯示歷史清單(Day 13 新增)

實作

今日的程式碼為前幾日與今日的完整功能版,讓你檢查前幾日的程式碼是否有誤,因此篇幅較長,有標註今日的新增與調整部分。
HTML:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Procrastinator MVP</title>
</head>
<body>
  <!-- Day 12:進場畫面(維持) -->
  <section id="resonanceScreen" aria-labelledby="res-title" role="dialog" aria-modal="true">
    <div>
      <h1 id="res-title">你是不是也常常…</h1>
      <ul>
        <li>看了一堆自我提升影片,結果還是沒開始?</li>
        <li>打開 IG/小紅書 想找靈感,結果一個多小時過去?</li>
        <li>明明有想做的事,卻又莫名其妙滑到半夜?</li>
      </ul>
      <p><strong>想想看,假日想做但沒做的事通常是什麼?</strong></p>
      <div><button id="btnStart">一起看看今天怎麼了 →</button></div>
    </div>
  </section>

  <!-- 表單頁(主工作區) -->
  <section id="recordFormSection" hidden>
    <h1>I want to...</h1>
    <div>
      <input type="text" id="taskInput" placeholder="Enter your task..." aria-label="任務名稱"/>
      <select id="reasonSelect" aria-label="拖延原因">
        <option value="tired">太累 (Tired)</option>
        <option value="mood">沒心情 (Not in the mood)</option>
        <option value="distractions">太多干擾 (Too many distractions)</option>
        <option value="difficult">太難 (Too difficult)</option>
        <option value="dontKnow">不知道怎麼開始 (Don't know how to start)</option>
        <option value="other">其他 (Other)</option>
      </select>

      <!-- [Day13-CHANGED] 明確指定 type="button",避免成為預設 submit 造成整頁刷新 -->
      <button id="submitBtn" type="button">Submit</button>
    </div>

    <p id="quote" aria-live="polite"></p>

    <!-- [Day13-NEW] Submit 後才出現的精神引導選擇 -->
    <div id="motivationSection" hidden>
      <p>要不要幫自己留一點能量,在最清醒的時候做想做的事?</p>
      <div>
        <button id="btnYes">當然!</button>
        <button id="btnLater">下次吧…</button>
      </div>
      <p id="feedbackMsg" aria-live="polite"></p>
    </div>

    <!-- [Day13-NEW] 以按鈕觸發 DOM 切換到歷史回顧區塊 -->
    <div style="margin-top:1rem;">
      <button id="btnHistory" aria-controls="historySection">歷史回顧 →</button>
    </div>
  </section>

  <!-- [Day13-NEW] 歷史回顧頁(獨立區塊,按下按鈕才顯示) -->
  <section id="historySection" hidden aria-labelledby="history-title">
    <h1 id="history-title" tabindex="-1">歷史回顧</h1>

    <!-- 排序與篩選(搬入歷史頁,避免表單頁太雜) -->
    <div>
      <label for="sortSelect">排序:</label>
      <select id="sortSelect">
        <option value="time_desc">依建立時間(新→舊)</option>
        <option value="time_asc">依建立時間(舊→新)</option>
        <option value="reason">依原因</option>
        <option value="task">依任務字母/注音順序</option>
      </select>

      <label for="filterSelect">篩選:</label>
      <select id="filterSelect">
        <option value="all">全部</option>
        <option value="tired">只看「太累」</option>
        <option value="mood">只看「沒心情」</option>
        <option value="distractions">只看「太多干擾」</option>
        <option value="difficult">只看「太難」</option>
        <option value="dontKnow">只看「不知道怎麼開始」</option>
        <option value="other">只看「其他」</option>
      </select>
    </div>

    <ul id="historyList" aria-live="polite"></ul>

    <div style="margin-top:0.5rem;">
      <button id="undoBtn" disabled>回復上一筆</button>
      <button id="clearBtn">清空紀錄</button>
    </div>

    <div style="margin-top:1rem;">
      <button id="btnBack" aria-controls="recordFormSection">← 回到表單</button>
    </div>
  </section>

  <script src="script.js" defer></script>
</body>
</html>

JavaScript:

// ===== 常數與偏好 =====
const STORAGE_KEY = 'procrastinator_records';
const LEGACY_KEY  = 'procrastinationData';
const SORT_PREF_KEY   = 'procrastinator_sort_pref';
const FILTER_PREF_KEY = 'procrastinator_filter_pref';
let currentSort   = localStorage.getItem(SORT_PREF_KEY)   || 'time_desc';
let currentFilter = localStorage.getItem(FILTER_PREF_KEY) || 'all';

// ===== 進場畫面(Day 12:維持原邏輯) =====
const introEl        = document.getElementById('resonanceScreen');
const startBtn       = document.getElementById('btnStart');
const formSection    = document.getElementById('recordFormSection');
const historySection = document.getElementById('historySection');

function focusFirstField() {
  document.querySelector('#taskInput')?.focus?.();
}
function showIntro() {
  introEl.hidden = false;
  formSection.hidden = true;
  historySection.hidden = true;
}
function hideIntroShowForm() {
  introEl.hidden = true;
  formSection.hidden = false;
  historySection.hidden = true;
  focusFirstField();
}
startBtn?.addEventListener('click', hideIntroShowForm);

// ===== 資料存取(維持) =====
function safeParseArray(raw) {
  try {
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    const arr = Array.isArray(parsed) ? parsed : parsed?.records;
    return Array.isArray(arr) ? arr : [];
  } catch {
    localStorage.removeItem(STORAGE_KEY);
    return [];
  }
}
function readRecords() { return safeParseArray(localStorage.getItem(STORAGE_KEY)); }
function writeRecords(list) { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); }
function addRecord(record) { const list = readRecords(); list.push(record); writeRecords(list); }

// Day4 → 陣列遷移(一次性)
(function migrateLegacyKey(){
  const raw = localStorage.getItem(LEGACY_KEY);
  if (!raw) return;
  try {
    const obj = JSON.parse(raw);
    if (obj && typeof obj === 'object') {
      const list = readRecords();
      list.push({ id:String(Date.now()), task:obj.task??'', reason:obj.reason??'', reasonCode:'legacy', quote:obj.quote??'', createdAt:Date.now() });
      writeRecords(list);
    }
  } catch {}
  localStorage.removeItem(LEGACY_KEY);
})();

// ===== 語錄 & 表單(維持) =====
function getQuoteByReason(code){
  const quotes = {
    tired:['先補點能量也算前進。','睡一覺,回來再戰。'],
    mood:['先做 3 分鐘,心情會跟上。','心情像雲,會散開的。'],
    distractions:['關掉一個通知,就是一步。','把手機放遠一點點就好。'],
    difficult:['把任務切成最小一口。','先完成最簡單的 1%。'],
    dontKnow:['寫下第一步就夠了。','先鋪一張桌子,事就開始。'],
    other:['你願意回顧,已在改變。','行動勝於完美。']
  };
  const list = quotes[code] || quotes.other;
  return list[Math.floor(Math.random()*list.length)];
}
function getFormValues(){
  const task = document.getElementById('taskInput').value.trim();
  const reasonSelect = document.getElementById('reasonSelect');
  const reasonCode = reasonSelect.value;
  const reasonText = reasonSelect.options[reasonSelect.selectedIndex].text;
  if (!task) throw new Error('EMPTY_TASK');
  return { task, reasonCode, reasonText };
}
function showReasonQuote(reasonText, quote){
  document.getElementById('quote').textContent = `原來你因為「${reasonText}」。👉 ${quote}`;
}

// ===== [Day13-NEW] Submit 後才顯示精神引導區 =====
const submitBtn    = document.getElementById('submitBtn'); // type="button":避免預設 submit
const motivationEl = document.getElementById('motivationSection');
const feedback     = document.getElementById('feedbackMsg');
const btnYes       = document.getElementById('btnYes');
const btnLater     = document.getElementById('btnLater');

function onSubmit(e){
  // [Day13-NEW] 若之後改成 <form> 監聽 submit,這行可避免整頁刷新:
  // e?.preventDefault?.();

  try {
    const { task, reasonCode, reasonText } = getFormValues();
    const quote = getQuoteByReason(reasonCode);

    addRecord({ id:String(Date.now()), task, reason:reasonText, reasonCode, quote, createdAt:Date.now() });
    showReasonQuote(reasonText, quote);

    motivationEl.hidden = false; // [Day13-NEW] 顯示精神引導選擇
    btnYes?.focus?.();           // a11y:引導焦點到第一個互動元件
  } catch (err) {
    if (err.message === 'EMPTY_TASK') {
      alert('先寫下你想做的事喔!');
    } else {
      alert('發生未預期的錯誤,請稍後再試。');
      console.error(err);
    }
  }
}
submitBtn?.addEventListener('click', onSubmit);

// [Day13-NEW] 精神引導分支
btnYes?.addEventListener('click',   ()=>{ feedback.innerText='太好了!你今天踏出了不一樣的一步 💪'; /* 可進階:localStorage.setItem('motivationChoice','yes') */ });
btnLater?.addEventListener('click', ()=>{ feedback.innerText='沒關係,你隨時都能回來開始。我會一直在這等你 😊'; /* 可進階:localStorage.setItem('motivationChoice','later') */ });

// ===== [Day13-NEW] 歷史回顧(按鈕才切換 DOM 顯示) =====
const btnHistory   = document.getElementById('btnHistory');
const btnBack      = document.getElementById('btnBack');
const sortSelect   = document.getElementById('sortSelect');
const filterSelect = document.getElementById('filterSelect');
const historyList  = document.getElementById('historyList');
const undoBtn      = document.getElementById('undoBtn');
const clearBtn     = document.getElementById('clearBtn');

function showPage(page){
  if (page === 'history') {
    formSection.hidden = true;      // [Day13-NEW]
    historySection.hidden = false;  // [Day13-NEW]
    renderHistory();                // 只在需要時渲染(效能 & 清晰)
    document.getElementById('history-title')?.focus?.(); // a11y:告知區塊已切換
  } else {
    historySection.hidden = true;
    formSection.hidden = false;
    focusFirstField();
  }
}
btnHistory?.addEventListener('click', ()=>showPage('history')); // [Day13-NEW]
btnBack?.addEventListener('click',    ()=>showPage('home'));    // [Day13-NEW]

// 排序/篩選 + 渲染(維持)
function applySortFilter(list){
  let arr = Array.isArray(list) ? list.slice() : [];
  const f = (filterSelect?.value ?? currentFilter);
  if (f && f !== 'all') arr = arr.filter(r => r.reasonCode === f);
  const s = (sortSelect?.value ?? currentSort);
  switch (s) {
    case 'time_asc': arr.sort((a,b)=>a.createdAt-b.createdAt); break;
    case 'reason':   arr.sort((a,b)=>(a.reason||'').localeCompare(b.reason||'')); break;
    case 'task':     arr.sort((a,b)=>(a.task||'').localeCompare(b.task||'')); break;
    case 'time_desc':
    default:         arr.sort((a,b)=>b.createdAt-a.createdAt); break;
  }
  return arr;
}
function renderHistory(){
  const records = applySortFilter(readRecords());
  historyList.innerHTML = records.length
    ? records.map(r=>`
        <li>
          [${new Date(r.createdAt).toLocaleString()}] ${r.task} — ${r.reason} |「${r.quote}」
          <button class="btn-delete" data-id="${r.id}">刪除</button>
        </li>
      `).join('')
    : '<li class="muted">尚無紀錄</li>';
  undoBtn.disabled = deletedStack.length === 0;
}

// 刪除 / Undo(維持)
let deletedStack = [];
historyList.addEventListener('click',(e)=>{
  const btn = e.target.closest('button.btn-delete');
  if (!btn) return;
  const id = btn.dataset.id;

  const list = readRecords();
  const idx = list.findIndex(r=>r.id===id);
  if (idx === -1) { alert('找不到這筆紀錄,可能已在其他分頁修改。'); return renderHistory(); }

  deletedStack.push(list[idx]);
  writeRecords(list.filter(r=>r.id!==id));
  renderHistory();
});
undoBtn.addEventListener('click',()=>{
  if (!deletedStack.length) return;
  const peek = deletedStack[deletedStack.length-1];
  if (!window.confirm(`要回復上一筆刪除嗎?(${peek.task} — ${peek.reason})`)) return;
  const rec = deletedStack.pop();
  const list = readRecords(); list.push(rec); writeRecords(list);
  renderHistory();
});

// 清空(維持)
clearBtn.addEventListener('click',()=>{
  if (!window.confirm('確定要清空所有紀錄嗎?清空後無法回復')) return;
  localStorage.removeItem(STORAGE_KEY);
  renderHistory();
});

// 偏好同步(維持)
sortSelect?.addEventListener('change',e=>{
  currentSort = e.target.value || 'time_desc';
  localStorage.setItem(SORT_PREF_KEY, currentSort);
  renderHistory();
});
filterSelect?.addEventListener('change',e=>{
  currentFilter = e.target.value || 'all';
  localStorage.setItem(FILTER_PREF_KEY, currentFilter);
  renderHistory();
});
window.addEventListener('storage',e=>{
  if (e.key === STORAGE_KEY) renderHistory();
  if (e.key === SORT_PREF_KEY) {
    currentSort = localStorage.getItem(SORT_PREF_KEY) || 'time_desc';
    if (sortSelect) sortSelect.value = currentSort;
    renderHistory();
  }
  if (e.key === FILTER_PREF_KEY) {
    currentFilter = localStorage.getItem(FILTER_PREF_KEY) || 'all';
    if (filterSelect) filterSelect.value = currentFilter;
    renderHistory();
  }
});

// ===== 初次載入(維持) =====
(function init(){
  showIntro();
  if (sortSelect)   sortSelect.value = currentSort;
  if (filterSelect) filterSelect.value = currentFilter;
})();

驗證

  1. Submit 後引導:輸入任務+原因 → 按 Submit → 顯示精神引導(兩顆按鈕)並把焦點移到「當然!」。

  2. 選擇分支:

    • 點「當然!」 → 顯示:太好了!你今天踏出了不一樣的一步 💪
    • 點「下次吧…」 → 顯示:沒關係,你隨時都能回來開始。我會一直在這等你 😊
  3. 歷史切換:點「歷史回顧 →」→ 表單隱藏、歷史頁顯示並渲染清單;點「← 回到表單」返回。

  4. 排序/篩選:歷史頁變更下拉 → 立刻重渲染,偏好寫入 localStorage。

  5. Undo/刪除/清空:與 Day 7–11 相容,動作後清單會即時刷新。

常見錯誤 & 排查

  • 點 Submit 沒有顯示精神引導 → 檢查 submitBtn 綁定與 onSubmit() 是否有呼叫、是否有 preventDefault()(若改用 <form> 監聽)。
  • 歷史與表單同時可見 → 檢查 showPage() 是否正確設定三個區塊的 hidden 狀態。
  • 排序/篩選不生效 → 確認 currentSort/currentFilter 與下拉選單值同步、renderHistory() 是否被呼叫。

上一篇
【Day 12】入門 JavaScript 網頁架設:進場畫面
下一篇
【Day 14】- 入門 JavaScript 網頁架設:精神狀態小測驗(radio button 單選)
系列文
Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言