iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Modern Web

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

【Day 29】— 入門 JavaScript 網頁架設:多裝置同步

  • 分享至 

  • xImage
  •  

摘要
讓你的資料「帶得走」。今天我們做一個最小可用的雲端同步:前端維持離線可用(localStorage),需要時一鍵「備份到雲端」、另一台裝置一鍵「從雲端載入」。不做複雜帳號,先用固定 clientId + API key;同步策略採最後寫入覆蓋(LWW),並處理逾時、重試、斷線提示。UI 會清楚顯示「上次同步時間/筆數」。
註:Day 29 只專於實現「任務清單」同步。

為什麼要做「最小可用的同步」?

  • 跨裝置可用:資料不鎖在一台電腦,手機/平板可快速接續。
  • 離線友好:依舊以 localStorage 為核心,斷網照用,回線再同步。
  • 理解成本低:先用 clientId + API key,可預留將來接第三方雲或登入系統。
  • 可觀測:UI 告訴你「上次同步:今天 14:32/37 筆」,心裡有底。

實作

假設已具備:

  • Day 4–5:本機資料存取(localStorage)

    • readRecords()、writeRecords(list)、addRecord(record)、safeParseArray(raw)
    • 供 Day 29 備份/拉回時讀寫本機資料
  • Day 12–19:頁面骨架與導覽

    • showIntro()、hideIntroShowForm()、showPage(page)
    • getVisibleSection()、switchSectionWithAnimation(fromEl, toEl, cb)
    • 回訪體驗:hasVisitedBefore()、markVisited() 與二訪分支 UI 事件
  • Day 14–15:精神小測驗與提問頁

    • saveMood(period, value)、loadMood()、showAggregateFeedback(moodData)
    • makeSnapshotFromCurrentMood()、appendMoodSnapshot(snapshot)
  • Day 16–18:任務安排(含拖曳)與溫暖總結

    • renderTaskCard()、placeTaskTo(period)、renderArrangementSummary()
    • 輔助:readScheduleMap()、writeScheduleMap(map)、getLatestRecord()、getTaskTitleById(id)
  • Day 20–21:圖表與統計

    • ensureChartJS()、renderMoodStats()、renderMoodPie(countMap)、renderMoodTrend(points)
    • 一鍵渲染:renderMoodChartsAll()(Day 29 拉回後若需要重繪)
  • Day 22:匯出

    • triggerDownload(filename, mime, data)、buildCSV(records)(JSON/CSV 下載)
  • Day 23:雲端備份基礎

    • postJSONWithTimeout(url, payload, timeoutMs)(舊版可保留;Day 29 主要改用泛用 requestJSONWithTimeout)
  • Day 24:暖心語錄(後端代理)

    • 前端:genQuote(payload, timeoutMs)、getCurrentTaskAndReason()
    • 後端:generateGentleQuote({ task, reason, tone })、safeOneLine(text, max)、POST /gen-quote
  • Day 25:語錄牆

    • readQuoteWall()、writeQuoteWall(list)、addQuoteItem({ text, task, reason, source })
    • applyQuoteFilterSort(list)、renderQuoteWall()、escapeHTML(s)
  • Day 26–27:全站樣式 / 主題切換

    • applyTheme({ mode, palette, save })、resolveMode(mode)
    • system/dark/light 與色盤事件綁定
  • Day 28:互動強化

    • attachRipple(btn)、enhanceMicroInteractions()(Day 29 會把新按鈕加入 ripple 名單)
  1. HTML:在歷史頁的雲端區塊擴充 UI
    把原本的 <div id="cloudBackupSection"> 改成以下(保留原有 id,方便沿用舊程式碼):
