Files
planet/frontend/public/earth/js/news.js
2026-04-22 17:29:24 +08:00

323 lines
10 KiB
JavaScript

import { showStatusMessage } from "./ui.js";
import { isTVPanelVisible } from "./tv.js";
// News aggregation now lives inside the shared media panel:
// - outer shell: #media-panel
// - this module renders into inner pane: #news-panel
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
const FOCUS_UPDATE_INTERVAL_MS = 4000;
const DATA_REFRESH_INTERVAL_MS = 180000;
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
const REQUEST_TIMEOUT_MS = 15000;
let initialized = false;
let refreshPromise = null;
let payload = null;
let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
regionChip: document.getElementById("news-region-chip"),
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
};
}
function formatCoord(value, positiveLabel, negativeLabel) {
const abs = Math.abs(value).toFixed(1);
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
}
function formatRelativeTime(raw) {
if (!raw) return "刚刚同步";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return "刚刚同步";
const diff = Date.now() - date.getTime();
const minutes = Math.max(1, Math.round(diff / 60000));
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.round(hours / 24);
return `${days} 天前`;
}
export function updateNewsToggleUI(visible) {
void visible;
}
function renderEmptyState(message) {
const { board, empty, status, openBtn } = getElements();
if (board) board.innerHTML = "";
if (empty) {
empty.hidden = false;
empty.textContent = message;
}
if (status) {
status.textContent = "等待聚合新闻源";
}
if (openBtn) openBtn.disabled = true;
}
function renderPayload(nextPayload) {
payload = nextPayload;
const {
board,
empty,
status,
focusLabel,
focusCoords,
sourceCount,
regionChip,
openBtn,
feedAnchor,
} = getElements();
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
return;
}
} else {
return;
}
}
if (regionChip) {
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
}
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page does not show the compact chip row.
} else if (!regionChip) {
return;
}
focusLabel.textContent = focus.label || "全球焦点";
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
} else {
focusCoords.textContent = "跟随当前视角自动聚焦";
}
sourceCount.textContent = `${sources.length} 路聚合源`;
if (nextPayload?.stale) {
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length}`;
} else {
status.textContent = nextPayload?.errors?.length
? `已聚合 ${items.length} 条,部分源不可用`
: `已聚合 ${items.length} 条态势新闻`;
}
if (feedAnchor) {
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
}
if (openBtn) {
openBtn.disabled = !feedAnchor?.href;
}
if (items.length === 0) {
board.innerHTML = "";
if (empty) {
empty.hidden = false;
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
}
return;
}
if (empty) empty.hidden = true;
board.innerHTML = items
.map((item) => {
const cardClass = item.is_focus_match
? "news-story-card news-story-card--focus"
: "news-story-card";
const summary = item.summary
? `<div class="news-story-summary">${item.summary}</div>`
: "";
return `
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<div class="news-story-meta">
<span class="news-story-source">${item.source}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div>
<div class="news-story-title">${item.title}</div>
${summary}
<div class="news-story-tags">
<span class="news-story-tag">${item.region}</span>
<span class="news-story-tag">${item.feed_name}</span>
</div>
</a>
`;
})
.join("");
}
async function fetchNews(lat, lon) {
const url = new URL(EARTH_NEWS_API, window.location.origin);
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4));
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(url.toString(), {
cache: "no-store",
signal: controller.signal,
}).finally(() => {
window.clearTimeout(timeoutId);
});
if (!response.ok) {
throw new Error(`新闻源请求失败: ${response.status}`);
}
return response.json();
}
async function refreshNews(lat, lon, { silent = false } = {}) {
if (refreshPromise) return refreshPromise;
const { status } = getElements();
if (status) {
status.textContent = "正在同步全球态势新闻...";
}
refreshPromise = fetchNews(lat, lon)
.then((nextPayload) => {
renderPayload(nextPayload);
lastFetchAt = Date.now();
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
const { status } = getElements();
if (status) {
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
}
}
return nextPayload;
})
.catch((error) => {
console.error("加载 Earth RSS 新闻失败:", error);
const message = error?.name === "AbortError"
? "新闻聚合请求超时,请稍后重试"
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
if (!payload) {
renderEmptyState(message);
} else if (!silent) {
showStatusMessage("态势新闻同步失败", "error");
}
throw error;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
function shouldRefreshForFocus(lat, lon, region) {
const now = Date.now();
if (!lastFocus) return true;
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
lastRegionSwitchAt = now;
return true;
}
if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true;
if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false;
const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0));
const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0));
return latDrift >= 18 || lonDrift >= 25;
}
function inferRegion(lat, lon) {
if (typeof lat !== "number" || typeof lon !== "number") return "global";
if (lon >= -170 && lon <= -30) return "americas";
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
return "asia-pacific";
}
function openCurrentSourceHomepage() {
const { feedAnchor } = getElements();
if (feedAnchor?.href) {
window.open(feedAnchor.href, "_blank", "noopener,noreferrer");
}
}
export function updateNewsViewFocus(coords) {
if (!initialized) return;
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
const region = inferRegion(coords.lat, coords.lon);
const nextFocus = {
lat: coords.lat,
lon: coords.lon,
region,
updatedAt: Date.now(),
};
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
lastFocus = nextFocus;
if (shouldRefresh) {
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
}
}
export async function ensureNewsPanelReady() {
if (!initialized) {
initNewsPanel();
}
if (!payload) {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
}
return payload;
}
export function initNewsPanel() {
if (initialized) return;
initialized = true;
updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源...");
window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible());
});
window.addEventListener("earth:tv-visibility-change", (event) => {
updateNewsToggleUI(Boolean(event.detail?.visible));
});
["news-refresh", "mobile-news-refresh"].forEach((id) => {
const refreshBtn = document.getElementById(id);
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻已刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
});
});
["news-open-external", "mobile-news-open-external"].forEach((id) => {
const openBtn = document.getElementById(id);
openBtn?.addEventListener("click", openCurrentSourceHomepage);
});
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}