前端空資料自動重試,改善 Redis 冷啟動 UX

後端 Redis key 過期時會先回空陣列、背景 fetcher 約 30-60 秒
填回資料。過去前端只要拿到空 items 就直接顯示紅色錯誤框,
使用者第一次開頁面撞上這個暫態會誤以為服務壞了。

這次把「空資料」視為暫態而非錯誤:顯示中性的 loading banner
並每 8 秒自動重試,最多 10 次(總 80 秒)。拿到資料立即停止
polling 並渲染;超過上限仍空則改顯示中性的稍後重試提示,
不再使用紅色錯誤框。真正的 network / HTTP / JSON 錯誤維持
原本的紅框行為。

新增:showStatus / clearStatus / scheduleRetry / clearRetry、
retryCount / retryTimer 狀態變數、renderItems 抽出 marker 渲染、
loadScan 包裝整個 fetch 流程,beforeunload 清 timer 防 leak。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
timmy
2026-04-20 11:13:27 +08:00
parent 86c002bead
commit 0ad7f0fba5

View File

@@ -382,7 +382,13 @@
showLoadError(`定位失敗:${e.message}`);
});
// ---- 狀態提示error紅框真正錯誤與 status中性灰框暫態提示分開 ----
let errorControl = null;
let statusControl = null;
function showLoadError(msg) {
clearStatus(); // 真錯誤時優先顯示,蓋掉中性 banner
if (errorControl) { map.removeControl(errorControl); errorControl = null; }
const box = L.control({ position: 'topright' });
box.onAdd = () => {
const div = L.DomUtil.create('div');
@@ -391,63 +397,91 @@
return div;
};
box.addTo(map);
errorControl = box;
}
fetch(API_URL, {
method: 'POST',
cache: 'no-cache',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: SCAN_CENTER.lat, lng: SCAN_CENTER.lng })
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
return res.json();
})
.then(json => {
// 後端回 {source, items: [...]}; toList() 也相容頂層陣列的舊格式
const list = toList(json);
const markers = [];
function showStatus(msg) {
if (statusControl) { map.removeControl(statusControl); statusControl = null; }
const box = L.control({ position: 'topright' });
box.onAdd = () => {
const div = L.DomUtil.create('div');
div.style.cssText = 'background:#fff;border:1px solid #cbd5e1;color:#334155;padding:8px 12px;border-radius:6px;font-size:14px;box-shadow:0 1px 4px rgba(0,0,0,.15)';
div.textContent = msg;
return div;
};
box.addTo(map);
statusControl = box;
}
for (const item of list) {
const lat = item.latitude ?? item.Latitude_Adjusted ?? item.Latitude_Raw;
const lng = item.longitude ?? item.Longitude_Adjusted ?? item.Longitude_Raw;
if (!isNum(lat) || !isNum(lng)) continue;
function clearStatus() {
if (statusControl) { map.removeControl(statusControl); statusControl = null; }
}
const icon = makeIcon(item);
const marker = L.marker([lat, lng], { icon, title: `${item.name ?? ''} (#${item.id})` }).addTo(map);
const isPerfect = !!item.is_perfect || item.iv_pct === 100;
// 延遲到打開時再 build讓剩餘秒數每次開 popup 都是即時的
marker.bindPopup(() => buildPopup(item), { className: isPerfect ? 'perfect-popup' : '' });
// ---- 空資料自動重試(處理 API stale-while-revalidate cold-start 暫態)----
const RETRY_INTERVAL_MS = 8000;
const RETRY_MAX = 10; // 總上限 80 秒
let retryCount = 0;
let retryTimer = null;
let mapClickBound = false;
// hover 顯示
marker.on('mouseover', function(e) {
if (pinnedPopup && pinnedPopup === this.getPopup()) return;
this.openPopup();
e.target._icon.classList.add('hovered');
});
function clearRetry() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
}
// 離開關閉(未固定時)
marker.on('mouseout', function(e) {
if (pinnedPopup && pinnedPopup === this.getPopup()) return;
this.closePopup();
e.target._icon.classList.remove('hovered');
});
function scheduleRetry() {
clearRetry();
retryTimer = setTimeout(() => {
retryTimer = null;
loadScan();
}, RETRY_INTERVAL_MS);
}
// 點擊固定 popup
marker.on('click', function(e) {
if (pinnedPopup && pinnedPopup !== this.getPopup()) {
pinnedPopup._source.closePopup();
pinnedPopup._source._icon.classList.remove('hovered');
}
this.openPopup();
e.target._icon.classList.add('hovered');
pinnedPopup = this.getPopup();
});
// 頁面關閉/離開時清 timer避免 leak
window.addEventListener('beforeunload', clearRetry);
markers.push(marker);
}
function renderItems(list) {
const markers = [];
for (const item of list) {
const lat = item.latitude ?? item.Latitude_Adjusted ?? item.Latitude_Raw;
const lng = item.longitude ?? item.Longitude_Adjusted ?? item.Longitude_Raw;
if (!isNum(lat) || !isNum(lng)) continue;
// 點擊地圖其他地方 → 關閉固定 popup
const icon = makeIcon(item);
const marker = L.marker([lat, lng], { icon, title: `${item.name ?? ''} (#${item.id})` }).addTo(map);
const isPerfect = !!item.is_perfect || item.iv_pct === 100;
// 延遲到打開時再 build讓剩餘秒數每次開 popup 都是即時的
marker.bindPopup(() => buildPopup(item), { className: isPerfect ? 'perfect-popup' : '' });
// hover 顯示
marker.on('mouseover', function(e) {
if (pinnedPopup && pinnedPopup === this.getPopup()) return;
this.openPopup();
e.target._icon.classList.add('hovered');
});
// 離開關閉(未固定時)
marker.on('mouseout', function(e) {
if (pinnedPopup && pinnedPopup === this.getPopup()) return;
this.closePopup();
e.target._icon.classList.remove('hovered');
});
// 點擊固定 popup
marker.on('click', function(e) {
if (pinnedPopup && pinnedPopup !== this.getPopup()) {
pinnedPopup._source.closePopup();
pinnedPopup._source._icon.classList.remove('hovered');
}
this.openPopup();
e.target._icon.classList.add('hovered');
pinnedPopup = this.getPopup();
});
markers.push(marker);
}
// 點擊地圖其他地方 → 關閉固定 popup整個頁面生命週期只綁一次
if (!mapClickBound) {
map.on('click', function() {
if (pinnedPopup) {
pinnedPopup._source.closePopup();
@@ -455,19 +489,59 @@
pinnedPopup = null;
}
});
mapClickBound = true;
}
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.2));
} else {
// API stale-while-revalidate快取剛過期時會先回空陣列幾十秒後再打才有資料
showLoadError('目前無資料,稍後再試');
}
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.2));
}
return markers.length;
}
function loadScan() {
fetch(API_URL, {
method: 'POST',
cache: 'no-cache',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: SCAN_CENTER.lat, lng: SCAN_CENTER.lng })
})
.catch(err => {
console.error('讀取掃描資料失敗:', err);
showLoadError(`掃描資料載入失敗:${err.message}`);
});
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
return res.json();
})
.then(json => {
// 後端回 {source, items: [...]}; toList() 也相容頂層陣列的舊格式
const list = toList(json);
if (list.length === 0) {
// 空資料視為暫態Redis 剛過期、背景 fetcher 正在重建
retryCount++;
if (retryCount <= RETRY_MAX) {
showStatus(`正在更新資料,請稍候…(${retryCount}/${RETRY_MAX}`);
scheduleRetry();
} else {
clearRetry();
showStatus('後端仍無資料,請稍後重新整理');
}
return;
}
// 有資料:停 polling、清 banner、渲染
clearRetry();
clearStatus();
retryCount = 0;
renderItems(list);
})
.catch(err => {
// 真正的 fetch 失敗(網路/HTTP/JSON parse→ 紅框,不自動 retry
clearRetry();
console.error('讀取掃描資料失敗:', err);
showLoadError(`掃描資料載入失敗:${err.message}`);
});
}
loadScan();
</script>
</body>
</html>