<!-- [Day29-CHANGED] 簡易雲端同步(clientId / API key) -->
<div id="cloudBackupSection" style="margin-top:.75rem;">
  <div class="toolbar" aria-label="雲端同步設定">
    <label for="clientIdInput">Client ID:</label>
    <input id="clientIdInput" type="text" placeholder="例如:my-laptop 或 phone-123" style="min-width:180px;" />
    <label for="apiKeyInput">API Key:</label>
    <input id="apiKeyInput" type="password" placeholder="(可留空)" style="min-width:180px;" />
    <span class="muted" aria-live="polite">僅供同步用途,請勿上傳敏感內容</span>
  </div>

  <div class="toolbar" style="margin-top:.25rem;">
    <button id="btnBackupNow" type="button">備份到雲端</button>
    <button id="btnPullNow"   type="button">從雲端載入</button>
    <button id="btnSyncNow"   type="button" title="先備份,再拉回最新">同步</button>
  </div>

  <p id="syncMeta" class="muted" aria-live="polite" style="margin:.25rem 0 0;">
    上次同步:—(尚無)
  </p>

  <p id="backupFeedback" class="muted" aria-live="polite" style="margin:.25rem 0 0;"></p>
  <p id="pullFeedback"   class="muted" aria-live="polite" style="margin:.15rem 0 0;"></p>
</div>

可選 CSS:現有 .muted 與 .toolbar 已足夠,無需另加樣式。

  1. 前端 script.js:常數、UI 節點、工具函式與事件
    2-1. 常數與節點(加在你的常數區)
// [Day29-NEW] 簡易雲端同步
const BACKUP_ENDPOINT = "/backup";
const GEN_QUOTE_ENDPOINT = "/gen-quote";
const PULL_ENDPOINT = "/pull-latest"; // 與後端一致
const CLIENT_ID_KEY = 'cloud_client_id';
const API_KEY_KEY   = 'cloud_api_key';      // 可留空,若伺服器未設 Key
const SYNC_META_KEY = 'cloud_sync_meta';    // { lastSyncedAt:number, lastCloudId:string, lastCount:number }

const clientIdInput = document.getElementById('clientIdInput');
const apiKeyInput   = document.getElementById('apiKeyInput');
const btnPullNow    = document.getElementById('btnPullNow');
const btnSyncNow    = document.getElementById('btnSyncNow');
const pullFeedback  = document.getElementById('pullFeedback');
const syncMetaEl    = document.getElementById('syncMeta');

2-2. 偏好存取與徽章顯示

// [Day29-NEW] 偏好:ClientId / API Key
function loadCloudPrefs() {
  return {
    clientId: localStorage.getItem(CLIENT_ID_KEY) || 'fixed-demo',
    apiKey:   localStorage.getItem(API_KEY_KEY) || ''
  };
}
function saveCloudPrefs({ clientId, apiKey }) {
  if (clientId != null) localStorage.setItem(CLIENT_ID_KEY, String(clientId));
  if (apiKey   != null) localStorage.setItem(API_KEY_KEY,   String(apiKey));
}

function readSyncMeta() {
  try { return JSON.parse(localStorage.getItem(SYNC_META_KEY)) || null; }
  catch { return null; }
}
function writeSyncMeta(meta) {
  localStorage.setItem(SYNC_META_KEY, JSON.stringify(meta || {}));
}
function updateSyncBadge(meta = readSyncMeta()) {
  if (!syncMetaEl) return;
  if (!meta) { syncMetaEl.textContent = '上次同步:—(尚無)'; return; }
  const t = meta.lastSyncedAt ? new Date(meta.lastSyncedAt).toLocaleString() : '—';
  const cnt = Number.isFinite(meta.lastCount) ? `${meta.lastCount} 筆` : '—';
  const id = meta.lastCloudId ? `#${meta.lastCloudId}` : '—';
  syncMetaEl.textContent = `上次同步:${t}(雲端 ${cnt},編號 ${id})`;
}

初始化/綁定(放在 init() 之後或內部尾端):

// [Day29-NEW] 初始填入 clientId / apiKey,並綁定即時保存
(function initCloudPrefs(){
  if (clientIdInput) clientIdInput.value = loadCloudPrefs().clientId;
  if (apiKeyInput)   apiKeyInput.value   = loadCloudPrefs().apiKey;
  clientIdInput?.addEventListener('input', () => saveCloudPrefs({ clientId: clientIdInput.value }));
  apiKeyInput?.addEventListener('input',   () => saveCloudPrefs({ apiKey:   apiKeyInput.value }));
  updateSyncBadge();
})();

