376 lines
12 KiB
JavaScript
376 lines
12 KiB
JavaScript
import { showStatusMessage } from "./ui.js";
|
|
import { getActiveTVTab, 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;
|
|
let selectedCruiseStoryId = null;
|
|
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}" data-news-id="${item.id}" 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("");
|
|
|
|
applyCruiseStorySelection();
|
|
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
|
|
detail: {
|
|
payload: nextPayload,
|
|
itemIds: items.map((item) => item.id),
|
|
},
|
|
}));
|
|
}
|
|
|
|
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 getNewsPayload() {
|
|
return payload;
|
|
}
|
|
|
|
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
|
|
if (!(board instanceof HTMLElement)) return;
|
|
const cards = board.querySelectorAll("[data-news-id]");
|
|
cards.forEach((card) => {
|
|
const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId;
|
|
card.classList.toggle("news-story-card--cruise", matches);
|
|
if (matches && scrollIntoView) {
|
|
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyCruiseStorySelection(options = {}) {
|
|
const desktopBoard = document.getElementById("news-board-list");
|
|
const mobileBoard = document.getElementById("mobile-news-board-list");
|
|
updateBoardSelection(desktopBoard, options);
|
|
updateBoardSelection(mobileBoard, options);
|
|
}
|
|
|
|
function revealSelectedCruiseStory() {
|
|
if (!selectedCruiseStoryId) return;
|
|
applyCruiseStorySelection({ scrollIntoView: true });
|
|
}
|
|
|
|
export function selectNewsItem(itemId, options = {}) {
|
|
selectedCruiseStoryId = itemId || null;
|
|
applyCruiseStorySelection(options);
|
|
}
|
|
|
|
export function clearSelectedNewsItem() {
|
|
selectedCruiseStoryId = null;
|
|
applyCruiseStorySelection();
|
|
}
|
|
|
|
export function initNewsPanel() {
|
|
if (initialized) return;
|
|
initialized = true;
|
|
|
|
updateNewsToggleUI(isTVPanelVisible());
|
|
renderEmptyState("正在准备全球态势新闻聚合源...");
|
|
|
|
window.addEventListener("earth:tv-tab-change", () => {
|
|
updateNewsToggleUI(isTVPanelVisible());
|
|
if (getActiveTVTab() === "news") {
|
|
revealSelectedCruiseStory();
|
|
}
|
|
});
|
|
window.addEventListener("earth:tv-visibility-change", (event) => {
|
|
updateNewsToggleUI(Boolean(event.detail?.visible));
|
|
if (event.detail?.visible && getActiveTVTab() === "news") {
|
|
revealSelectedCruiseStory();
|
|
}
|
|
});
|
|
|
|
["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(() => {});
|
|
}
|