前端空資料自動重試,改善 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}`);
|
showLoadError(`定位失敗:${e.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 狀態提示:error(紅框,真正錯誤)與 status(中性灰框,暫態提示)分開 ----
|
||||||
|
let errorControl = null;
|
||||||
|
let statusControl = null;
|
||||||
|
|
||||||
function showLoadError(msg) {
|
function showLoadError(msg) {
|
||||||
|
clearStatus(); // 真錯誤時優先顯示,蓋掉中性 banner
|
||||||
|
if (errorControl) { map.removeControl(errorControl); errorControl = null; }
|
||||||
const box = L.control({ position: 'topright' });
|
const box = L.control({ position: 'topright' });
|
||||||
box.onAdd = () => {
|
box.onAdd = () => {
|
||||||
const div = L.DomUtil.create('div');
|
const div = L.DomUtil.create('div');
|
||||||
@@ -391,63 +397,91 @@
|
|||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
box.addTo(map);
|
box.addTo(map);
|
||||||
|
errorControl = box;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(API_URL, {
|
function showStatus(msg) {
|
||||||
method: 'POST',
|
if (statusControl) { map.removeControl(statusControl); statusControl = null; }
|
||||||
cache: 'no-cache',
|
const box = L.control({ position: 'topright' });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
box.onAdd = () => {
|
||||||
body: JSON.stringify({ lat: SCAN_CENTER.lat, lng: SCAN_CENTER.lng })
|
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)';
|
||||||
.then(res => {
|
div.textContent = msg;
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
return div;
|
||||||
return res.json();
|
};
|
||||||
})
|
box.addTo(map);
|
||||||
.then(json => {
|
statusControl = box;
|
||||||
// 後端回 {source, items: [...]}; toList() 也相容頂層陣列的舊格式
|
}
|
||||||
const list = toList(json);
|
|
||||||
const markers = [];
|
|
||||||
|
|
||||||
for (const item of list) {
|
function clearStatus() {
|
||||||
const lat = item.latitude ?? item.Latitude_Adjusted ?? item.Latitude_Raw;
|
if (statusControl) { map.removeControl(statusControl); statusControl = null; }
|
||||||
const lng = item.longitude ?? item.Longitude_Adjusted ?? item.Longitude_Raw;
|
}
|
||||||
if (!isNum(lat) || !isNum(lng)) continue;
|
|
||||||
|
|
||||||
const icon = makeIcon(item);
|
// ---- 空資料自動重試(處理 API stale-while-revalidate cold-start 暫態)----
|
||||||
const marker = L.marker([lat, lng], { icon, title: `${item.name ?? ''} (#${item.id})` }).addTo(map);
|
const RETRY_INTERVAL_MS = 8000;
|
||||||
const isPerfect = !!item.is_perfect || item.iv_pct === 100;
|
const RETRY_MAX = 10; // 總上限 80 秒
|
||||||
// 延遲到打開時再 build,讓剩餘秒數每次開 popup 都是即時的
|
let retryCount = 0;
|
||||||
marker.bindPopup(() => buildPopup(item), { className: isPerfect ? 'perfect-popup' : '' });
|
let retryTimer = null;
|
||||||
|
let mapClickBound = false;
|
||||||
|
|
||||||
// hover 顯示
|
function clearRetry() {
|
||||||
marker.on('mouseover', function(e) {
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||||
if (pinnedPopup && pinnedPopup === this.getPopup()) return;
|
}
|
||||||
this.openPopup();
|
|
||||||
e.target._icon.classList.add('hovered');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 離開關閉(未固定時)
|
function scheduleRetry() {
|
||||||
marker.on('mouseout', function(e) {
|
clearRetry();
|
||||||
if (pinnedPopup && pinnedPopup === this.getPopup()) return;
|
retryTimer = setTimeout(() => {
|
||||||
this.closePopup();
|
retryTimer = null;
|
||||||
e.target._icon.classList.remove('hovered');
|
loadScan();
|
||||||
});
|
}, RETRY_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// 點擊固定 popup
|
// 頁面關閉/離開時清 timer,避免 leak
|
||||||
marker.on('click', function(e) {
|
window.addEventListener('beforeunload', clearRetry);
|
||||||
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);
|
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() {
|
map.on('click', function() {
|
||||||
if (pinnedPopup) {
|
if (pinnedPopup) {
|
||||||
pinnedPopup._source.closePopup();
|
pinnedPopup._source.closePopup();
|
||||||
@@ -455,19 +489,59 @@
|
|||||||
pinnedPopup = null;
|
pinnedPopup = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
mapClickBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (markers.length > 0) {
|
if (markers.length > 0) {
|
||||||
const group = L.featureGroup(markers);
|
const group = L.featureGroup(markers);
|
||||||
map.fitBounds(group.getBounds().pad(0.2));
|
map.fitBounds(group.getBounds().pad(0.2));
|
||||||
} else {
|
}
|
||||||
// API stale-while-revalidate:快取剛過期時會先回空陣列,幾十秒後再打才有資料
|
return markers.length;
|
||||||
showLoadError('目前無資料,稍後再試');
|
}
|
||||||
}
|
|
||||||
|
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 => {
|
.then(res => {
|
||||||
console.error('讀取掃描資料失敗:', err);
|
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||||
showLoadError(`掃描資料載入失敗:${err.message}`);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user