2-3. 請求工具:逾時 + 退避重試(GET/POST 共用)

// [Day29-NEW] 汎用 JSON 請求(含逾時)
async function requestJSONWithTimeout(url, { method='GET', headers={}, body=null } = {}, timeoutMs=10000) {
  const ctrl = new AbortController();
  const tid = setTimeout(() => ctrl.abort('TIMEOUT'), timeoutMs);
  try {
    const res = await fetch(url, {
      method,
      headers: { 'Content-Type': 'application/json', ...headers },
      body: body ? JSON.stringify(body) : null,
      signal: ctrl.signal
    });
    const data = await res.json().catch(()=> ({}));
    if (!res.ok) {
      const msg = data?.message || `HTTP ${res.status}`;
      throw new Error(msg);
    }
    return data;
  } finally {
    clearTimeout(tid);
  }
}

// [Day29-NEW] 退避重試(最多 2 次)
async function withRetry(fn, { retries=2, baseDelay=400 }={}) {
  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (e) {
      if (attempt >= retries || e?.name === 'AbortError') throw e;
      const wait = baseDelay * Math.pow(2, attempt); // 400, 800, ...
      await new Promise(r => setTimeout(r, wait));
      attempt++;
    }
  }
}

你原本的 postJSONWithTimeout 可留作舊功能使用;Day 29 起新流程改用上面的泛用版。

2-4. 上傳(備份)/下載(拉回)

// [Day29-NEW] 上傳前過濾欄位(最小必要)
function sanitizeRecordsForUpload(list) {
  return (Array.isArray(list)? list: []).map(r => ({
    id: String(r.id || ''),
    task: String(r.task || ''),
    reasonCode: String(r.reasonCode || ''),
    reason: String(r.reason || ''),
    quote: String(r.quote || ''),
    createdAt: Number(r.createdAt || Date.now())
  }));
}

// [Day29-NEW] 備份到雲端
async function backupToCloud() {
  const { clientId, apiKey } = loadCloudPrefs();
  const records = readRecords();
  if (!records?.length) {
    backupFeedback.textContent = '目前沒有可備份的紀錄。';
    return null;
  }
  if (!navigator.onLine) {
    backupFeedback.textContent = '目前離線,請連線後再試。';
    return null;
  }

  backupFeedback.textContent = '上傳中…';
  btnBackupNow.disabled = true;

  const payload = {
    clientId: clientId || 'fixed-demo',
    exportedAt: new Date().toISOString(),
    records: sanitizeRecordsForUpload(records)
  };

  try {
    const json = await withRetry(() =>
      requestJSONWithTimeout(BACKUP_ENDPOINT, {
        method: 'POST',
        headers: apiKey ? { 'X-API-Key': apiKey } : {},
        body: payload
      }, 12000)
    );

    backupFeedback.textContent = json?.ok
      ? `備份成功:已保存 ${json.saved ?? records.length} 筆(編號 #${json.id || 'N/A'})。`
      : '備份完成,但回應格式非預期。';

    // 更新同步徽章
    writeSyncMeta({
      lastSyncedAt: Date.now(),
      lastCloudId: json?.id || null,
      lastCount: json?.saved ?? records.length
    });
    updateSyncBadge();
    return json;
  } catch (err) {
    backupFeedback.textContent = `備份失敗:${String(err.message || err)}`;
    return null;
  } finally {
    btnBackupNow.disabled = false;
  }
}

