前端空資料自動重試,改善 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:
192
index.html
192
index.html
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user