摘要
讓你的資料「帶得走」。今天我們做一個最小可用的雲端同步:前端維持離線可用(localStorage),需要時一鍵「備份到雲端」、另一台裝置一鍵「從雲端載入」。不做複雜帳號,先用固定 clientId + API key;同步策略採最後寫入覆蓋(LWW),並處理逾時、重試、斷線提示。UI 會清楚顯示「上次同步時間/筆數」。
註:Day 29 只專於實現「任務清單」同步。
假設已具備:
Day 4–5:本機資料存取(localStorage)
Day 12–19:頁面骨架與導覽
Day 14–15:精神小測驗與提問頁
Day 16–18:任務安排(含拖曳)與溫暖總結
Day 20–21:圖表與統計
Day 22:匯出
Day 23:雲端備份基礎
Day 24:暖心語錄(後端代理)
Day 25:語錄牆
Day 26–27:全站樣式 / 主題切換
Day 28:互動強化
<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 已足夠,無需另加樣式。
// [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)));
})();
// [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
});
});
在 A 裝置新增幾筆紀錄 → 歷史頁 「備份到雲端」
在 B 裝置打開同一作品 → 填入 相同 clientId / API Key → 按 「從雲端載入」
斷網狀態測試:navigator.onLine=false 時
備份/拉回按鈕顯示「目前離線…」提示,不會整頁卡死。
同步:兩台都各自新增資料 → 各自按「同步(先備份再拉回)」
最終兩邊都以「最新一份」為準(LWW)。
401 UNAUTHORIZED
CORS/網路錯誤
逾時/重試仍失敗
觀察後端日誌是否掛掉;調大 timeoutMs;或檢查是否過於大型 payload。
拉回後資料不見?
本文採 LWW 覆蓋:拉回會直接覆蓋本機。若要「合併」,需日後在資料契約加入版本號與合併策略。
多 clientId 混用
clientId 是「你的裝置/作品識別」。請各裝置一致;否則後端會視為不同桶。
405/OPTIONS 預檢失敗
檢查 CORS allowedHeaders: ['Content-Type','X-API-Key'] 是否與前端一致