// [Day29-NEW] 從雲端拉回最新(LWW 覆蓋)
async function pullFromCloud() {
  const { clientId, apiKey } = loadCloudPrefs();

  if (!clientId) {
    pullFeedback.textContent = '請先輸入 Client ID。';
    return null;
  }
  if (!navigator.onLine) {
    pullFeedback.textContent = '目前離線,無法從雲端載入。';
    return null;
  }

  pullFeedback.textContent = '下載中…';
  btnPullNow.disabled = true;

  const url = `${PULL_ENDPOINT}?clientId=${encodeURIComponent(clientId)}`;

  try {
    const json = await withRetry(() =>
      requestJSONWithTimeout(url, {
        method: 'GET',
        headers: apiKey ? { 'X-API-Key': apiKey } : {}
      }, 12000)
    );

    if (!json?.ok || !Array.isArray(json.records)) {
      pullFeedback.textContent = '雲端回應格式非預期或尚無備份。';
      return null;
    }

    // LWW:直接覆蓋本機
    writeRecords(json.records);
    renderHistory?.();
    renderQuoteWall?.();
    renderTaskCard?.();

    pullFeedback.textContent = `載入完成:取得 ${json.count ?? json.records.length} 筆(編號 #${json.id || 'N/A'})。`;

    writeSyncMeta({
      lastSyncedAt: Date.now(),
      lastCloudId: json?.id || null,
      lastCount: json?.count ?? json.records.length
    });
    updateSyncBadge();
    return json;
  } catch (err) {
    const msg = String(err.message || err);
    pullFeedback.textContent = msg.includes('404') ? '雲端沒有找到這個 Client ID 的備份。' : `載入失敗:${msg}`;
    return null;
  } finally {
    btnPullNow.disabled = false;
  }
}

// [Day29-NEW] 同步(先備份 → 再拉回最新)
async function syncNow() {
  pullFeedback.textContent = '';
  backupFeedback.textContent = '';
  btnSyncNow.disabled = true;
  try {
    await backupToCloud();
    await pullFromCloud();
  } finally {
    btnSyncNow.disabled = false;
  }
}

2-5. 綁定按鈕(加入 ripple 名單)

// 事件
btnPullNow?.addEventListener('click',  () => pullFromCloud());
btnSyncNow?.addEventListener('click',  () => syncNow());

// Ripple:把新鈕加進去
(function extendRippleTargets(){
  [
    '#btnPullNow', '#btnSyncNow'
  ].forEach(sel => attachRipple(document.querySelector(sel)));
})();
  1. 後端 server.js:加入 API Key 驗證與 GET /pull-latest
    保留你原本的 Day 23/24 內容,在其上追加/小幅修改。
// [Day29-NEW] 簡易 API Key(可選)
const SYNC_API_KEY = process.env.SYNC_API_KEY || ''; // 若不設定則不檢查

function requireApiKey(req, res, next) {
  if (!SYNC_API_KEY) return next();
  const provided = req.get('X-API-Key') || req.query.key || '';
  if (provided !== SYNC_API_KEY) {
    return res.status(401).json({ ok:false, error:'UNAUTHORIZED' });
  }
  next();
}

// ↑ 把 requireApiKey 套在需要保護的路由

3-1. 修改路由
// 把 public 當成前端根目錄

const PUBLIC_DIR = path.join(__dirname, 'public');
app.use(express.static(PUBLIC_DIR));
app.use(cors({ origin: true, methods: ['GET','POST','OPTIONS'], allowedHeaders: ['Content-Type','X-API-Key'] }));                 // 允許跨域(方便前端直接呼叫)
app.options(/.*/, cors());

// SPA 退回 index.html(避免重新整理 404)
app.get(/^(?!\/(backup|pull-latest|gen-quote|health))(.*)$/, (req, res) => {
  res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});

3-2. 備份路由:套用金鑰 + 最小欄位
把既有 /backup 路由略微調整(只要在路由前掛 requireApiKey,並在保存前過濾欄位):

// 備份 API
app.post('/backup', requireApiKey, (req, res) => {
  const body = req.body;
  if (!body || !Array.isArray(body.records)) {
    return res.status(400).json({ ok: false, error: 'INVALID_PAYLOAD', message: 'payload 必須包含 records 陣列' });
  }

  const sanitize = r => ({
    id: String(r.id || ''),
    task: String(r.task || ''),
    reasonCode: String(r.reasonCode || ''),
    reason: String(r.reason || ''),
    quote: String(r.quote || ''),
    createdAt: Number(r.createdAt || Date.now())
  });

  const current = readBackupFile();
  const entry = {
    id: Date.now().toString(),
    receivedAt: new Date().toISOString(),
    clientId: String(body.clientId || 'unknown'),
    count: body.records.length,
    records: body.records.map(sanitize)
  };
  current.push(entry);

  try {
    writeBackupFile(current);
    return res.json({ ok: true, id: entry.id, saved: entry.count });
  } catch (err) {
    console.error('寫入 backup.json 失敗:', err);
    return res.status(500).json({ ok: false, error: 'WRITE_FAILED' });
  }
});

3-3. 新增拉回最新:GET /pull-latest?clientId=xxx
// [Day29-NEW] 取回某 clientId 最新備份
app.get('/pull-latest', requireApiKey, (req, res) => {
  const clientId = String(req.query.clientId || '').trim();
  if (!clientId) return res.status(400).json({ ok:false, error:'MISSING_CLIENT_ID' });

  const all = readBackupFile().filter(e => String(e.clientId) === clientId);
  if (!all.length) return res.status(404).json({ ok:false, error:'NOT_FOUND', message:'no backup for this clientId' });

  // 以 id(timestamp)或 receivedAt 排序,取最新
  all.sort((a,b) => Number(b.id) - Number(a.id));
  const latest = all[0];

  res.json({
    ok: true,
    id: latest.id,
    count: latest.count,
    receivedAt: latest.receivedAt,
    records: latest.records
  });
});

安全提醒

  • 把 SYNC_API_KEY 設在伺服器環境變數,不要硬編在前端。
  • 本服務不應存放個資或敏感資訊。上傳的欄位已做最小化。
  • 如要進一步保護,可改用 一客戶一金鑰、簽章或 JWT;本文維持最小可用版本。

驗證

  1. 在 A 裝置新增幾筆紀錄 → 歷史頁 「備份到雲端」

    • 看見提示:備份成功:已保存 37 筆(編號 #IDxxxx)
    • 同步徽章顯示:上次同步:今天 HH:mm(雲端 37 筆,編號 #IDxxxx)
  2. 在 B 裝置打開同一作品 → 填入 相同 clientId / API Key → 按 「從雲端載入」

    • 幾秒後,任務清單加載回來。
  3. 斷網狀態測試:navigator.onLine=false 時
    備份/拉回按鈕顯示「目前離線…」提示,不會整頁卡死。

  4. 同步:兩台都各自新增資料 → 各自按「同步(先備份再拉回)」
    最終兩邊都以「最新一份」為準(LWW)。

常見錯誤 & 排查

  1. 401 UNAUTHORIZED

    • 伺服器設了 SYNC_API_KEY,但前端未帶 X-API-Key 或輸入錯誤。
    • 檢查 server 環境變數,或在前端 API Key 欄位重新輸入。
  2. CORS/網路錯誤

    • 確認 ngrok 網址是否與 BACKUP_ENDPOINT / PULL_ENDPOINT 一致。
    • app.use(cors()) 是否存在、ngrok 是否存活。
  3. 逾時/重試仍失敗
    觀察後端日誌是否掛掉;調大 timeoutMs;或檢查是否過於大型 payload。

  4. 拉回後資料不見?
    本文採 LWW 覆蓋:拉回會直接覆蓋本機。若要「合併」,需日後在資料契約加入版本號與合併策略。

  5. 多 clientId 混用
    clientId 是「你的裝置/作品識別」。請各裝置一致;否則後端會視為不同桶。

  6. 405/OPTIONS 預檢失敗
    檢查 CORS allowedHeaders: ['Content-Type','X-API-Key'] 是否與前端一致


上一篇
【Day 28】— 入門 JavaScript 網頁架設:動畫過場 + 拖曳高亮 + Ripple
系列文
Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言