release: bump version to 0.69.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-06-03 17:27:00 +08:00
parent 06aca980d0
commit acbbfdf9e2
57 changed files with 5907 additions and 316 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.68.1",
"version": "0.69.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -1086,6 +1086,12 @@
font-weight: 600;
}
.earth-mobile-news-filter-popover {
position: relative;
inset: auto;
max-height: 34vh;
}
.earth-mobile-news-board-list .news-story-card {
margin: 0;
}

View File

@@ -427,6 +427,43 @@
text-wrap: balance;
}
.info-card-news-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: calc(6px * var(--hud-scale));
}
.info-card-news-meta-grid--mobile {
grid-template-columns: 1fr;
}
.info-card-news-meta-item {
min-width: 0;
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
border: 1px solid rgba(201, 225, 247, 0.08);
background: rgba(255, 255, 255, 0.035);
}
.info-card-news-meta-item span,
.info-card-news-meta-item strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-card-news-meta-item span {
color: rgba(188, 212, 238, 0.62);
font-size: calc(0.58rem * var(--hud-scale));
}
.info-card-news-meta-item strong {
color: rgba(236, 246, 255, 0.9);
font-size: calc(0.68rem * var(--hud-scale));
font-weight: 650;
margin-top: calc(2px * var(--hud-scale));
}
.info-card-news-summary-shell {
position: relative;
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));

View File

@@ -303,6 +303,121 @@
font-size: calc(0.72rem * var(--hud-scale));
}
.news-filter-bar {
position: relative;
display: flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
flex-wrap: wrap;
}
.news-filter-pill {
display: inline-flex;
align-items: center;
gap: calc(6px * var(--hud-scale));
min-height: calc(30px * var(--hud-scale));
border: 1px solid rgba(201, 225, 247, 0.1);
border-radius: calc(12px * var(--hud-scale));
padding: calc(5px * var(--hud-scale)) calc(9px * var(--hud-scale));
color: var(--hud-text-soft);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(116, 166, 224, 0.04));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 8px 18px rgba(2, 10, 22, 0.12);
font: inherit;
font-size: calc(0.72rem * var(--hud-scale));
cursor: pointer;
}
.news-filter-pill:hover,
.news-filter-pill[aria-expanded="true"] {
color: var(--hud-text);
border-color: rgba(147, 202, 255, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(116, 166, 224, 0.07));
}
.news-filter-pill strong {
color: var(--hud-accent-strong);
font-size: calc(0.7rem * var(--hud-scale));
font-weight: 700;
}
.news-filter-pill--view {
margin-left: auto;
}
.news-filter-popover {
position: absolute;
top: calc(154px * var(--hud-scale));
left: calc(14px * var(--hud-scale));
right: calc(14px * var(--hud-scale));
z-index: 30;
display: grid;
gap: calc(10px * var(--hud-scale));
max-height: min(calc(280px * var(--hud-scale)), 44vh);
overflow-y: auto;
border: 1px solid rgba(205, 231, 255, 0.12);
border-radius: calc(16px * var(--hud-scale));
padding: calc(12px * var(--hud-scale));
color: var(--hud-text);
background:
radial-gradient(circle at 18% 10%, rgba(122, 187, 255, 0.16), transparent 42%),
linear-gradient(180deg, rgba(20, 35, 58, 0.96), rgba(10, 20, 35, 0.96));
box-shadow:
0 20px 50px rgba(2, 8, 20, 0.38),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
backdrop-filter: blur(18px) saturate(125%);
-webkit-backdrop-filter: blur(18px) saturate(125%);
}
.news-filter-popover[hidden] {
display: none;
}
.news-filter-popover__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: calc(10px * var(--hud-scale));
}
.news-filter-popover__title {
font-size: calc(0.8rem * var(--hud-scale));
font-weight: 700;
}
.news-filter-popover__hint {
color: var(--hud-text-muted);
font-size: calc(0.66rem * var(--hud-scale));
}
.news-filter-chip-group {
display: flex;
flex-wrap: wrap;
gap: calc(8px * var(--hud-scale));
}
.news-filter-chip {
border: 1px solid rgba(201, 225, 247, 0.12);
border-radius: calc(14px * var(--hud-scale));
padding: calc(7px * var(--hud-scale)) calc(10px * var(--hud-scale));
color: var(--hud-text-soft);
background: rgba(255, 255, 255, 0.04);
font: inherit;
font-size: calc(0.74rem * var(--hud-scale));
cursor: pointer;
}
.news-filter-chip.is-active {
color: var(--hud-text);
border-color: rgba(120, 190, 255, 0.36);
background:
linear-gradient(180deg, rgba(79, 143, 232, 0.22), rgba(64, 111, 191, 0.12));
box-shadow: inset 0 0 0 1px rgba(206, 232, 255, 0.08);
}
.news-board {
display: flex;
flex: 1 1 auto;
@@ -379,13 +494,21 @@
.news-story-tags {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(8px * var(--hud-scale));
flex-wrap: wrap;
}
.news-story-meta {
justify-content: space-between;
}
.news-story-tags {
justify-content: flex-start;
}
.news-story-source,
.news-story-time,
.news-story-origin,
.news-story-tag {
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
@@ -393,6 +516,17 @@
.news-story-source {
color: var(--hud-accent-strong);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.news-story-origin {
color: rgba(188, 212, 238, 0.56);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.news-story-title {

View File

@@ -606,6 +606,22 @@
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
</div>
<div class="news-filter-bar" data-news-filter-bar>
<button class="news-filter-pill" type="button" data-news-filter-toggle="category" aria-expanded="false">
<span>类型</span>
<strong data-news-filter-summary="category">全部</strong>
</button>
<button class="news-filter-pill" type="button" data-news-filter-toggle="source" aria-expanded="false">
<span>来源</span>
<strong data-news-filter-summary="source">全部</strong>
</button>
<button id="news-view-all-toggle" class="news-filter-pill news-filter-pill--view" type="button">
<span data-news-view-mode-label>查看全部</span>
<strong data-news-filter-summary="limit">12 条</strong>
</button>
</div>
<div class="news-filter-popover" data-news-filter-popover hidden></div>
<div class="news-board">
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
<div id="news-board-list" class="news-board-list"></div>
@@ -768,6 +784,21 @@
</div>
<div id="mobile-news-source-count" class="earth-mobile-news-source-count">0 路聚合源</div>
</div>
<div class="news-filter-bar earth-mobile-news-filter-bar" data-news-filter-bar>
<button class="news-filter-pill" type="button" data-news-filter-toggle="category" aria-expanded="false">
<span>类型</span>
<strong data-news-filter-summary="category">全部</strong>
</button>
<button class="news-filter-pill" type="button" data-news-filter-toggle="source" aria-expanded="false">
<span>来源</span>
<strong data-news-filter-summary="source">全部</strong>
</button>
<button id="mobile-news-view-all-toggle" class="news-filter-pill news-filter-pill--view" type="button">
<span data-news-view-mode-label>查看全部</span>
<strong data-news-filter-summary="limit">12 条</strong>
</button>
</div>
<div class="news-filter-popover earth-mobile-news-filter-popover" data-news-filter-popover hidden></div>
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
@@ -1049,6 +1080,27 @@
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<div class="earth-mobile-settings-subsection-title">新闻类型</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">新闻类型</span>
<span class="earth-mobile-settings-subtitle">只筛选当前浏览器的新闻面板与新闻巡航,不改变后台新闻源。</span>
</div>
<div class="earth-mobile-settings-chip-group" role="group" aria-label="选择新闻类型">
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="politics" aria-pressed="true">政治</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="business" aria-pressed="true">商业</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="ecommerce" aria-pressed="true">电商</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="finance" aria-pressed="true">金融</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="sports" aria-pressed="true">体育</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="technology" aria-pressed="true">科技</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="military" aria-pressed="true">军事</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="disaster" aria-pressed="true">灾害</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="energy" aria-pressed="true">能源</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="society" aria-pressed="true">社会</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="culture" aria-pressed="true">文化</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-news-category-toggle="other" aria-pressed="true">其他</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="panels" hidden>
<div class="earth-mobile-settings-title">面板</div>
@@ -1555,6 +1607,27 @@
<span class="earth-settings-switch-track"></span>
</span>
</label>
<div class="earth-settings-subsection-title">新闻类型</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">新闻类型</span>
<span class="earth-settings-item-subtitle">只筛选当前浏览器的新闻面板与新闻巡航,不改变后台新闻源。</span>
</div>
<div class="earth-settings-chip-group" role="group" aria-label="选择新闻类型">
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="politics" aria-pressed="true">政治</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="business" aria-pressed="true">商业</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="ecommerce" aria-pressed="true">电商</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="finance" aria-pressed="true">金融</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="sports" aria-pressed="true">体育</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="technology" aria-pressed="true">科技</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="military" aria-pressed="true">军事</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="disaster" aria-pressed="true">灾害</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="energy" aria-pressed="true">能源</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="society" aria-pressed="true">社会</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="culture" aria-pressed="true">文化</button>
<button type="button" class="earth-settings-chip is-active" data-news-category-toggle="other" aria-pressed="true">其他</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section" data-settings-tab-panel="panels" hidden>

View File

@@ -2,6 +2,8 @@ import { PATHS } from "./constants.js";
const RECENT_EVENT_TTL_MS = 15_000;
const recentEventMap = new Map();
const pendingEventMap = new Map();
let pendingFlushTimer = null;
function normalizeErrorDetail(detail) {
if (!detail) return "";
@@ -22,6 +24,37 @@ function dedupeKey(level, message, detail, category) {
return `${level}::${category || ""}::${message}::${detail}`;
}
function normalizeFingerprintText(value) {
return String(value || "")
.replace(/[?&](m|t|token|expires|signature|X-Amz-[^=]+)=[^&\s]+/gi, "")
.replace(/(index|chunk|segment)[_-]?\d+(_\d+)?\.(ts|m4s|vtt)/gi, "<hls-fragment>")
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<uuid>")
.replace(/\bconn_[A-Za-z0-9:._-]+\b/g, "<connection>")
.replace(/\b\d{5,}\b/g, "<number>")
.replace(/\s+/g, " ")
.trim();
}
function hashFingerprint(value) {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return `client-${(hash >>> 0).toString(16).padStart(8, "0")}`;
}
function buildFingerprint(level, message, detail, category, module) {
return hashFingerprint(
[
normalizeFingerprintText(level),
normalizeFingerprintText(category),
normalizeFingerprintText(module),
normalizeFingerprintText(message),
normalizeFingerprintText(detail),
].join("|"),
);
}
function shouldSkip(level, message, detail, category) {
const key = dedupeKey(level, message, detail, category);
const now = Date.now();
@@ -37,33 +70,14 @@ function shouldSkip(level, message, detail, category) {
return lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS;
}
export async function reportEarthClientLog({
level = "error",
message,
category = "runtime",
module = "earth",
detail = "",
}) {
if (!message) return;
const normalizedDetail = normalizeErrorDetail(detail);
if (shouldSkip(level, message, normalizedDetail, category)) {
return;
}
async function sendEarthClientLog(payload) {
try {
await fetch(PATHS.earthClientLogsApi, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
level,
message,
category,
module,
url: window.location.href,
detail: normalizedDetail.slice(0, 4000),
}),
body: JSON.stringify(payload),
keepalive: true,
});
} catch {
@@ -71,6 +85,61 @@ export async function reportEarthClientLog({
}
}
function scheduleFlush() {
if (pendingFlushTimer) return;
pendingFlushTimer = window.setTimeout(() => {
pendingFlushTimer = null;
const pending = Array.from(pendingEventMap.values());
pendingEventMap.clear();
pending.forEach((entry) => {
void sendEarthClientLog({
level: entry.level,
message: entry.message,
category: entry.category,
module: entry.module,
url: window.location.href,
detail: entry.detail.slice(0, 4000),
fingerprint: entry.fingerprint,
occurrence_count: entry.occurrenceCount,
metadata: entry.metadata,
});
});
}, 1000);
}
export function reportEarthClientLog({
level = "error",
message,
category = "runtime",
module = "earth",
detail = "",
metadata = {},
}) {
if (!message) return;
const normalizedDetail = normalizeErrorDetail(detail);
const fingerprint = buildFingerprint(level, message, normalizedDetail, category, module);
const key = dedupeKey(level, message, normalizedDetail, category);
const existing = pendingEventMap.get(key);
if (existing) {
existing.occurrenceCount += 1;
existing.metadata = { ...existing.metadata, ...metadata };
} else {
pendingEventMap.set(key, {
level,
message,
category,
module,
detail: normalizedDetail,
fingerprint,
occurrenceCount: 1,
metadata,
});
}
shouldSkip(level, message, normalizedDetail, category);
scheduleFlush();
}
export function registerEarthClientErrorHandlers() {
window.addEventListener("error", (event) => {
console.error("全局错误:", event.error);

View File

@@ -159,7 +159,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 15;
const EARTH_SETTINGS_VERSION = 16;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
@@ -172,6 +172,21 @@ const SURFACE_HOVER_INFO_DEFAULT_VERSION = 11;
const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
const CRUISE_QUEUE_DEFAULT_VERSION = 14;
const AUTO_ROTATION_SPEED_DEFAULT_VERSION = 15;
const NEWS_CATEGORY_FILTERS_DEFAULT_VERSION = 16;
const DEFAULT_NEWS_CATEGORY_FILTERS = {
politics: true,
business: true,
ecommerce: true,
finance: true,
sports: true,
technology: true,
military: true,
disaster: true,
energy: true,
society: true,
culture: true,
other: true,
};
const AUTO_ROTATION_SPEED_MIN = 0.0001;
const AUTO_ROTATION_SPEED_MAX = 0.0015;
const AUTO_ROTATION_SPEED_STEP = 0.00005;
@@ -1367,6 +1382,23 @@ function canUseLocalStorage() {
}
}
function normalizeNewsCategoryFilters(filters) {
const normalized = { ...DEFAULT_NEWS_CATEGORY_FILTERS };
if (!filters || typeof filters !== "object") return normalized;
Object.keys(DEFAULT_NEWS_CATEGORY_FILTERS).forEach((category) => {
if (typeof filters[category] === "boolean") {
normalized[category] = filters[category];
}
});
return normalized;
}
function isNewsCategoryFilterEnabled(filters, category) {
const key = String(category || "").trim();
if (!(key in DEFAULT_NEWS_CATEGORY_FILTERS)) return true;
return normalizeNewsCategoryFilters(filters)[key] !== false;
}
function getCurrentPanelVisibilitySnapshot() {
return Object.fromEntries(
HUD_PANEL_IDS.map((panelId) => {
@@ -1400,6 +1432,9 @@ function getCurrentSharedSettingsSnapshot() {
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
surfaceHoverInfoMode: getSurfaceHoverInfoMode(),
newsCategoryFilters: normalizeNewsCategoryFilters(
earthSettingsState?.shared?.newsCategoryFilters,
),
keyboardShortcuts: normalizeKeyboardShortcuts(keyboardShortcuts),
};
}
@@ -1464,6 +1499,7 @@ function cloneEarthSettings(settings) {
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
settings.shared.surfaceHoverInfoMode,
),
newsCategoryFilters: normalizeNewsCategoryFilters(settings.shared.newsCategoryFilters),
keyboardShortcuts: normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts),
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
@@ -1611,6 +1647,13 @@ function normalizeEarthSettings(rawSettings, defaults) {
(rawSettings?.version || 0) >= KEYBOARD_SHORTCUTS_DEFAULT_VERSION
? normalizeKeyboardShortcuts(sharedSettings?.keyboardShortcuts)
: defaults.shared.keyboardShortcuts;
const legacyNewsCategoryFilters = sharedSettings?.["display" + "Types"]?.news;
const nextNewsCategoryFilters =
(rawSettings?.version || 0) >= NEWS_CATEGORY_FILTERS_DEFAULT_VERSION
? normalizeNewsCategoryFilters(
sharedSettings?.newsCategoryFilters || legacyNewsCategoryFilters,
)
: normalizeNewsCategoryFilters(defaults.shared.newsCategoryFilters);
const nextCruiseQueueMode =
(rawSettings?.version || 0) >= CRUISE_QUEUE_DEFAULT_VERSION
? normalizeCruiseQueueMode(sharedSettings?.cruiseQueueMode)
@@ -1651,6 +1694,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
surfaceHoverInfoMode: nextSurfaceHoverInfoMode,
newsCategoryFilters: nextNewsCategoryFilters,
keyboardShortcuts: nextKeyboardShortcuts,
},
views: {
@@ -1937,6 +1981,71 @@ function syncInteractableCompactDotsToggle() {
});
}
function syncNewsCategoryFilterControls() {
const filters = normalizeNewsCategoryFilters(
earthSettingsState?.shared?.newsCategoryFilters,
);
document.querySelectorAll("[data-news-category-toggle]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const category = button.dataset.newsCategoryToggle || "";
const active = isNewsCategoryFilterEnabled(filters, category);
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function dispatchNewsCategoryFiltersChange(
filters = earthSettingsState?.shared?.newsCategoryFilters,
) {
window.dispatchEvent(
new CustomEvent("earth:news-category-filters-change", {
detail: {
categories: normalizeNewsCategoryFilters(filters),
},
}),
);
}
function applyNewsCategoryFilters(filters = earthSettingsState?.shared?.newsCategoryFilters) {
const normalized = normalizeNewsCategoryFilters(filters);
syncNewsCategoryFilterControls();
dispatchNewsCategoryFiltersChange(normalized);
}
export function getEarthNewsCategoryFilters() {
return normalizeNewsCategoryFilters(earthSettingsState?.shared?.newsCategoryFilters);
}
export function isEarthNewsCategoryEnabled(category) {
return isNewsCategoryFilterEnabled(
earthSettingsState?.shared?.newsCategoryFilters,
category,
);
}
export function setEarthNewsCategoryEnabled(
category,
enabled,
{ persist = true, suppressStatus = false } = {},
) {
const key = String(category || "").trim();
if (!(key in DEFAULT_NEWS_CATEGORY_FILTERS)) return false;
ensureMutableEarthSettingsState();
const nextFilters = normalizeNewsCategoryFilters(earthSettingsState.shared.newsCategoryFilters);
nextFilters[key] = Boolean(enabled);
earthSettingsState.shared.newsCategoryFilters = nextFilters;
applyNewsCategoryFilters(nextFilters);
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(Boolean(enabled) ? "新闻类型已显示" : "新闻类型已隐藏", "info");
}
return true;
}
function syncSurfaceHoverInfoModeControls() {
const activeMode = getSurfaceHoverInfoMode();
document.querySelectorAll("[data-surface-hover-info-mode]").forEach((button) => {
@@ -2295,6 +2404,7 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
applyImmediateLayerVisibilityHints(layerVisibility);
deferredLayerVisibilitySettings = layerVisibility;
applyNewsCategoryFilters(settings.shared.newsCategoryFilters);
return;
}
@@ -2303,6 +2413,7 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
silent: true,
});
applyNewsCategoryFilters(settings.shared.newsCategoryFilters);
}
export function getMotionDebugEnabled() {
@@ -2415,6 +2526,7 @@ export async function applyDeferredLayerVisibilitySettings(options = {}) {
silent: true,
...options,
});
applyNewsCategoryFilters(earthSettingsState?.shared?.newsCategoryFilters);
}
function resetEarthSettings() {
@@ -4281,6 +4393,19 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-news-category-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLButtonElement)) return;
bindListener(toggle, "click", () => {
const active = toggle.classList.contains("is-active");
setEarthNewsCategoryEnabled(toggle.dataset.newsCategoryToggle, !active);
});
});
bindListener(window, "earth:set-news-category-enabled", (event) => {
const detail = event.detail || {};
setEarthNewsCategoryEnabled(detail.category, Boolean(detail.enabled));
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
@@ -4323,6 +4448,7 @@ function setupSettingsControls() {
syncSatelliteIdleBreathingToggle();
syncSatelliteRealAltitudeToggle();
syncInteractableCompactDotsToggle();
syncNewsCategoryFilterControls();
syncSurfaceHoverInfoModeControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);

View File

@@ -1,7 +1,6 @@
import { PATHS } from "./constants.js";
import { createInteractableLayer, SURFACE_AVOIDANCE_PROFILES } from "./interactable.js";
let showEarthInteractables = true;
const interactableRevisions = new Map();
function featureToInteractableItem(feature) {
@@ -112,7 +111,7 @@ export async function loadEarthInteractables(earth, { silent = false } = {}) {
items.forEach((item) => interactableRevisions.set(item.id, Number(item.revision || 0)));
earthInteractableLayer.setData(items);
earthInteractableLayer.attach(earth);
earthInteractableLayer.setVisible(showEarthInteractables);
earthInteractableLayer.setVisible(true);
if (!silent) {
console.info("Earth interactables loaded", { count: items.length });
}
@@ -159,7 +158,7 @@ export function applyEarthInteractableEvent(earth, payload = {}) {
const changed = earthInteractableLayer.upsertItem(item);
interactableRevisions.set(id, nextRevision || Number(item.revision || 0));
earthInteractableLayer.attach(earth);
earthInteractableLayer.setVisible(showEarthInteractables);
earthInteractableLayer.setVisible(true);
return changed;
}

View File

@@ -287,10 +287,28 @@ function renderNewsCardContent(content, data) {
if (!(content instanceof HTMLElement)) return;
const summary = getNewsCardSummary(data);
const title = getNewsCardTitle(data);
const metaItems = [
['媒体来源', data?.source],
['RSS 来源', data?.feedName],
['源类型', data?.feedSourceTypeLabel],
['抓取通道', data?.fetchChannelLabel],
['新闻类型', data?.categoryLabel],
['区域', data?.regionLabel],
['发布时间', data?.publishedAtDisplay],
].filter(([, value]) => String(value ?? '').trim());
const metaHtml = metaItems.length
? `<div class="info-card-news-meta-grid">${metaItems.map(([label, value]) => `
<div class="info-card-news-meta-item">
<span>${escapeInfoCardHtml(label)}</span>
<strong>${escapeInfoCardHtml(value)}</strong>
</div>
`).join('')}</div>`
: '';
content.innerHTML = `
<div class="info-card-news-layout">
<div class="info-card-news-kicker">新闻信号</div>
<div class="info-card-news-title">${escapeInfoCardHtml(title)}</div>
${metaHtml}
<div class="info-card-news-summary-shell">
<div class="info-card-news-summary-label">概要</div>
<div class="info-card-news-summary" data-news-summary></div>
@@ -305,10 +323,25 @@ function renderMobileNewsCardContent(content, data) {
if (!(content instanceof HTMLElement)) return;
const summary = getNewsCardSummary(data);
const title = getNewsCardTitle(data);
const metaItems = [
['来源', data?.source],
['RSS', data?.feedName],
['类型', data?.categoryLabel],
['区域', data?.regionLabel],
].filter(([, value]) => String(value ?? '').trim());
const metaHtml = metaItems.length
? `<div class="info-card-news-meta-grid info-card-news-meta-grid--mobile">${metaItems.map(([label, value]) => `
<div class="info-card-news-meta-item">
<span>${escapeInfoCardHtml(label)}</span>
<strong>${escapeInfoCardHtml(value)}</strong>
</div>
`).join('')}</div>`
: '';
content.innerHTML = `
<div class="earth-mobile-news-detail">
<div class="earth-mobile-news-detail-kicker">新闻信号</div>
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
${metaHtml}
<div class="earth-mobile-news-detail-summary-shell">
<div class="earth-mobile-news-detail-summary-label">概要</div>
<div class="earth-mobile-news-detail-summary" data-news-summary></div>

View File

@@ -15,6 +15,13 @@ import {
SCENE_LIGHT_CONFIG,
SURFACE_HOVER_INFO_MODES,
} from "./constants.js";
import {
canAttemptEarthRealtime,
getEarthRealtimeCooldownMs,
getEarthRealtimeUrl,
recordEarthRealtimeFailure,
recordEarthRealtimeOpen,
} from "./realtime.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
showStatusMessage,
@@ -4287,17 +4294,13 @@ async function hydrateAllSatellitesInBackground(guardFn) {
}
}
function getEarthUpdatesRealtimeUrl() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws`;
}
function scheduleEarthUpdatesReconnect() {
if (earthUpdatesReconnectTimer || destroyed) return;
const delay = Math.max(EARTH_UPDATES_RECONNECT_DELAY_MS, getEarthRealtimeCooldownMs());
earthUpdatesReconnectTimer = window.setTimeout(() => {
earthUpdatesReconnectTimer = null;
connectEarthUpdatesRealtime();
}, EARTH_UPDATES_RECONNECT_DELAY_MS);
}, delay);
}
async function refreshBGPRealtimeLayer() {
@@ -4566,10 +4569,16 @@ function startEarthDataReconciliation() {
}
function connectEarthUpdatesRealtime() {
if (earthUpdatesSocket || typeof WebSocket === "undefined" || destroyed) return;
const socket = new WebSocket(getEarthUpdatesRealtimeUrl());
if (earthUpdatesSocket || destroyed) return;
if (!canAttemptEarthRealtime()) {
scheduleEarthUpdatesReconnect();
return;
}
const socket = new WebSocket(getEarthRealtimeUrl());
earthUpdatesSocket = socket;
socket.onopen = () => {
socket.__planetOpened = true;
recordEarthRealtimeOpen();
if (earthUpdatesReconnectTimer) {
window.clearTimeout(earthUpdatesReconnectTimer);
earthUpdatesReconnectTimer = null;
@@ -4599,6 +4608,9 @@ function connectEarthUpdatesRealtime() {
if (earthUpdatesSocket === socket) {
earthUpdatesSocket = null;
}
if (!socket.__planetOpened) {
recordEarthRealtimeFailure();
}
scheduleEarthUpdatesReconnect();
};
socket.onerror = () => {

View File

@@ -16,9 +16,12 @@ import {
import {
getNewsDisplaySummary,
getNewsDisplayTitle,
getNewsCategoryLabel,
getNewsFetchChannelLabel,
getNewsFeedLabel,
getNewsLocationSourceLabel,
getNewsRegionLabel,
getNewsSourceTypeLabel,
} from "./news-locale.js";
const CRUISE_PRESENTATION_HIDE_MS = 220;
@@ -89,6 +92,9 @@ function mapNewsItemToCruiseEvent(item) {
return null;
}
const rawFeedName = item.feed_name || "";
const sourceType = item.source_type || (String(rawFeedName).startsWith("Global Monitor /") ? "aggregated" : "rss");
return {
id: `news:${item.id}`,
sourceId: item.id,
@@ -96,7 +102,11 @@ function mapNewsItemToCruiseEvent(item) {
title: getNewsDisplayTitle(item),
summary: getNewsDisplaySummary(item),
source: item.source || "",
feedName: getNewsFeedLabel(item.feed_name),
feedName: getNewsFeedLabel(rawFeedName),
rawFeedName,
feedSourceTypeLabel: getNewsSourceTypeLabel(sourceType),
fetchChannelLabel: getNewsFetchChannelLabel(rawFeedName, sourceType),
categoryLabel: getNewsCategoryLabel(item.category),
region: item.region || "global",
regionLabel: item.display_region || getNewsRegionLabel(item.region),
url: item.url || "",
@@ -275,6 +285,13 @@ export function createNewsCruiseAdapter({ camera, earth, connector, focusView })
showInfoCard("news", {
title: item.title,
summary: item.summary || item.title || "",
source: item.source,
feedName: item.rawFeedName || item.feedName,
feedSourceTypeLabel: item.feedSourceTypeLabel,
fetchChannelLabel: item.fetchChannelLabel,
categoryLabel: item.categoryLabel,
regionLabel: item.regionLabel,
publishedAtDisplay: item.publishedAtDisplay,
}, {
x: placement.x,
y: placement.y,
@@ -320,6 +337,13 @@ export function createNewsCruiseAdapter({ camera, earth, connector, focusView })
showInfoCard("news", {
title: item.title,
summary: item.summary || item.title || "",
source: item.source,
feedName: item.rawFeedName || item.feedName,
feedSourceTypeLabel: item.feedSourceTypeLabel,
fetchChannelLabel: item.fetchChannelLabel,
categoryLabel: item.categoryLabel,
regionLabel: item.regionLabel,
publishedAtDisplay: item.publishedAtDisplay,
}, {
x: placement.x,
y: placement.y,

View File

@@ -9,11 +9,33 @@ const REGION_LABELS = {
};
const FEED_LABELS = {
"Global Monitor / World": "全球监测",
"Global Monitor / Americas": "美洲监测",
"Global Monitor / Europe": "欧洲监测",
"Global Monitor / MEA": "中东与非洲监测",
"Global Monitor / APAC": "亚太监测",
"Global Monitor / World": "区域监测",
"Global Monitor / Americas": "区域监测",
"Global Monitor / Europe": "区域监测",
"Global Monitor / MEA": "区域监测",
"Global Monitor / APAC": "区域监测",
};
const CATEGORY_LABELS = {
politics: "政治",
business: "商业",
ecommerce: "电商",
finance: "金融",
sports: "体育",
technology: "科技",
military: "军事",
disaster: "灾害",
energy: "能源",
society: "社会",
culture: "文化",
other: "其他",
};
const SOURCE_TYPE_LABELS = {
rss: "RSS",
atom: "Atom",
aggregated: "Aggregated",
reference: "Reference",
};
const LOCATION_SOURCE_LABELS = {
@@ -94,10 +116,7 @@ export function isNewsContentReady(item, locale = DEFAULT_LOCALE) {
const localized = getLocalization(item, locale);
const title = normalizeText(item?.display_title || localized.title);
const summary = normalizeText(item?.display_summary || localized.summary);
return (
(item?.enrichment_status === "success" || item?.enrichment_status === "content_only")
&& Boolean(title && summary)
);
return Boolean(title && summary);
}
export function getNewsRegionLabel(region, fallback = "") {
@@ -108,10 +127,39 @@ export function getNewsFeedLabel(feedName) {
return FEED_LABELS[feedName] || feedName || "聚合源";
}
export function getNewsCategoryLabel(category) {
return CATEGORY_LABELS[category] || category || CATEGORY_LABELS.other;
}
export function getNewsSourceTypeLabel(sourceType) {
const normalized = normalizeText(sourceType).toLowerCase();
return SOURCE_TYPE_LABELS[normalized] || sourceType || "RSS";
}
export function isRegionalMonitorFeed(feedName) {
return Object.prototype.hasOwnProperty.call(FEED_LABELS, feedName);
}
export function getNewsFetchChannelLabel(feedName, sourceType = "") {
const normalized = normalizeText(sourceType).toLowerCase();
if (isRegionalMonitorFeed(feedName) || normalized === "aggregated") return "区域监测";
if (normalized === "atom") return "单源 Atom";
if (normalized === "reference") return "配置保留";
return "单源 RSS";
}
export function getNewsLocationSourceLabel(source) {
return LOCATION_SOURCE_LABELS[source] || source || "位置来源";
}
export function getNewsEnrichmentStatusLabel(status) {
export function getNewsEnrichmentStatusLabel(statusOrItem) {
const item = statusOrItem && typeof statusOrItem === "object" ? statusOrItem : null;
const status = item ? item.enrichment_status : statusOrItem;
const language = String(item?.content_language || "").toLowerCase();
if (language.startsWith("zh") && status !== "success" && status !== "content_only") {
if (status === "queued" || status === "attempted") return "英文补译中";
if (status === "provider_error" || status === "parse_error" || status === "no_result") return "中文原文";
return "中文原文";
}
return ENRICHMENT_STATUS_LABELS[status] || status || "增强状态";
}

View File

@@ -2,11 +2,20 @@ import { showStatusMessage } from "./ui.js";
import {
getNewsDisplaySummary,
getNewsDisplayTitle,
getNewsCategoryLabel,
getNewsEnrichmentStatusLabel,
getNewsFeedLabel,
getNewsFetchChannelLabel,
getNewsRegionLabel,
getNewsSourceTypeLabel,
isNewsContentReady,
} from "./news-locale.js";
import {
canAttemptEarthRealtime,
getEarthRealtimeCooldownMs,
getEarthRealtimeUrl,
recordEarthRealtimeFailure,
recordEarthRealtimeOpen,
} from "./realtime.js";
// Desktop news has two surfaces:
// - a persistent top ticker
@@ -23,6 +32,9 @@ const NEWS_HUD_MIN_WIDTH_PX = 420;
const NEWS_HUD_MIN_HEIGHT_PX = 360;
const NEWS_HUD_RESIZE_MARGIN_PX = 12;
const NEWS_REALTIME_RECONNECT_MS = 5000;
const NEWS_SUMMARY_LIMIT = 12;
const NEWS_FULL_LIMIT = 50;
const NEWS_SOURCE_FILTER_STORAGE_KEY = "planet.earth.newsSourceFilters.v1";
let initialized = false;
let refreshPromise = null;
@@ -34,6 +46,54 @@ let selectedCruiseStoryId = null;
let morphTimer = null;
let newsRealtimeSocket = null;
let newsRealtimeReconnectTimer = null;
let activeNewsCategoryFilters = null;
let lastCategorySignature = "";
let activeNewsSourceFilters = loadNewsSourceFilters();
let lastSourceSignature = "";
let newsFullListMode = false;
let activeFilterPopover = null;
const NEWS_CATEGORY_ALIASES = {
politics: ["politics", "political", "policy", "政府", "政治", "政策", "政务"],
business: ["business", "economy", "economic", "commerce", "商业", "经济", "产业", "企业"],
ecommerce: ["ecommerce", "e-commerce", "online_retail", "retail_online", "电商", "电子商务", "网上零售", "跨境电商"],
finance: ["finance", "financial", "market", "stock", "金融", "财经", "市场", "证券"],
sports: ["sports", "sport", "体育"],
technology: ["technology", "tech", "science", "科技", "科学", "技术", "ai", "人工智能"],
military: ["military", "defense", "war", "军事", "防务", "战争"],
disaster: ["disaster", "emergency", "earthquake", "flood", "storm", "灾害", "灾难", "应急", "地震", "洪水"],
energy: ["energy", "oil", "gas", "power", "能源", "石油", "天然气", "电力"],
society: ["society", "social", "社会", "民生"],
culture: ["culture", "arts", "entertainment", "文化", "艺术", "娱乐"],
other: ["other", "general", "misc", "其他", "综合"],
};
function loadNewsSourceFilters() {
try {
const raw = window.localStorage?.getItem(NEWS_SOURCE_FILTER_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed?.enabled)) return null;
return parsed.enabled.map((id) => String(id || "").trim()).filter(Boolean);
} catch {
return null;
}
}
function persistNewsSourceFilters(enabledIds) {
try {
if (!Array.isArray(enabledIds)) {
window.localStorage?.removeItem(NEWS_SOURCE_FILTER_STORAGE_KEY);
return;
}
window.localStorage?.setItem(
NEWS_SOURCE_FILTER_STORAGE_KEY,
JSON.stringify({ enabled: enabledIds }),
);
} catch {
// Ignore localStorage failures; source filters are display-only preferences.
}
}
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
@@ -53,6 +113,9 @@ function getElements() {
tickerTrack: document.getElementById("news-ticker-track"),
hud: document.getElementById("news-hud-panel"),
hudCloseBtn: document.getElementById("news-hud-close"),
filterPopovers: document.querySelectorAll("[data-news-filter-popover]"),
filterToggles: document.querySelectorAll("[data-news-filter-toggle]"),
viewAllToggles: document.querySelectorAll("#news-view-all-toggle, #mobile-news-view-all-toggle"),
};
}
@@ -196,6 +259,11 @@ function closeNewsHud() {
setNewsHudOpen(false);
}
function isNewsHudOpen() {
const { hud } = getElements();
return hud instanceof HTMLElement && !hud.classList.contains("hud-panel-hidden");
}
function setupNewsHudResize() {
const { hud } = getElements();
const container = document.getElementById("container");
@@ -298,7 +366,221 @@ function escapeNewsHtml(value) {
}
function getDisplayableNewsItems(items) {
return Array.isArray(items) ? items.filter(isNewsContentReady) : [];
return Array.isArray(items)
? items.filter(isNewsContentReady)
: [];
}
function normalizeNewsSourceType(value) {
const normalized = String(value ?? "").trim().toLowerCase();
return normalized || "";
}
function getNewsSourceDescriptor(item, sourcesByName, sourcesById) {
const feedName = String(item?.feed_name || "").trim();
const sourceName = String(item?.source || feedName || "NEWS").trim();
const sourceId = String(item?.source_id || "").trim();
const sourceConfig = sourcesById.get(sourceId) || sourcesByName.get(feedName) || null;
const sourceType = normalizeNewsSourceType(item?.source_type || sourceConfig?.source_type)
|| (feedName.startsWith("Global Monitor /") ? "aggregated" : "rss");
const sourceTypeLabel = getNewsSourceTypeLabel(sourceType);
const channelLabel = getNewsFetchChannelLabel(feedName, sourceType);
const sourceGroupName = String(sourceConfig?.name || "").trim();
const originLabel = sourceGroupName
? `${sourceGroupName} · ${channelLabel}`
: feedName && feedName !== sourceName
? `${feedName} · ${sourceTypeLabel}`
: `${channelLabel} · ${sourceTypeLabel}`;
const tooltip = [
`媒体来源:${sourceName}`,
sourceGroupName ? `来源组:${sourceGroupName}` : "",
feedName ? `RSS 来源:${feedName}` : "",
`源类型:${sourceTypeLabel}`,
`抓取通道:${channelLabel}`,
].filter(Boolean).join("\n");
return {
sourceName,
feedName,
sourceType,
sourceTypeLabel,
channelLabel,
originLabel,
tooltip,
};
}
function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
if (!filters || typeof filters !== "object") return [];
return Object.entries(filters)
.filter(([, enabled]) => enabled !== false)
.map(([key]) => key)
.filter((key) => Object.prototype.hasOwnProperty.call(NEWS_CATEGORY_ALIASES, key))
.sort();
}
function getNewsCategorySignature(filters = activeNewsCategoryFilters) {
const enabled = getEnabledNewsCategoryKeys(filters);
const total = Object.keys(NEWS_CATEGORY_ALIASES).length;
if (enabled.length === 0) return "__none__";
if (enabled.length === total) return "";
return enabled.join(",");
}
function getAvailableSourceIds(nextPayload = payload) {
return (Array.isArray(nextPayload?.sources) ? nextPayload.sources : [])
.map((source) => String(source?.id || "").trim())
.filter(Boolean)
.sort();
}
function getEnabledNewsSourceIds(nextPayload = payload) {
const available = getAvailableSourceIds(nextPayload);
if (!available.length) return [];
if (!Array.isArray(activeNewsSourceFilters)) return available;
const allowed = new Set(activeNewsSourceFilters);
return available.filter((id) => allowed.has(id));
}
function getNewsSourceSignature(nextPayload = payload) {
const available = getAvailableSourceIds(nextPayload);
const enabled = getEnabledNewsSourceIds(nextPayload);
if (available.length > 0 && enabled.length === 0) return "__none__";
if (enabled.length === available.length) return "";
return enabled.join(",");
}
function getNewsLimit() {
return newsFullListMode ? NEWS_FULL_LIMIT : NEWS_SUMMARY_LIMIT;
}
function setNewsSourceFilters(enabledIds, { persist = true } = {}) {
const available = getAvailableSourceIds();
const next = Array.isArray(enabledIds)
? enabledIds.map((id) => String(id || "").trim()).filter((id) => available.includes(id))
: null;
activeNewsSourceFilters = next && next.length === available.length ? null : next;
if (persist) persistNewsSourceFilters(activeNewsSourceFilters);
}
function summarizeSelection(enabledCount, totalCount) {
if (totalCount <= 0) return "暂无";
if (enabledCount <= 0) return "未选";
if (enabledCount === totalCount) return "全部";
return `${enabledCount}`;
}
function syncFilterSummaries(nextPayload = payload) {
const categories = getEnabledNewsCategoryKeys();
const totalCategories = Object.keys(NEWS_CATEGORY_ALIASES).length;
const sources = getAvailableSourceIds(nextPayload);
const enabledSources = getEnabledNewsSourceIds(nextPayload);
document.querySelectorAll('[data-news-filter-summary="category"]').forEach((el) => {
el.textContent = summarizeSelection(categories.length, totalCategories);
});
document.querySelectorAll('[data-news-filter-summary="source"]').forEach((el) => {
el.textContent = summarizeSelection(enabledSources.length, sources.length);
});
document.querySelectorAll('[data-news-filter-summary="limit"]').forEach((el) => {
const total = Array.isArray(nextPayload?.items) ? nextPayload.items.length : 0;
el.textContent = newsFullListMode ? "全部" : `${Math.min(NEWS_SUMMARY_LIMIT, total || NEWS_SUMMARY_LIMIT)}`;
});
document.querySelectorAll("[data-news-view-mode-label]").forEach((el) => {
el.textContent = newsFullListMode ? "返回摘要" : "查看全部";
});
}
function closeNewsFilterPopover() {
activeFilterPopover = null;
document.querySelectorAll("[data-news-filter-popover]").forEach((popover) => {
if (popover instanceof HTMLElement) popover.hidden = true;
});
document.querySelectorAll("[data-news-filter-toggle]").forEach((toggle) => {
if (toggle instanceof HTMLElement) toggle.setAttribute("aria-expanded", "false");
});
}
function renderCategoryFilterChips() {
const enabled = new Set(getEnabledNewsCategoryKeys());
return Object.keys(NEWS_CATEGORY_ALIASES)
.map((key) => `
<button
class="news-filter-chip${enabled.has(key) ? " is-active" : ""}"
type="button"
data-news-category-toggle="${escapeNewsHtml(key)}"
aria-pressed="${enabled.has(key) ? "true" : "false"}"
>${escapeNewsHtml(getNewsCategoryLabel(key))}</button>
`)
.join("");
}
function renderSourceFilterChips() {
const sources = Array.isArray(payload?.sources) ? payload.sources : [];
const enabled = new Set(getEnabledNewsSourceIds());
if (!sources.length) return `<span class="news-filter-popover__hint">暂无可筛选来源。</span>`;
return sources
.map((source) => {
const id = String(source?.id || "").trim();
if (!id) return "";
const active = enabled.has(id);
return `
<button
class="news-filter-chip${active ? " is-active" : ""}"
type="button"
data-news-source-toggle="${escapeNewsHtml(id)}"
aria-pressed="${active ? "true" : "false"}"
>${escapeNewsHtml(source?.name || id)}</button>
`;
})
.join("");
}
function renderFilterPopover(kind) {
const title = kind === "source" ? "新闻来源" : "新闻类型";
const hint = kind === "source" ? "按大来源筛选,不影响后台抓取。" : "按新闻内容分类筛选。";
const content = kind === "source" ? renderSourceFilterChips() : renderCategoryFilterChips();
activeFilterPopover = kind;
document.querySelectorAll("[data-news-filter-popover]").forEach((popover) => {
if (!(popover instanceof HTMLElement)) return;
popover.hidden = false;
popover.innerHTML = `
<div class="news-filter-popover__header">
<div class="news-filter-popover__title">${escapeNewsHtml(title)}</div>
<div class="news-filter-popover__hint">${escapeNewsHtml(hint)}</div>
</div>
<div class="news-filter-chip-group">${content}</div>
`;
});
document.querySelectorAll("[data-news-filter-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLElement)) return;
toggle.setAttribute("aria-expanded", toggle.dataset.newsFilterToggle === kind ? "true" : "false");
});
}
function toggleNewsSource(sourceId) {
const available = getAvailableSourceIds();
if (!available.includes(sourceId)) return;
const current = new Set(getEnabledNewsSourceIds());
if (current.has(sourceId)) current.delete(sourceId);
else current.add(sourceId);
setNewsSourceFilters([...current]);
syncFilterSummaries();
if (activeFilterPopover === "source") renderFilterPopover("source");
lastFetchAt = 0;
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
}
function toggleNewsCategory(category, enabled) {
window.dispatchEvent(new CustomEvent("earth:set-news-category-enabled", {
detail: { category, enabled },
}));
}
function toggleNewsListMode() {
newsFullListMode = !newsFullListMode;
syncFilterSummaries();
lastFetchAt = 0;
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
}
function renderTicker(nextPayload) {
@@ -320,7 +602,7 @@ function renderTicker(nextPayload) {
const visibleItems = getDisplayableNewsItems(items).slice(0, 6);
if (visibleItems.length === 0) {
tickerTrack.textContent = "正在等待中文新闻...";
tickerTrack.textContent = "当前新闻类型没有可显示新闻...";
tickerTrack.style.removeProperty("--news-ticker-duration");
return;
}
@@ -366,9 +648,20 @@ function renderPayload(nextPayload) {
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const displayItems = getDisplayableNewsItems(items);
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const sourcesByName = new Map(
sources
.filter((source) => source && typeof source === "object" && source.name)
.map((source) => [String(source.name), source]),
);
const sourcesById = new Map(
sources
.filter((source) => source && typeof source === "object" && source.id)
.map((source) => [String(source.id), source]),
);
const focus = nextPayload?.focus || {};
renderTicker(nextPayload);
syncFilterSummaries(nextPayload);
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
@@ -400,8 +693,11 @@ function renderPayload(nextPayload) {
focusCoords.textContent = "跟随当前视角自动聚焦";
}
sourceCount.textContent = `${sources.length} 路聚合源`;
if (nextPayload?.stale) {
const enabledSourceCount = getEnabledNewsSourceIds(nextPayload).length;
sourceCount.textContent = `${enabledSourceCount || sources.length} / ${sources.length} 路来源`;
if (displayItems.length !== items.length) {
status.textContent = `展示 ${displayItems.length} / ${items.length} 条态势新闻`;
} else if (nextPayload?.stale) {
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length}`;
} else {
status.textContent = nextPayload?.errors?.length
@@ -424,7 +720,7 @@ function renderPayload(nextPayload) {
empty.hidden = false;
empty.textContent = items.length === 0
? "当前未拉到可用新闻,请稍后刷新或切换视角区域。"
: "正在等待中文新闻内容完成处理。";
: "当前新闻类型没有可显示新闻。";
}
return;
}
@@ -440,22 +736,24 @@ function renderPayload(nextPayload) {
const summaryText = getNewsDisplaySummary(item);
const leadText = summaryText || title;
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
const feedLabel = getNewsFeedLabel(item.feed_name);
const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status);
const categoryLabel = getNewsCategoryLabel(item.category);
const statusLabel = getNewsEnrichmentStatusLabel(item);
const sourceDescriptor = getNewsSourceDescriptor(item, sourcesByName, sourcesById);
const summary = title && title !== leadText
? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
: "";
return `
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener" title="${escapeNewsHtml(sourceDescriptor.tooltip)}">
<div class="news-story-meta">
<span class="news-story-source">${escapeNewsHtml(item.source || "NEWS")}</span>
<span class="news-story-source">${escapeNewsHtml(sourceDescriptor.sourceName)}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div>
<div class="news-story-origin">${escapeNewsHtml(sourceDescriptor.originLabel)}</div>
<div class="news-story-title">${escapeNewsHtml(leadText)}</div>
${summary}
<div class="news-story-tags">
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(feedLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
</div>
</a>
@@ -472,11 +770,6 @@ function renderPayload(nextPayload) {
}));
}
function getNewsRealtimeUrl() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws`;
}
function clearNewsRealtimeReconnectTimer() {
if (!newsRealtimeReconnectTimer) return;
window.clearTimeout(newsRealtimeReconnectTimer);
@@ -485,10 +778,11 @@ function clearNewsRealtimeReconnectTimer() {
function scheduleNewsRealtimeReconnect() {
if (newsRealtimeReconnectTimer) return;
const delay = Math.max(NEWS_REALTIME_RECONNECT_MS, getEarthRealtimeCooldownMs());
newsRealtimeReconnectTimer = window.setTimeout(() => {
newsRealtimeReconnectTimer = null;
connectNewsRealtime();
}, NEWS_REALTIME_RECONNECT_MS);
}, delay);
}
function applyNewsRealtimePatch(updatePayload) {
@@ -517,10 +811,15 @@ function applyNewsRealtimePatch(updatePayload) {
}
function connectNewsRealtime() {
if (newsRealtimeSocket || typeof WebSocket === "undefined") return;
const socket = new WebSocket(getNewsRealtimeUrl());
if (newsRealtimeSocket || !canAttemptEarthRealtime()) {
scheduleNewsRealtimeReconnect();
return;
}
const socket = new WebSocket(getEarthRealtimeUrl());
newsRealtimeSocket = socket;
socket.onopen = () => {
socket.__planetOpened = true;
recordEarthRealtimeOpen();
clearNewsRealtimeReconnectTimer();
socket.send(JSON.stringify({
type: "subscribe",
@@ -547,6 +846,9 @@ function connectNewsRealtime() {
if (newsRealtimeSocket === socket) {
newsRealtimeSocket = null;
}
if (!socket.__planetOpened) {
recordEarthRealtimeFailure();
}
scheduleNewsRealtimeReconnect();
};
socket.onerror = () => {
@@ -555,9 +857,34 @@ function connectNewsRealtime() {
}
async function fetchNews(lat, lon) {
const categorySignature = getNewsCategorySignature();
const sourceSignature = getNewsSourceSignature();
if (categorySignature === "__none__" || sourceSignature === "__none__") {
return {
...(payload || {}),
generated_at: new Date().toISOString(),
focus: payload?.focus || { lat, lon, region: "global", label: "全球焦点", display_region: "全球" },
sources: payload?.sources || [],
filters: {
region: payload?.focus?.region || "global",
categories: categorySignature === "__none__" ? [] : getEnabledNewsCategoryKeys(),
sources: sourceSignature === "__none__" ? [] : getEnabledNewsSourceIds(),
limit: getNewsLimit(),
locale: "zh-CN",
},
items: [],
cruise_items: [],
errors: [],
stale: false,
};
}
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));
if (categorySignature) url.searchParams.set("categories", categorySignature);
if (sourceSignature) url.searchParams.set("sources", sourceSignature);
url.searchParams.set("limit", String(getNewsLimit()));
url.searchParams.set("locale", "zh-CN");
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -585,6 +912,8 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
.then((nextPayload) => {
renderPayload(nextPayload);
lastFetchAt = Date.now();
lastCategorySignature = getNewsCategorySignature();
lastSourceSignature = getNewsSourceSignature(nextPayload);
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
const { status } = getElements();
if (status) {
@@ -618,6 +947,8 @@ export async function refreshEarthNews({ silent = true } = {}) {
function shouldRefreshForFocus(lat, lon, region) {
const now = Date.now();
if (getNewsCategorySignature() !== lastCategorySignature) return true;
if (getNewsSourceSignature() !== lastSourceSignature) return true;
if (!lastFocus) return true;
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
lastRegionSwitchAt = now;
@@ -650,6 +981,7 @@ export function updateNewsViewFocus(coords) {
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
const region = inferRegion(coords.lat, coords.lon);
const previousRegion = lastFocus?.region || null;
const nextFocus = {
lat: coords.lat,
lon: coords.lon,
@@ -658,6 +990,9 @@ export function updateNewsViewFocus(coords) {
};
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
if (!shouldRefresh && previousRegion && region !== previousRegion) {
return;
}
lastFocus = nextFocus;
if (shouldRefresh) {
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
@@ -743,6 +1078,57 @@ export function initNewsPanel() {
openNewsHud();
});
hudCloseBtn?.addEventListener("click", closeNewsHud);
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !isNewsHudOpen()) return;
event.preventDefault();
if (activeFilterPopover) {
closeNewsFilterPopover();
return;
}
closeNewsHud();
});
document.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target) return;
const filterToggle = target.closest("[data-news-filter-toggle]");
if (filterToggle instanceof HTMLElement) {
const kind = filterToggle.dataset.newsFilterToggle || "";
if (activeFilterPopover === kind) closeNewsFilterPopover();
else renderFilterPopover(kind);
return;
}
const categoryToggle = target.closest("[data-news-category-toggle]");
if (categoryToggle instanceof HTMLElement && categoryToggle.closest("[data-news-filter-popover]")) {
const category = categoryToggle.dataset.newsCategoryToggle || "";
const active = categoryToggle.classList.contains("is-active");
toggleNewsCategory(category, !active);
return;
}
const sourceToggle = target.closest("[data-news-source-toggle]");
if (sourceToggle instanceof HTMLElement) {
toggleNewsSource(sourceToggle.dataset.newsSourceToggle || "");
return;
}
const viewAllToggle = target.closest("#news-view-all-toggle, #mobile-news-view-all-toggle");
if (viewAllToggle instanceof HTMLElement) {
toggleNewsListMode();
return;
}
if (activeFilterPopover && !target.closest("[data-news-filter-popover]")) {
closeNewsFilterPopover();
}
});
window.addEventListener("earth:news-category-filters-change", (event) => {
activeNewsCategoryFilters = event.detail?.categories || null;
syncFilterSummaries();
if (activeFilterPopover === "category") renderFilterPopover("category");
lastFetchAt = 0;
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
});
setupNewsHudResize();
connectNewsRealtime();

View File

@@ -0,0 +1,40 @@
const WS_BACKOFF_BASE_MS = 5_000;
const WS_BACKOFF_MAX_MS = 60_000;
const wsBackoffState = {
failures: 0,
nextAttemptAt: 0,
};
export function getEarthRealtimeUrl() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.hostname;
const port = window.location.port;
if ((host === "localhost" || host === "127.0.0.1") && port === "3000") {
return `${protocol}//${host}:8000/ws`;
}
return `${protocol}//${window.location.host}/ws`;
}
export function getEarthRealtimeCooldownMs() {
return Math.max(0, wsBackoffState.nextAttemptAt - Date.now());
}
export function canAttemptEarthRealtime() {
return typeof WebSocket !== "undefined" && getEarthRealtimeCooldownMs() <= 0;
}
export function recordEarthRealtimeOpen() {
wsBackoffState.failures = 0;
wsBackoffState.nextAttemptAt = 0;
}
export function recordEarthRealtimeFailure() {
wsBackoffState.failures += 1;
const delay = Math.min(
WS_BACKOFF_MAX_MS,
WS_BACKOFF_BASE_MS * 2 ** Math.min(wsBackoffState.failures - 1, 5),
);
wsBackoffState.nextAttemptAt = Date.now() + delay;
return delay;
}

View File

@@ -39,12 +39,85 @@ const TV_PANEL_MIN_HEIGHT_PX = 340;
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = {
maxNumRetry: 4,
retryDelayMs: 1500,
maxRetryDelayMs: 8000,
maxNumRetry: 1,
retryDelayMs: 1200,
maxRetryDelayMs: 3000,
backoff: "exponential",
};
const HLS_NON_PLAYBACK_ERROR_DETAILS = new Set([
"subtitleTrackLoadError",
"subtitleTrackParsingError",
"subtitleTrackSwitchError",
"audioTrackLoadError",
"audioTrackSwitchError",
"keyLoadError",
]);
const HLS_SOURCE_FAILURE_DETAILS = new Set([
"manifestLoadError",
"manifestLoadTimeOut",
"levelLoadError",
"levelLoadTimeOut",
"fragLoadError",
"fragLoadTimeOut",
]);
function isVideoActuallyPlaying(video) {
return (
video instanceof HTMLVideoElement
&& !video.paused
&& !video.ended
&& video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
}
function getHlsErrorMessage(data) {
const details = data?.details || "";
const response = data?.response || {};
const status = response?.code || response?.status;
const url = data?.url || response?.url || "";
if (details === "manifestLoadError" || details === "manifestLoadTimeOut") {
return status
? `HLS 主播放列表加载失败HTTP ${status}`
: "HLS 主播放列表加载失败";
}
if (details === "levelLoadError" || details === "levelLoadTimeOut") {
return status
? `HLS 清晰度播放列表加载失败HTTP ${status}`
: "HLS 清晰度播放列表加载失败";
}
if (details === "fragLoadError" || details === "fragLoadTimeOut") {
return status
? `HLS 分片加载失败HTTP ${status}`
: "HLS 分片加载失败";
}
if (details === "bufferStalledError") return "直播流缓冲停滞,正在等待数据";
if (details === "bufferAppendError") return "直播流缓冲写入失败";
if (details === "manifestParsingError") return "HLS 播放列表格式无法解析";
if (details === "fragParsingError") return "HLS 分片格式无法解析";
if (url) return `HLS 资源加载失败:${url}`;
return "HLS 播放流不可用";
}
function logHlsDiagnostic(data, source) {
const payload = {
source_id: source?.id,
source_name: source?.name,
type: data?.type,
details: data?.details,
fatal: Boolean(data?.fatal),
url: data?.url || data?.response?.url,
status: data?.response?.code || data?.response?.status,
};
if (data?.fatal) {
console.error("HLS 播放失败:", payload, data);
} else {
console.debug("HLS 非致命事件:", payload, data);
}
}
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
@@ -398,6 +471,8 @@ function tryStartPlayback(video) {
function attachVideoSource(video, source) {
const sourceUrl = getVideoUrl(source);
if (!(video instanceof HTMLVideoElement) || !sourceUrl) return;
let hlsNetworkErrorCount = 0;
let hlsHardFailureHandled = false;
destroyHlsPlayer();
video.autoplay = true;
@@ -414,16 +489,18 @@ function attachVideoSource(video, source) {
if (Hls.isSupported()) {
hlsPlayer = new Hls({
enableWorker: true,
enableWebVTT: false,
subtitleDisplay: false,
lowLatencyMode: false,
manifestLoadingTimeOut: 20000,
levelLoadingTimeOut: 20000,
fragLoadingTimeOut: 25000,
fragLoadingMaxRetry: 3,
fragLoadingRetryDelay: 1500,
levelLoadingMaxRetry: 3,
levelLoadingRetryDelay: 1500,
manifestLoadingMaxRetry: 2,
manifestLoadingRetryDelay: 1500,
fragLoadingMaxRetry: 1,
fragLoadingRetryDelay: 1200,
levelLoadingMaxRetry: 1,
levelLoadingRetryDelay: 1200,
manifestLoadingMaxRetry: 1,
manifestLoadingRetryDelay: 1200,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 10,
manifestLoadPolicy: {
@@ -432,11 +509,11 @@ function attachVideoSource(video, source) {
maxLoadTimeMs: 20000,
timeoutRetry: {
...HLS_RETRY_CONFIG,
maxNumRetry: 2,
maxNumRetry: 1,
},
errorRetry: {
...HLS_RETRY_CONFIG,
maxNumRetry: 2,
maxNumRetry: 1,
},
},
},
@@ -465,14 +542,35 @@ function attachVideoSource(video, source) {
tryStartPlayback(video);
});
hlsPlayer.on(Hls.Events.ERROR, (_event, data) => {
console.error("HLS 播放失败:", data);
logHlsDiagnostic(data, source);
const status = Number(data?.response?.code || data?.response?.status || 0);
const sourceFailure = HLS_SOURCE_FAILURE_DETAILS.has(data?.details);
if (sourceFailure && (status >= 500 || data?.details === "manifestLoadError")) {
hlsNetworkErrorCount += 1;
}
if (!hlsHardFailureHandled && !isVideoActuallyPlaying(video) && sourceFailure && hlsNetworkErrorCount >= 2) {
hlsHardFailureHandled = true;
const reasonMessage = getHlsErrorMessage(data);
hlsPlayer?.stopLoad();
if (!showEmbeddedFallback(source, reasonMessage) && !tryFallbackSource()) {
setPanelMessage(reasonMessage);
}
return;
}
if (
!data?.fatal
&& HLS_NON_PLAYBACK_ERROR_DETAILS.has(data?.details)
&& isVideoActuallyPlaying(video)
) {
return;
}
if (!data?.fatal) {
if (data?.type === Hls.ErrorTypes.NETWORK_ERROR) {
setPanelMessage("直播流网络波动,正在重试...");
setPanelMessage(isVideoActuallyPlaying(video) ? TV_STATUS_MESSAGE.videoReady : "直播流网络波动,正在重试...");
return;
}
if (data?.type === Hls.ErrorTypes.MEDIA_ERROR) {
setPanelMessage("直播流正在恢复...");
setPanelMessage(isVideoActuallyPlaying(video) ? TV_STATUS_MESSAGE.videoReady : "直播流正在恢复...");
return;
}
}
@@ -491,8 +589,9 @@ function attachVideoSource(video, source) {
}
}
if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
const reasonMessage = getHlsErrorMessage(data);
if (!showEmbeddedFallback(source, reasonMessage) && !tryFallbackSource()) {
setPanelMessage(reasonMessage);
}
});
return;

View File

@@ -2,6 +2,13 @@ import * as THREE from "three";
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import {
canAttemptEarthRealtime,
getEarthRealtimeCooldownMs,
getEarthRealtimeUrl,
recordEarthRealtimeFailure,
recordEarthRealtimeOpen,
} from "./realtime.js";
import { latLonToVector3 } from "./utils.js";
let showVessels = false;
@@ -105,12 +112,6 @@ function applyVesselDeltas(earth, vessels = []) {
return changed;
}
function getVesselStreamUrl() {
if (typeof window === "undefined") return "ws://localhost:8000/ws";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws`;
}
function normalizeVesselType(value, code) {
const type = String(value || "").trim().toLowerCase();
const numericCode = Number(code);
@@ -386,13 +387,19 @@ function normalizeVesselViewportOptions(options = {}) {
}
export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {}) {
if (vesselStreamSocket || typeof WebSocket === "undefined") return;
if (vesselStreamSocket || vesselStreamReconnectTimer || typeof WebSocket === "undefined") return;
const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit });
const connect = () => {
if (!showVessels || vesselStreamSocket) return;
const socket = new WebSocket(getVesselStreamUrl());
if (!canAttemptEarthRealtime()) {
vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs()));
return;
}
const socket = new WebSocket(getEarthRealtimeUrl());
vesselStreamSocket = socket;
socket.onopen = () => {
socket.__planetOpened = true;
recordEarthRealtimeOpen();
vesselRealtimeStats = {
...vesselRealtimeStats,
connected: true,
@@ -457,7 +464,10 @@ export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {})
};
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
if (showVessels) {
vesselStreamReconnectTimer = window.setTimeout(connect, 3000);
if (!socket.__planetOpened) {
recordEarthRealtimeFailure();
}
vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs()));
}
};
socket.onerror = () => {

View File

@@ -33,7 +33,7 @@ export function Select({ value, onValueChange, options, placeholder, disabled, c
{options.map((option) => (
<SelectPrimitive.Item key={option.value} value={option.value} className="an-select__item">
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemIndicator className="an-select__indicator">
<Check size={14} />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>

View File

@@ -23,6 +23,12 @@ const LOG_LEVEL_OPTIONS = [
{ value: 'debug', label: '调试' },
]
const LOG_TAIL_CHANNEL = 'logs_tail'
const LOG_VIEW_OPTIONS = [
{ value: 'grouped', label: '重复统计' },
{ value: 'raw', label: '原始日志' },
{ value: 'audit', label: '审计日志' },
]
type LogViewMode = 'grouped' | 'raw' | 'audit'
interface LogSourceSummary {
source_id: string
@@ -51,8 +57,57 @@ interface LogSnapshot {
lines: string[]
}
interface ObservabilityGroup {
fingerprint: string
source: string
service?: string | null
module?: string | null
category?: string | null
event?: string | null
level: string
message: string
detail?: string | null
affected_sources: string[]
count: number
first_seen_at?: string | null
last_seen_at?: string | null
}
interface ObservabilityEvent {
id: number
source: string
service?: string | null
module?: string | null
category?: string | null
event?: string | null
level: string
message: string
fingerprint: string
occurred_at?: string | null
request_id?: string | null
trace_id?: string | null
task_id?: string | null
source_id?: string | null
provider?: string | null
context: Record<string, unknown>
occurrence_count: number
}
interface ObservabilityGroupsResponse {
groups: ObservabilityGroup[]
line_count: number
}
interface ObservabilityGroupEventsResponse {
fingerprint: string
group: ObservabilityGroup
events: ObservabilityEvent[]
line_count: number
}
type StoredLogFilters = {
selectedSource?: string
mode?: LogViewMode
lineLimit?: number
level?: string
startDate?: string
@@ -74,8 +129,11 @@ function readStoredFilters() {
function readUrlFilters(search: string): StoredLogFilters {
const params = new URLSearchParams(search)
const lineLimit = Number(params.get('limit') || '')
const rawMode = params.get('mode')
const mode = rawMode === 'raw' || rawMode === 'audit' || rawMode === 'grouped' ? rawMode : undefined
return {
selectedSource: params.get('source') || undefined,
mode,
lineLimit: Number.isFinite(lineLimit) && lineLimit > 0 ? lineLimit : undefined,
level: params.get('level') || undefined,
startDate: params.get('start_date') || undefined,
@@ -93,6 +151,13 @@ function statusTone(status: string) {
return 'neutral'
}
function levelTone(level: string) {
if (level === 'error') return 'danger'
if (level === 'warning') return 'warning'
if (level === 'info') return 'success'
return 'neutral'
}
function statusLabel(status: string) {
if (status === 'ok') return '可用'
if (status === 'missing') return '暂无日志'
@@ -108,6 +173,23 @@ function getErrorMessage(error: unknown, fallback: string) {
return typeof detail === 'string' ? detail : fallback
}
function formatDateTime(value?: string | null) {
if (!value) return '-'
try {
return new Date(value).toLocaleString()
} catch {
return value
}
}
function stringifyContext(context: Record<string, unknown>) {
try {
return JSON.stringify(context || {}, null, 2)
} catch {
return '{}'
}
}
export default function Logs() {
const storedFilters = readStoredFilters()
const location = useLocation()
@@ -117,6 +199,7 @@ export default function Logs() {
const { toast } = useToast()
const isSuperAdmin = user?.role === 'super_admin'
const [sources, setSources] = useState<LogSourceSummary[]>([])
const [mode, setMode] = useState<LogViewMode>(urlFilters.mode || storedFilters?.mode || 'grouped')
const [selectedSource, setSelectedSource] = useState(urlFilters.selectedSource || storedFilters?.selectedSource || 'backend')
const [lineLimit, setLineLimit] = useState(urlFilters.lineLimit || storedFilters?.lineLimit || 200)
const [level, setLevel] = useState(urlFilters.level || storedFilters?.level || 'all')
@@ -126,6 +209,9 @@ export default function Logs() {
const [submittedSearch, setSubmittedSearch] = useState(urlFilters.searchQuery ?? storedFilters?.searchQuery ?? '')
const [followEnabled, setFollowEnabled] = useState(Boolean(urlFilters.follow || storedFilters?.follow))
const [snapshot, setSnapshot] = useState<LogSnapshot | null>(null)
const [groups, setGroups] = useState<ObservabilityGroup[]>([])
const [selectedGroup, setSelectedGroup] = useState<ObservabilityGroup | null>(null)
const [groupEvents, setGroupEvents] = useState<ObservabilityEvent[]>([])
const [sourcesLoading, setSourcesLoading] = useState(false)
const [logLoading, setLogLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
@@ -201,9 +287,10 @@ export default function Logs() {
const fetchSnapshot = async () => {
if (!isSuperAdmin || !selectedSource) return
const sourceForMode = mode === 'audit' ? 'audit-db' : selectedSource
setLogLoading(true)
try {
const response = await axios.get<LogSnapshot>(`/api/v1/system/logs/${encodeURIComponent(selectedSource)}`, {
const response = await axios.get<LogSnapshot>(`/api/v1/system/logs/${encodeURIComponent(sourceForMode)}`, {
params: {
limit: lineLimit,
level,
@@ -223,13 +310,55 @@ export default function Logs() {
}
}
const fetchGroups = async () => {
if (!isSuperAdmin) return
setLogLoading(true)
try {
const response = await axios.get<ObservabilityGroupsResponse>('/api/v1/system/logs/observability/groups', {
params: {
limit: lineLimit,
level,
levels: level === 'all' ? undefined : level,
start_date: startDate || undefined,
end_date: endDate || undefined,
search: submittedSearch.trim() || undefined,
},
})
setGroups(response.data.groups || [])
setErrorMessage(null)
} catch (error) {
setGroups([])
setErrorMessage(getErrorMessage(error, '加载重复日志统计失败'))
} finally {
setLogLoading(false)
}
}
const fetchGroupEvents = async (group: ObservabilityGroup) => {
setSelectedGroup(group)
try {
const response = await axios.get<ObservabilityGroupEventsResponse>(
`/api/v1/system/logs/observability/groups/${encodeURIComponent(group.fingerprint)}/events`,
{ params: { limit: lineLimit } },
)
setGroupEvents(response.data.events || [])
} catch (error) {
setGroupEvents([])
setErrorMessage(getErrorMessage(error, '加载重复日志详情失败'))
}
}
useEffect(() => {
void fetchSources()
}, [isSuperAdmin])
useEffect(() => {
if (!followEnabled) void fetchSnapshot()
}, [isSuperAdmin, selectedSource, lineLimit, level, startDate, endDate, submittedSearch, followEnabled])
if (mode === 'grouped') {
void fetchGroups()
} else if (!followEnabled) {
void fetchSnapshot()
}
}, [isSuperAdmin, selectedSource, lineLimit, level, startDate, endDate, submittedSearch, followEnabled, mode])
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -242,6 +371,7 @@ export default function Logs() {
if (typeof window === 'undefined') return
window.localStorage.setItem(LOG_FILTER_STORAGE_KEY, JSON.stringify({
selectedSource,
mode,
lineLimit,
level,
startDate,
@@ -249,10 +379,11 @@ export default function Logs() {
searchQuery: submittedSearch,
follow: followEnabled,
}))
}, [endDate, followEnabled, level, lineLimit, selectedSource, startDate, submittedSearch])
}, [endDate, followEnabled, level, lineLimit, mode, selectedSource, startDate, submittedSearch])
useEffect(() => {
const filters = readUrlFilters(location.search)
if (filters.mode && filters.mode !== mode) setMode(filters.mode)
if (filters.selectedSource && filters.selectedSource !== selectedSource) setSelectedSource(filters.selectedSource)
if (filters.lineLimit && filters.lineLimit !== lineLimit) setLineLimit(filters.lineLimit)
if (filters.level && filters.level !== level) setLevel(filters.level)
@@ -270,6 +401,7 @@ export default function Logs() {
useEffect(() => {
if (typeof window === 'undefined') return
const params = new URLSearchParams()
if (mode !== 'grouped') params.set('mode', mode)
if (selectedSource && selectedSource !== 'backend') params.set('source', selectedSource)
if (lineLimit !== 200) params.set('limit', String(lineLimit))
if (level !== 'all') params.set('level', level)
@@ -282,7 +414,7 @@ export default function Logs() {
if (nextSearch !== currentSearch) {
navigate({ pathname: location.pathname, search: nextSearch ? `?${nextSearch}` : '' }, { replace: true })
}
}, [endDate, followEnabled, level, lineLimit, location.pathname, location.search, navigate, selectedSource, startDate, submittedSearch])
}, [endDate, followEnabled, level, lineLimit, location.pathname, location.search, mode, navigate, selectedSource, startDate, submittedSearch])
useEffect(() => {
if (!followEnabled || !followConnected || !isSuperAdmin || !selectedSource) return
@@ -332,7 +464,10 @@ export default function Logs() {
}
const copyLogs = async () => {
await navigator.clipboard.writeText(snapshot?.lines?.join('\n') || '')
const text = mode === 'grouped'
? groupEvents.map((event) => `${event.occurred_at || ''} ${event.level.toUpperCase()} ${event.message} ${stringifyContext(event.context)}`).join('\n')
: snapshot?.lines?.join('\n') || ''
await navigator.clipboard.writeText(text)
toast({ tone: 'success', title: '日志已复制' })
}
@@ -353,35 +488,78 @@ export default function Logs() {
<PageFrame
title="系统日志"
description="查看日志源、按级别/日期/搜索条件读取快照,并复制原始输出。"
className="an-logs-page"
actions={(
<>
<Button size="icon" variant="subtle" onClick={() => void fetchSources()} loading={sourcesLoading} aria-label="刷新日志源" title="刷新日志源">
<RefreshCw size={15} />
</Button>
<Button
size="icon"
variant={followEnabled ? 'primary' : 'subtle'}
onClick={() => setFollowEnabled((enabled) => !enabled)}
aria-label={followEnabled ? '暂停日志跟随' : '跟随日志'}
title={followEnabled ? '暂停日志跟随' : '跟随日志'}
>
{followEnabled ? <Pause size={15} /> : <Play size={15} />}
</Button>
<Button size="icon" variant="primary" onClick={() => void fetchSnapshot()} loading={logLoading} aria-label="刷新日志" title="刷新日志">
{mode === 'raw' ? (
<Button
size="icon"
variant={followEnabled ? 'primary' : 'subtle'}
onClick={() => setFollowEnabled((enabled) => !enabled)}
aria-label={followEnabled ? '暂停日志跟随' : '跟随日志'}
title={followEnabled ? '暂停日志跟随' : '跟随日志'}
>
{followEnabled ? <Pause size={15} /> : <Play size={15} />}
</Button>
) : null}
<Button size="icon" variant="primary" onClick={() => mode === 'grouped' ? void fetchGroups() : void fetchSnapshot()} loading={logLoading} aria-label="刷新日志" title="刷新日志">
<RefreshCw size={15} />
</Button>
</>
)}
>
<div className="an-section-tabs" role="tablist" aria-label="日志视图">
{LOG_VIEW_OPTIONS.map((option) => {
const active = mode === option.value
return (
<button
key={option.value}
type="button"
role="tab"
aria-selected={active}
className={active ? 'an-section-tab is-active' : 'an-section-tab'}
onClick={() => {
setMode(option.value as LogViewMode)
setFollowEnabled(false)
setSelectedGroup(null)
setGroupEvents([])
}}
>
<span className={active ? 'an-section-tab__dot an-section-tab__dot--success' : 'an-section-tab__dot an-section-tab__dot--neutral'} />
<span>{option.label}</span>
</button>
)
})}
</div>
<div className="an-logs-layout">
<Panel className="an-logs-sidebar">
<div className="an-panel-heading">
<div>
<h2></h2>
<p>{sources.length} </p>
<h2>{mode === 'grouped' ? '重复统计' : mode === 'audit' ? '审计来源' : '日志源'}</h2>
<p>{mode === 'grouped' ? `${groups.length} 个聚合项` : `${sources.length} 个来源`}</p>
</div>
</div>
<Scrollbar className="an-logs-source-list">
{mode === 'grouped' ? (
<Scrollbar className="an-logs-source-list">
{groups.map((group) => (
<button
type="button"
key={group.fingerprint}
className={selectedGroup?.fingerprint === group.fingerprint ? 'an-logs-source is-active' : 'an-logs-source'}
onClick={() => void fetchGroupEvents(group)}
>
<strong>{group.message}</strong>
<span>{group.category || group.event || group.module || group.source}</span>
<StatusText tone={levelTone(group.level)}>{group.count} </StatusText>
</button>
))}
{!groups.length && !logLoading ? <EmptyState title="暂无重复日志" /> : null}
</Scrollbar>
) : (
<Scrollbar className="an-logs-source-list">
{sources.map((source) => (
<button
type="button"
@@ -395,19 +573,20 @@ export default function Logs() {
</button>
))}
{!sources.length && !sourcesLoading ? <EmptyState title="暂无日志源" /> : null}
</Scrollbar>
</Scrollbar>
)}
</Panel>
<Panel className="an-logs-main">
<div className="an-panel-heading">
<div>
<h2>{selectedSourceInfo?.name || selectedSource}</h2>
<p>{selectedSourceInfo?.description || '选择日志源后读取快照。'}</p>
<h2>{mode === 'grouped' ? (selectedGroup ? '重复日志详情' : '重复日志统计') : mode === 'audit' ? '审计事件' : (selectedSourceInfo?.name || selectedSource)}</h2>
<p>{mode === 'grouped' ? '点击左侧聚合项查看每次发生时间。' : mode === 'audit' ? '管理员敏感操作和安全审计记录。' : (selectedSourceInfo?.description || '选择日志源后读取快照。')}</p>
</div>
<div className="an-toolbar">
{snapshot ? <Badge tone="blue">{snapshot.line_count} </Badge> : null}
{mode === 'grouped' ? <Badge tone="blue">{selectedGroup ? groupEvents.length : groups.length} </Badge> : snapshot ? <Badge tone="blue">{snapshot.line_count} </Badge> : null}
{followEnabled ? <Badge tone={followConnected ? 'green' : 'amber'}>{followConnected ? '跟随中' : '连接中'}</Badge> : null}
<Button size="icon" variant="subtle" onClick={copyLogs} disabled={!snapshot?.lines?.length} aria-label="复制日志" title="复制日志">
<Button size="icon" variant="subtle" onClick={copyLogs} disabled={mode === 'grouped' ? !groupEvents.length : !snapshot?.lines?.length} aria-label="复制日志" title="复制日志">
<Copy size={15} />
</Button>
</div>
@@ -439,6 +618,30 @@ export default function Logs() {
<div className="an-log-reader">
{logLoading ? (
<div className="an-loading"><span className="an-spinner" /></div>
) : mode === 'grouped' && selectedGroup ? (
<Scrollbar className="an-log-reader__scroll" viewportRef={scrollContainerRef}>
<div className="an-log-group-detail">
<div className="an-log-group-summary">
<strong>{selectedGroup.message}</strong>
<span> {selectedGroup.fingerprint}</span>
<span> {formatDateTime(selectedGroup.first_seen_at)} · {formatDateTime(selectedGroup.last_seen_at)}</span>
</div>
{groupEvents.map((event) => (
<div className="an-log-occurrence" key={event.id}>
<div>
<strong>{formatDateTime(event.occurred_at)}</strong>
<StatusText tone={levelTone(event.level)}>{event.level}</StatusText>
{event.occurrence_count > 1 ? <Badge tone="blue">{event.occurrence_count} </Badge> : null}
</div>
<p>{event.message}</p>
<pre>{stringifyContext(event.context)}</pre>
</div>
))}
{!groupEvents.length ? <EmptyState title="暂无发生明细" /> : null}
</div>
</Scrollbar>
) : mode === 'grouped' ? (
<EmptyState title="选择一组重复日志" description="左侧展示按 fingerprint 聚合后的运行时错误。" />
) : snapshot?.lines?.length ? (
<Scrollbar className="an-log-reader__scroll" viewportRef={scrollContainerRef}>
<pre>{snapshot.lines.join('\n')}</pre>

View File

@@ -24,7 +24,6 @@ import {
EyeOff,
Sparkles,
Square,
TestTube2,
Trash2,
X,
} from 'lucide-react'
@@ -789,6 +788,22 @@ type HierarchyGroup = {
type TvSourceKind = 'builtin' | 'collected' | 'custom'
const DEFAULT_TV_SOURCE_ID = 'cgtn-en'
const NEWS_FETCHABLE_SOURCE_TYPES = new Set(['rss', 'atom', 'aggregated'])
const NEWS_SOURCE_TYPE_OPTIONS = [
{ value: 'rss', label: 'RSS' },
{ value: 'atom', label: 'Atom' },
{ value: 'aggregated', label: 'Aggregated' },
{ value: 'reference', label: '参考链接' },
]
const NEWS_REGION_OPTIONS = [
{ value: 'global', label: '全球' },
{ value: 'china', label: '中国' },
{ value: 'us', label: '美国' },
{ value: 'americas', label: '美洲' },
{ value: 'europe', label: '欧洲' },
{ value: 'asia-pacific', label: '亚太' },
{ value: 'middle-east-africa', label: '中东与非洲' },
]
type FieldConfig = {
key: string
@@ -821,6 +836,16 @@ const fieldLabels: Record<string, string> = {
embed_url: '嵌入地址',
stream_url: '播放流地址',
homepage_url: '主页地址',
feed_directory_url: 'Feed 信息页',
feed_url: 'Feed 地址',
source_tags_text: '源属性标签',
default_category: '默认类型',
importance_weight: '重要度权重',
fetch_interval_minutes: '抓取间隔(分钟)',
timeout_seconds: '超时(秒)',
failure_threshold: '失败阈值',
cooldown_minutes: '熔断冷却(分钟)',
circuit_breaker: '熔断开关',
poster_url: '封面地址',
youtube_video_id: 'YouTube 视频 ID',
youtube_channel: 'YouTube 频道',
@@ -882,7 +907,6 @@ const fieldLabels: Record<string, string> = {
client_secret: 'Client Secret',
max_tokens: '最大 Token',
anthropic_version: 'Anthropic 版本',
timeout_seconds: '超时(秒)',
retry_attempts: '重试次数',
providers: '供应商配置',
models: '模型列表',
@@ -1287,7 +1311,7 @@ function snapshotOptionLabel(record: AnyRecord) {
const emptyRows = () => []
const placeholderSectionKeys = new Set(['basemap', 'layer_resources', 'models_3d', 'news_anchor_strategy'])
const placeholderSectionKeys = new Set(['basemap', 'layer_resources', 'models_3d'])
function isPlaceholderSection(key: string) {
return placeholderSectionKeys.has(key)
@@ -1420,6 +1444,261 @@ function makeNewTvSourceGroup(index: number): HierarchyGroup {
}
}
function newsSourcesPayload(raw: unknown): AnyRecord {
if (!isObjectRecord(raw)) return { sources: [], source_tags: [], categories: [], item_tag_rules: [], health: {} }
return {
...raw,
sources: Array.isArray(raw.sources) ? raw.sources.filter(isObjectRecord) : [],
source_tags: Array.isArray(raw.source_tags) ? raw.source_tags.filter(isObjectRecord) : [],
categories: Array.isArray(raw.categories) ? raw.categories.filter(isObjectRecord) : [],
item_tag_rules: Array.isArray(raw.item_tag_rules) ? raw.item_tag_rules.filter(isObjectRecord) : [],
health: isObjectRecord(raw.health) ? raw.health : {},
}
}
function newsSourceType(source: AnyRecord) {
return text(source.source_type, 'rss').toLowerCase()
}
function newsSourceStatusLabel(source: AnyRecord) {
if (source.__isDraft) return '新建'
const type = newsSourceType(source)
if (type === 'reference') return '参考'
if (source.enabled === false) return '停用'
return '启用'
}
function newsSourceHealthLabel(health: AnyRecord) {
const status = text(health.status, '')
if (!status) return '未测试'
if (status === 'ok') return '连通正常'
if (status === 'empty') return '无条目'
if (status === 'format_error') return '格式错误'
if (status === 'http_error') return 'HTTP 失败'
if (status === 'timeout') return '超时'
if (status === 'reference') return '参考链接'
return '连接失败'
}
function newsSourceHealthDescription(health: AnyRecord) {
if (!isObjectRecord(health) || !text(health.status, '')) return '尚未测试当前源。'
const count = Number(health.item_count ?? health.count ?? 0)
const statusCode = health.status_code ? `HTTP ${health.status_code}` : ''
const latency = health.latency_ms ? `${health.latency_ms}ms` : ''
const details = [newsSourceHealthLabel(health), statusCode, latency, Number.isFinite(count) ? `${count}` : ''].filter(Boolean).join(' · ')
return text(health.error, '') ? `${details}${text(health.error, '')}` : details
}
function newsSourceToEditor(source: AnyRecord, health: AnyRecord = {}) {
const policy = isObjectRecord(source.health_policy) ? source.health_policy : {}
const feedUrls = Array.isArray(source.feed_urls)
? source.feed_urls.map((url) => String(url).trim()).filter(Boolean)
: text(source.feed_url, '').split(/[\n,]/).map((url) => url.trim()).filter(Boolean)
const rawFeeds = Array.isArray(source.feeds) ? source.feeds.filter(isObjectRecord) : []
const feeds = rawFeeds.length
? rawFeeds.map((feed, index) => ({
id: text(feed.id, `feed-${index + 1}`),
name: text(feed.name, `Feed ${index + 1}`),
url: text(feed.url || feed.feed_url, ''),
type: text(feed.type || feed.source_type, newsSourceType(source)),
enabled: feed.enabled !== false,
default_category: text(feed.default_category, text(source.default_category, 'business')),
tags_text: Array.isArray(feed.tags) ? feed.tags.map((tag) => String(tag)).join(', ') : text(feed.tags, ''),
priority: Number(feed.priority ?? index + 1),
}))
: feedUrls.map((url, index) => ({
id: `feed-${index + 1}`,
name: feedUrls.length === 1 ? text(source.name, `Feed ${index + 1}`) : `Feed ${index + 1}`,
url,
type: newsSourceType(source),
enabled: source.enabled !== false,
default_category: text(source.default_category, 'business'),
tags_text: '',
priority: index + 1,
}))
return {
...source,
region: text(source.region, 'global'),
source_type: newsSourceType(source),
enabled: source.enabled !== false,
feed_urls_text: feedUrls.length ? feedUrls.join('\n') : text(source.feed_url, ''),
feeds,
default_category: text(source.default_category, 'business'),
source_tags_text: Array.isArray(source.source_tags) ? source.source_tags.map((tag) => String(tag)).join(', ') : '',
importance_weight: Number(source.importance_weight ?? 0),
fetch_interval_minutes: Number(policy.fetch_interval_minutes ?? source.fetch_interval_minutes ?? 45),
timeout_seconds: Number(policy.timeout_seconds ?? source.timeout_seconds ?? 12),
failure_threshold: Number(policy.failure_threshold ?? source.failure_threshold ?? 3),
cooldown_minutes: Number(policy.cooldown_minutes ?? source.cooldown_minutes ?? 30),
circuit_breaker: Boolean(policy.circuit_breaker ?? source.circuit_breaker ?? true),
__module: '新闻源',
__status: newsSourceStatusLabel(source),
__health: health,
__healthLabel: newsSourceHealthLabel(health),
__title: pick(source, ['name', 'id'], '新闻源'),
}
}
function newsSourceFromEditor(record: AnyRecord) {
const next = cleanRecord(record)
const rawFeeds = Array.isArray(next.feeds) ? next.feeds.filter(isObjectRecord) : []
const feeds = rawFeeds
.map((feed, index) => {
const url = text(feed.url || feed.feed_url, '').trim()
if (!url) return null
return {
id: text(feed.id, `feed-${index + 1}`),
name: text(feed.name, `Feed ${index + 1}`),
url,
type: text(feed.type || feed.source_type, text(next.source_type, 'rss')),
enabled: feed.enabled !== false,
default_category: text(feed.default_category, text(next.default_category, 'business')),
tags: text(feed.tags_text || feed.tags, '').split(/[,\n]/).map((item) => item.trim()).filter(Boolean),
priority: Number(feed.priority || index + 1),
}
})
.filter(isObjectRecord) as AnyRecord[]
const tags = text(next.source_tags_text, '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
next.source_tags = tags
next.feeds = feeds
const nextFeedUrls = feeds.map((feed) => text(feed.url, '')).filter(Boolean)
next.feed_urls = nextFeedUrls
next.feed_url = nextFeedUrls[0] || text(next.feed_url, '')
next.health_policy = {
fetch_interval_minutes: Number(next.fetch_interval_minutes || 45),
timeout_seconds: Number(next.timeout_seconds || 12),
failure_threshold: Number(next.failure_threshold || 3),
cooldown_minutes: Number(next.cooldown_minutes || 30),
circuit_breaker: Boolean(next.circuit_breaker),
}
delete next.source_tags_text
delete next.feed_urls_text
delete next.fetch_interval_minutes
delete next.timeout_seconds
delete next.failure_threshold
delete next.cooldown_minutes
delete next.circuit_breaker
delete next.__isDraft
return next
}
function newsSourceGroup(source: AnyRecord, healthPayload: AnyRecord = {}): HierarchyGroup {
const maybeHealth = healthPayload[text(source.id, '')]
const health: AnyRecord = isObjectRecord(maybeHealth) ? maybeHealth : {}
const editor: AnyRecord = newsSourceToEditor(source, health)
return {
key: `news-source:${text(editor.id, text(editor.name, crypto.randomUUID()))}`,
label: pick(editor, ['name', 'id'], '新闻源'),
description: [text(editor.source_type, '').toUpperCase(), text(editor.region, ''), text(editor.default_category, ''), text(editor.__healthLabel, '')].filter(Boolean).join(' · '),
status: newsSourceStatusLabel(editor),
count: Array.isArray(editor.source_tags) ? editor.source_tags.length : text(editor.source_tags_text, '').split(/[,\n]/).filter(Boolean).length,
record: editor,
}
}
function makeNewNewsSourceGroup(index: number): HierarchyGroup {
return newsSourceGroup({
id: `custom-news-${Date.now()}`,
name: `新增新闻源 ${index}`,
region: 'global',
feed_url: '',
homepage_url: '',
feed_directory_url: '',
source_type: 'rss',
enabled: true,
default_category: 'business',
source_tags: ['business_news'],
importance_weight: 0,
health_policy: {
fetch_interval_minutes: 45,
timeout_seconds: 12,
failure_threshold: 3,
cooldown_minutes: 30,
circuit_breaker: true,
},
__isDraft: true,
})
}
function newsCategoryOptions(payload: AnyRecord) {
const categories = arrayAt(payload, 'categories')
const options = categories
.map((category) => ({ value: text(category.key || category.id, ''), label: text(category.label || category.name || category.key, '') }))
.filter((option) => option.value && option.label)
return options.length ? options : [
{ value: 'politics', label: '政治' },
{ value: 'business', label: '商业' },
{ value: 'ecommerce', label: '电商' },
{ value: 'finance', label: '金融' },
{ value: 'technology', label: '科技' },
{ value: 'other', label: '其他' },
]
}
function newsTagOptions(payload: AnyRecord) {
return arrayAt(payload, 'source_tags')
.map((tag) => ({ value: text(tag.key || tag.id, ''), label: text(tag.label || tag.name || tag.key, '') }))
.filter((option) => option.value && option.label)
}
function newsSourceValidationError(source: AnyRecord, existingSources: AnyRecord[], oldId = '') {
const id = text(source.id, '').trim()
const name = text(source.name, '').trim()
const type = newsSourceType(source)
if (!id) return '新闻源 ID 不能为空。'
if (!name) return '新闻源名称不能为空。'
if (existingSources.some((item) => text(item.id, '') === id && text(item.id, '') !== oldId)) return `新闻源 ID "${id}" 已存在。`
if (source.enabled !== false && !NEWS_FETCHABLE_SOURCE_TYPES.has(type)) return '参考链接不能启用抓取,请改为 RSS/Atom/Aggregated 或关闭启用。'
const feedUrls = Array.isArray(source.feed_urls) ? source.feed_urls.filter(Boolean) : []
const feeds = Array.isArray(source.feeds) ? source.feeds.filter(isObjectRecord).filter((feed) => text(feed.url || feed.feed_url, '').trim()) : []
if (NEWS_FETCHABLE_SOURCE_TYPES.has(type) && !feeds.length && !feedUrls.length && !text(source.feed_url || source.url, '').trim()) return 'RSS/Atom/Aggregated 新闻源需要至少一个 Feed 子项。'
return ''
}
function updateNewsFeedDraft(
draft: string,
fallback: AnyRecord,
index: number,
key: string,
value: unknown,
) {
const current = draftRecord(draft, fallback)
const feeds = Array.isArray(current.feeds) ? current.feeds.filter(isObjectRecord).map((feed) => ({ ...feed })) : []
feeds[index] = { ...(feeds[index] || {}), [key]: value }
return JSON.stringify({ ...current, feeds }, null, 2)
}
function removeNewsFeedDraft(draft: string, fallback: AnyRecord, index: number) {
const current = draftRecord(draft, fallback)
const feeds = Array.isArray(current.feeds) ? current.feeds.filter(isObjectRecord).filter((_, feedIndex) => feedIndex !== index) : []
return JSON.stringify({ ...current, feeds }, null, 2)
}
function addNewsFeedDraft(draft: string, fallback: AnyRecord) {
const current = draftRecord(draft, fallback)
const feeds = Array.isArray(current.feeds) ? current.feeds.filter(isObjectRecord) : []
const index = feeds.length + 1
return JSON.stringify({
...current,
feeds: [
...feeds,
{
id: `feed-${index}`,
name: `Feed ${index}`,
url: '',
type: text(current.source_type, 'rss'),
enabled: true,
default_category: text(current.default_category, 'business'),
tags_text: '',
priority: index,
},
],
}, null, 2)
}
function makeNewDatasourceConfigGroup(index: number): HierarchyGroup {
const name = `custom_collector_${index}`
return {
@@ -1849,6 +2128,7 @@ function FieldGrid({
>
{(field.options || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
{field.help ? <small>{field.help}</small> : null}
</label>
)
}
@@ -1870,6 +2150,7 @@ function FieldGrid({
}}
spellCheck={false}
/>
{field.help ? <small>{field.help}</small> : null}
</label>
)
}
@@ -1906,6 +2187,7 @@ function FieldGrid({
{field.secretVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{field.help ? <small>{field.help}</small> : null}
</label>
)
}
@@ -1938,16 +2220,19 @@ function GroupList({
activeKey,
onSelect,
footer,
header,
renderGroupControl,
}: {
groups: HierarchyGroup[]
activeKey: string
onSelect: (group: HierarchyGroup) => void
footer?: ReactNode
header?: ReactNode
renderGroupControl?: (group: HierarchyGroup) => ReactNode
}) {
return (
<Panel className="an-hierarchy-list">
{header ? <div className="an-hierarchy-list__header">{header}</div> : null}
<Scrollbar className="an-hierarchy-list__scroll">
<div className="an-hierarchy-list__items">
{groups.map((group) => {
@@ -2287,6 +2572,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [activeGroupKey, setActiveGroupKey] = useState('')
const [hierarchyDraft, setHierarchyDraft] = useState('')
const [tvDraftGroup, setTvDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsDraftGroup, setNewsDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsFilters, setNewsFilters] = useState({ status: 'all', sourceType: 'all', region: 'all', tag: 'all' })
const [collectionDraftGroup, setCollectionDraftGroup] = useState<HierarchyGroup | null>(null)
const [snapshotSelectionBySource, setSnapshotSelectionBySource] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
@@ -2961,6 +3248,79 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
}
}
const testEarthNewsSource = async (record: AnyRecord) => {
const payload = stripInternalFields(record)
const sources = Array.isArray(payload.sources) ? payload.sources.filter(isObjectRecord) : []
const source = sources.find((item) => {
const sourceType = text(item.source_type, 'rss')
return item.enabled !== false && ['rss', 'atom', 'aggregated'].includes(sourceType)
}) || sources[0]
if (!source) {
toast({ title: '没有可测试的新闻源', description: '当前新闻源配置里没有 sources。' })
return
}
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-sources/test'), { source })
const result = response.data as AnyRecord
const count = Number(result.count || 0)
if (result.ok) {
toast({
title: '新闻源测试通过',
description: `${pick(source, ['name', 'id'], '新闻源')} 返回 ${Number.isFinite(count) ? count : 0} 条内容。`,
tone: 'success',
})
} else {
const health = isObjectRecord(result.health) ? result.health : {}
toast({
title: '新闻源测试失败',
description: newsSourceHealthDescription(health) || text(result.error, '该来源暂不可按 RSS/Atom 抓取。'),
tone: 'error',
})
}
await load(activeSectionKey)
} catch (error) {
toast({ title: '新闻源测试失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const testSingleEarthNewsSource = async (record: AnyRecord) => {
const source = newsSourceFromEditor(record)
const sourceType = newsSourceType(source)
if (!NEWS_FETCHABLE_SOURCE_TYPES.has(sourceType)) {
toast({
title: '参考链接不参与抓取',
description: '当前来源只作为官网、报告页或未来采集器线索保留,不会执行 RSS/Atom 测试。',
})
return
}
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-sources/test'), { source })
const result = response.data as AnyRecord
const count = Number(result.count || 0)
if (result.ok) {
toast({
title: '新闻源测试通过',
description: `${pick(source, ['name', 'id'], '新闻源')} 返回 ${Number.isFinite(count) ? count : 0} 条内容。`,
tone: 'success',
})
} else {
toast({
title: '新闻源测试失败',
description: text(result.error, '该来源暂不可按 RSS/Atom 抓取。'),
tone: 'error',
})
}
} catch (error) {
toast({ title: '新闻源测试失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const testDatasourceConfigDraft = async (record: AnyRecord, options: { builtin?: boolean } = {}) => {
const payload = normalizeDatasourceConfigPayload(stripInternalFields(record))
await testConnection(
@@ -3462,6 +3822,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
await requestAction('保存 TV 配置', 'put', '/settings/tv', payload)
return
}
if (config === configs.earthContent && endpointKey === 'newsSources') {
await requestAction('保存新闻源配置', 'put', '/earth/news-sources', payload)
return
}
if (config === configs.collection && id && /configs/.test(endpointKey)) {
await requestAction('保存采集器配置', 'put', `/datasources/configs/${encodeURIComponent(id)}`, isObjectRecord(payload) ? normalizeDatasourceConfigPayload(payload) : payload)
return
@@ -4526,6 +4890,29 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
})))
if (tvDraftGroup) groups.push(tvDraftGroup)
}
const newsPayload = config === configs.earthContent && activeSection.key === 'news_sources'
? newsSourcesPayload(activeState.raw)
: {}
if (config === configs.earthContent && activeSection.key === 'news_sources') {
const matchesNewsFilter = (group: HierarchyGroup) => {
const source = group.record
const type = newsSourceType(source)
const tags = text(source.source_tags_text, '').split(/[,\n]/).map((tag) => tag.trim()).filter(Boolean)
const status = newsSourceStatusLabel(source)
if (newsFilters.status !== 'all') {
if (newsFilters.status === 'enabled' && status !== '启用') return false
if (newsFilters.status === 'disabled' && status !== '停用') return false
if (newsFilters.status === 'reference' && type !== 'reference') return false
}
if (newsFilters.sourceType !== 'all' && type !== newsFilters.sourceType) return false
if (newsFilters.region !== 'all' && text(source.region, '') !== newsFilters.region) return false
if (newsFilters.tag !== 'all' && !tags.includes(newsFilters.tag)) return false
return true
}
const newsHealthPayload = isObjectRecord(newsPayload.health) ? newsPayload.health : {}
groups = sortGroupsByStatus(arrayAt(newsPayload, 'sources').filter(isObjectRecord).map((source) => newsSourceGroup(source, newsHealthPayload))).filter(matchesNewsFilter)
if (newsDraftGroup) groups.push(newsDraftGroup)
}
if (config === configs.collection && activeSection.key === 'collection_history') {
groups = activeState.rows.map((row) => ({
key: row.__rowId,
@@ -4624,7 +5011,100 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const objectFields = Object.entries(activeGroup?.record || {})
.filter(([key, value]) => !key.startsWith('__') && !shouldHideDatasourceConfigObjectField(activeGroup?.record || {}, activeSourceEndpoint, key) && value !== null && typeof value === 'object')
.map(([key]): FieldConfig => ({ key, label: fieldLabel(key), type: 'textarea', wide: true }))
const fields = [...scalarFields, ...objectFields]
const newsSourceFields: FieldConfig[] = config === configs.earthContent && activeSection.key === 'news_sources' ? [
{ key: 'id', label: '源 ID' },
{ key: 'name', label: '源名称' },
{ key: 'region', label: '区域', type: 'select', options: NEWS_REGION_OPTIONS },
{ key: 'source_type', label: '源类型', type: 'select', options: NEWS_SOURCE_TYPE_OPTIONS },
{ key: 'enabled', label: '启用抓取', type: 'boolean' },
{ key: 'homepage_url', label: '主页地址', help: '来源官网、栏目页或报告页,不作为抓取入口。' },
{ key: 'feed_directory_url', label: 'Feed 信息页', help: 'RSS 订阅中心或 Feed 聚合页,只用于人工查看,不参与抓取。' },
{ key: 'source_tags_text', label: '源属性标签', wide: true, help: '描述来源属性,不是媒体来源名;多个标签用逗号分隔,例如 business_news, ecommerce, china。' },
{ key: 'importance_weight', label: '重要度权重', type: 'number' },
{ key: 'fetch_interval_minutes', label: '抓取间隔(分钟)', type: 'number' },
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number' },
{ key: 'failure_threshold', label: '失败阈值', type: 'number' },
{ key: 'cooldown_minutes', label: '熔断冷却(分钟)', type: 'number' },
{ key: 'circuit_breaker', label: '熔断开关', type: 'boolean' },
] : []
const fields = newsSourceFields.length ? newsSourceFields : [...scalarFields, ...objectFields]
const renderNewsFeedEditor = () => {
if (!activeGroup) return null
const currentSource = draftRecord(hierarchyDraft, activeGroup.record)
const feeds = Array.isArray(currentSource.feeds) ? currentSource.feeds.filter(isObjectRecord) : []
const categories = newsCategoryOptions(newsPayload)
return (
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>Feed </h3>
<p> RSS/Atom/Aggregated </p>
</div>
<div className="an-news-feed-list">
{feeds.map((feed, index) => (
<div className="an-news-feed-card" key={`${text(feed.id, 'feed')}-${index}`}>
<div className="an-news-feed-card__header">
<strong>{text(feed.name, `Feed ${index + 1}`)}</strong>
<div className="an-news-feed-card__actions">
<AdminSwitch
checked={feed.enabled !== false}
label={feed.enabled === false ? '启用 Feed' : '停用 Feed'}
onCheckedChange={(next) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'enabled', next))}
/>
<Button
size="icon"
variant="subtle"
icon="test"
title="测试当前 Feed"
aria-label="测试当前 Feed"
onClick={() => void testSingleEarthNewsSource({ ...currentSource, feeds: [feed], feed_url: text(feed.url, ''), feed_urls: [text(feed.url, '')] })}
/>
<Button size="icon" variant="subtle" title="删除 Feed 子项" aria-label="删除 Feed 子项" onClick={() => setHierarchyDraft(removeNewsFeedDraft(hierarchyDraft, activeGroup.record, index))}>
<Trash2 size={14} />
</Button>
</div>
</div>
<div className="an-field-grid">
<label className="an-field">
<span>Feed ID</span>
<input className="an-input" value={text(feed.id, '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'id', event.target.value))} />
</label>
<label className="an-field">
<span>Feed </span>
<input className="an-input" value={text(feed.name, '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'name', event.target.value))} />
</label>
<label className="an-field an-field--wide">
<span>Feed </span>
<input className="an-input" value={text(feed.url, '')} placeholder="https://example.com/feed.xml" onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'url', event.target.value))} />
</label>
<label className="an-field">
<span>Feed </span>
<select className="an-input" value={text(feed.type, 'rss')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'type', event.target.value))}>
{NEWS_SOURCE_TYPE_OPTIONS.filter((option) => option.value !== 'reference').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="an-field">
<span></span>
<select className="an-input" value={text(feed.default_category, text(currentSource.default_category, 'business'))} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'default_category', event.target.value))}>
{categories.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="an-field">
<span></span>
<input className="an-input" type="number" value={text(feed.priority, String(index + 1))} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'priority', Number(event.target.value)))} />
</label>
<label className="an-field">
<span>Feed </span>
<input className="an-input" value={text(feed.tags_text || (Array.isArray(feed.tags) ? feed.tags.join(', ') : ''), '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'tags_text', event.target.value))} />
</label>
</div>
</div>
))}
{!feeds.length ? <EmptyState title="暂无 Feed 子项" description="添加一个真实 RSS/Atom/Aggregated 地址后才能抓取。" /> : null}
<Button size="icon" variant="subtle" title="新增 Feed 子项" aria-label="新增 Feed 子项" onClick={() => setHierarchyDraft(addNewsFeedDraft(hierarchyDraft, activeGroup.record))}>+</Button>
</div>
</section>
)
}
const saveCurrent = async () => {
if (!activeGroup) return
@@ -4682,6 +5162,28 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
await saveHierarchySettings('/earth/boundaries/config', { config: payload }, '保存边界配置')
return
}
if (activeSection.key === 'news_sources') {
const fullPayload = newsSourcesPayload(activeState.raw)
const existingSources = arrayAt(fullPayload, 'sources')
const oldId = text(activeGroup.record.id, '')
const nextSource = newsSourceFromEditor(payload)
const validationError = newsSourceValidationError(nextSource, existingSources, activeGroup.record.__isDraft ? '' : oldId)
if (validationError) {
toast({ title: '新闻源配置不完整', description: validationError, tone: 'error' })
return
}
const nextSources = activeGroup.record.__isDraft
? [...existingSources, nextSource]
: existingSources.some((source) => text(source.id, '') === oldId)
? existingSources.map((source) => text(source.id, '') === oldId ? nextSource : source)
: [...existingSources, nextSource]
await saveHierarchySettings('/earth/news-sources', { ...fullPayload, sources: nextSources }, '保存新闻源')
if (activeGroup.record.__isDraft) {
setNewsDraftGroup(null)
setActiveGroupKey(`news-source:${text(nextSource.id, '')}`)
}
return
}
if (activeSection.key === 'tv') {
const tv = tvSettingsFromRaw(activeState.raw)
const sources = Array.isArray(tv.sources) ? tv.sources.filter(isObjectRecord) : []
@@ -4858,12 +5360,45 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
selectHierarchyGroup(group)
}} />
</TactileControlGroup>
) : config === configs.earthContent && activeSection.key === 'news_sources' ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="新增新闻源" aria-label="新增新闻源" onClick={() => {
const group = makeNewNewsSourceGroup(arrayAt(newsPayload, 'sources').length + 1)
setNewsDraftGroup(group)
selectHierarchyGroup(group)
}} />
</TactileControlGroup>
) : null
const hierarchyHeader = config === configs.earthContent && activeSection.key === 'news_sources' ? (
<div className="an-news-source-filters">
<select className="an-input" value={newsFilters.status} onChange={(event) => setNewsFilters((current) => ({ ...current, status: event.target.value }))}>
<option value="all"></option>
<option value="enabled"></option>
<option value="disabled"></option>
<option value="reference"></option>
</select>
<select className="an-input" value={newsFilters.sourceType} onChange={(event) => setNewsFilters((current) => ({ ...current, sourceType: event.target.value }))}>
<option value="all"></option>
{NEWS_SOURCE_TYPE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select className="an-input" value={newsFilters.region} onChange={(event) => setNewsFilters((current) => ({ ...current, region: event.target.value }))}>
<option value="all"></option>
{NEWS_REGION_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select className="an-input" value={newsFilters.tag} onChange={(event) => setNewsFilters((current) => ({ ...current, tag: event.target.value }))}>
<option value="all"></option>
{newsTagOptions(newsPayload).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
) : null
return (
<div className={`an-hierarchy-layout${mobileHierarchyDetailOpen ? ' is-mobile-detail-open' : ''}`}>
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} footer={hierarchyFooter} />
<Panel className="an-hierarchy-detail">
<div className="an-hierarchy-workspace">
{hierarchyHeader}
<div className={`an-hierarchy-layout${mobileHierarchyDetailOpen ? ' is-mobile-detail-open' : ''}`}>
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} footer={hierarchyFooter} />
<Panel className="an-hierarchy-detail">
<div className="an-mobile-detail-bar">
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{activeGroup?.label || activeSection.label}</strong>
@@ -4918,6 +5453,40 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{config === configs.earthContent && activeSection.key === 'earth_assets' ? (
<Button variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前表单未保存。' })} loading={actionLoading}></Button>
) : null}
{config === configs.earthContent && activeSection.key === 'news_sources' && activeGroup ? (
<>
<Button
size="icon"
variant="subtle"
title={newsSourceType(record) === 'reference' ? '参考链接不参与抓取' : '测试当前新闻源'}
aria-label={newsSourceType(record) === 'reference' ? '参考链接不参与抓取' : '测试当前新闻源'}
onClick={() => void testSingleEarthNewsSource(record)}
loading={actionLoading}
icon="test"
/>
<Button size="icon" variant="subtle" title="恢复当前表单" aria-label="恢复当前表单" onClick={() => {
setHierarchyDraft(formatRaw(activeGroup.record))
toast({ title: '已恢复当前项', description: '表单已恢复到加载时状态。', tone: 'success' })
}}><Redo2 size={15} /></Button>
{!activeGroup.record.__isDraft ? (
<Button size="icon" variant="danger" title="删除新闻源" aria-label="删除新闻源" onClick={() => setConfirmAction({
title: '删除新闻源',
description: `确认删除 ${recordTitle(record)}`,
danger: true,
confirmLabel: '删除',
run: async () => {
const fullPayload = newsSourcesPayload(activeState.raw)
const sourceId = text(record.id, '')
await saveHierarchySettings('/earth/news-sources', {
...fullPayload,
sources: arrayAt(fullPayload, 'sources').filter((source) => text(source.id, '') !== sourceId),
}, '删除新闻源')
setActiveGroupKey('')
},
})} loading={actionLoading}><Trash2 size={15} /></Button>
) : null}
</>
) : null}
{config === configs.earthContent && activeSection.key === 'tv' && activeGroup ? (
<>
<Button size="icon" variant="subtle" title={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} aria-label={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} onClick={() => {
@@ -4980,7 +5549,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
return
}
void requestAction('测试 SMTP', 'post', '/settings/smtp/test', { to: smtpTestEmail.trim(), settings: record }, { refresh: false, successDescription: '测试邮件请求已发送,未保存 SMTP 表单。' })
}} loading={actionLoading}><TestTube2 size={15} /></Button>
}} loading={actionLoading} icon="test" />
</>
) : null}
{config === configs.settings && activeSection.key === 'collectors' ? (
@@ -5021,6 +5590,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
toast({ title: '已取消新增直播源', tone: 'success' })
}}><X size={15} /></Button>
) : null}
{config === configs.earthContent && activeSection.key === 'news_sources' && activeGroup?.record.__isDraft ? (
<Button variant="subtle" onClick={() => {
setNewsDraftGroup(null)
setHierarchyDraft('')
setActiveGroupKey('')
toast({ title: '已取消新增新闻源', tone: 'success' })
}}><X size={15} /></Button>
) : null}
{config === configs.collection && activeSection.key === 'collector_credentials' && activeGroup?.record.__isDraft ? (
<Button variant="subtle" onClick={() => {
setCollectionDraftGroup(null)
@@ -5103,7 +5680,51 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{config === configs.earthContent && activeSection.key === 'brand' ? (
<EarthBrandPreview record={record} />
) : null}
{config === configs.collection && activeSection.key === 'collection_history' ? null : config === configs.earthContent && activeSection.key === 'tv' ? (
{config === configs.collection && activeSection.key === 'collection_history' ? null : config === configs.earthContent && activeSection.key === 'news_sources' ? (
<>
<section className="an-field-cluster an-field-cluster--compact">
<div className="an-field-cluster__heading">
<h3></h3>
<p>{newsSourceHealthDescription(isObjectRecord(record.__health) ? record.__health : {})}</p>
</div>
</section>
{newsSourceType(record) === 'reference' ? (
<section className="an-field-cluster an-field-cluster--compact">
<div className="an-field-cluster__heading">
<h3></h3>
<p>线 RSS/Atom RSSAtom Aggregated</p>
</div>
</section>
) : null}
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3></h3>
<p></p>
</div>
<FieldGrid
record={activeGroup.record}
draft={hierarchyDraft}
onDraftChange={setHierarchyDraft}
fields={fields.filter((field) => ['id', 'name', 'region', 'source_type', 'enabled', 'homepage_url', 'feed_directory_url', 'source_tags_text'].includes(field.key))}
searchGroupKey={activeGroup.key}
/>
</section>
{newsSourceType(record) === 'reference' ? null : renderNewsFeedEditor()}
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3></h3>
<p></p>
</div>
<FieldGrid
record={activeGroup.record}
draft={hierarchyDraft}
onDraftChange={setHierarchyDraft}
fields={fields.filter((field) => ['importance_weight', 'fetch_interval_minutes', 'timeout_seconds', 'failure_threshold', 'cooldown_minutes', 'circuit_breaker'].includes(field.key))}
searchGroupKey={activeGroup.key}
/>
</section>
</>
) : config === configs.earthContent && activeSection.key === 'tv' ? (
<>
<div className="an-tv-edit-layout">
<TVEarthPreview source={record} tv={{ ...(tvSettingsFromRaw(activeState.raw) as AnyRecord), sources: tvAdminSources(activeState.raw), source_count: tvAdminSources(activeState.raw).length }} />
@@ -5148,7 +5769,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
) : <EmptyState title={isPlaceholderSection(activeSection.key) ? '后端能力未提供' : '暂无分组'} description={isPlaceholderSection(activeSection.key) ? '当前分区没有可用后端能力,控制台不混入其他配置或假数据。' : '当前分区没有可配置项。'} />}
</Scrollbar>
</div>
</Panel>
</Panel>
</div>
</div>
)
}
@@ -5328,6 +5950,17 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
if (config === configs.earthContent && endpointKey === 'boundaries') {
actions.push(<Button key="build-boundaries" variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前配置未保存。' })} loading={actionLoading}></Button>)
}
if (config === configs.earthContent && endpointKey === 'newsSources') {
actions.push(
<Button key="test-news-source" size="icon" variant="subtle" icon="test" onClick={() => void testEarthNewsSource(selected)} loading={actionLoading} title="测试启用源" aria-label="测试启用源" />,
<Button key="reset-news-sources" variant="subtle" onClick={() => setConfirmAction({
title: '重置新闻源',
description: '确认恢复内置默认来源、Feed 子项、源属性标签和策略?当前自定义配置会被清空。',
confirmLabel: '重置',
run: () => requestAction('重置新闻源', 'post', '/earth/news-sources/reset'),
})} loading={actionLoading}><RefreshCw size={15} /></Button>,
)
}
if (config === configs.bgp && endpointKey === 'collectors' && id) {
actions.push(<Button key="collect-location" variant="primary" icon="trigger" onClick={() => void collectCollectorLocation(selected)} loading={actionLoading}></Button>)
@@ -5337,7 +5970,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
actions.push(<Button key="bgp-detail" variant="subtle" onClick={() => void loadBGPDetail(selected)} loading={actionLoading}><Eye size={15} /></Button>)
}
if ((config === configs.settings) || (config === configs.earthContent && ['brand', 'boundaries', 'tv'].includes(endpointKey)) || (config === configs.ai && ['prompts', 'integrations'].includes(endpointKey))) {
if ((config === configs.settings) || (config === configs.earthContent && ['brand', 'boundaries', 'tv', 'newsSources'].includes(endpointKey)) || (config === configs.ai && ['prompts', 'integrations'].includes(endpointKey))) {
actions.push(<Button key="save-json" variant="primary" onClick={saveAdvancedJson} loading={actionLoading}><Save size={15} /></Button>)
}
@@ -5352,12 +5985,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
return <div className="an-detail-actions">{actions}</div>
}
const supportsStructuredSave = (record: TableRecord | null) => {
const supportsStructuredSave = (record: TableRecord | null) => {
if (!record) return false
const endpointKey = record.__endpointKey
return (
config === configs.settings ||
(config === configs.earthContent && ['brand', 'boundaries', 'tv'].includes(endpointKey)) ||
(config === configs.earthContent && ['brand', 'boundaries', 'tv', 'newsSources'].includes(endpointKey)) ||
(config === configs.ai && ['integrations', 'prompts'].includes(endpointKey)) ||
(config === configs.collection && (endpointKey === 'mappings' || /configs/.test(endpointKey)))
)
@@ -5434,6 +6067,17 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
if (config === configs.collection) {
return actions
}
if (config === configs.earthContent && activeSectionKey === 'news_sources') {
actions.push(
<Button key="reset-news-sources" size="icon" variant="subtle" title="重置默认新闻源" aria-label="重置默认新闻源" onClick={() => setConfirmAction({
title: '重置默认新闻源',
description: '确认恢复内置默认来源、Feed 子项、源属性标签和策略?',
danger: false,
confirmLabel: '重置',
run: () => requestAction('重置新闻源', 'post', '/earth/news-sources/reset'),
})} loading={actionLoading}><RefreshCw size={15} /></Button>,
)
}
if (config === configs.bgp || config === configs.bgpAlerts) {
actions.push(
<Button key="generate-bgp-brief" variant="primary" onClick={() => void requestAction('生成 BGP 简报', 'post', '/ai/bgp/brief', { incident_limit: 5, anomaly_limit: 6, collector_limit: 5 }, { refresh: false, successDescription: 'AI 简报生成任务已提交,不会保存当前页面配置。' })} loading={actionLoading} title="生成 BGP AI 简报">
@@ -6124,10 +6768,25 @@ const configs = {
{ key: 'publicTv', label: '采集源', url: '/tv/streams', map: emptyRows },
],
},
{
key: 'news_sources',
label: '新闻源',
url: '/earth/news-sources',
map: (payload) => isObjectRecord(payload)
? [{
...payload,
__title: '新闻源配置',
__module: '新闻源',
__status: payload.is_default ? '默认配置' : '已配置',
__metric: `${arrayAt(payload, 'sources').length} 个源 / ${arrayAt(payload, 'categories').length} 个类型`,
__endpointKey: 'newsSources',
__endpointLabel: '新闻源',
}]
: [],
},
{ key: 'basemap', label: '底图资源', map: emptyRows },
{ key: 'layer_resources', label: '图层资源', map: emptyRows },
{ key: 'models_3d', label: '3D 模型', map: emptyRows },
{ key: 'news_anchor_strategy', label: '新闻锚点策略', map: emptyRows },
],
actions: [makeAction('打开智能星球', <Globe2 size={15} />, '/earth')],
detailTitle: '智能星球配置详情',

View File

@@ -48,7 +48,7 @@ export const adminRoutes: AdminRouteItem[] = [
{ path: '/alerts/bgp', label: 'BGP 告警', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] },
{ path: '/alerts/situational', label: '态势告警', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] },
{ path: '/ai', label: 'AI', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] },
{ path: '/earth-content', label: '智能星球内容', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand'] },
{ path: '/earth-content', label: '智能星球内容', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand', 'news', 'rss', '新闻源'] },
{ path: '/collection-management', label: '采集管理', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] },
{ path: '/logs', label: '系统日志', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true },
{ path: '/users', label: '用户管理', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] },

View File

@@ -1,6 +1,16 @@
const RECENT_EVENT_TTL_MS = 15_000
const MAX_DETAIL_LENGTH = 4000
const recentEventMap = new Map<string, number>()
const pendingEventMap = new Map<string, {
level: string
message: string
category: string
module: string
detail: string
fingerprint: string
occurrenceCount: number
}>()
let pendingFlushTimer: number | null = null
const NON_ADMIN_PATH_PREFIXES = ['/earth', '/docs', '/login', '/register', '/verify-email', '/forgot-password']
type RuntimeLogLevel = 'error' | 'warning' | 'info' | 'debug'
@@ -39,6 +49,28 @@ function shouldSkip(level: string, message: string, detail: string, category: st
return Boolean(lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS)
}
function normalizeFingerprintText(value: unknown) {
return String(value || '')
.replace(/[?&](m|t|token|expires|signature|X-Amz-[^=]+)=[^&\s]+/gi, '')
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>')
.replace(/\bconn_[A-Za-z0-9:._-]+\b/g, '<connection>')
.replace(/\b\d{5,}\b/g, '<number>')
.replace(/\s+/g, ' ')
.trim()
}
function hashFingerprint(value: string) {
let hash = 5381
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index)
}
return `admin-${(hash >>> 0).toString(16).padStart(8, '0')}`
}
function buildFingerprint(level: string, message: string, detail: string, category: string, module: string) {
return hashFingerprint([level, category, module, message, detail].map(normalizeFingerprintText).join('|'))
}
function isAdminRoute() {
if (typeof window === 'undefined') return false
const pathname = window.location.pathname
@@ -54,19 +86,56 @@ export async function reportAdminRuntimeLog({
}: AdminRuntimeLogInput) {
if (!message || typeof window === 'undefined' || !isAdminRoute()) return
const normalizedDetail = normalizeErrorDetail(detail)
if (shouldSkip(level, message, normalizedDetail, category)) return
const fingerprint = buildFingerprint(level, message, normalizedDetail, category, module)
const key = `${level}::${category}::${message}::${normalizedDetail}`
const existing = pendingEventMap.get(key)
if (existing) {
existing.occurrenceCount += 1
} else {
pendingEventMap.set(key, {
level,
message,
category,
module,
detail: normalizedDetail,
fingerprint,
occurrenceCount: 1,
})
}
shouldSkip(level, message, normalizedDetail, category)
if (pendingFlushTimer) return
pendingFlushTimer = window.setTimeout(() => {
pendingFlushTimer = null
const pending = Array.from(pendingEventMap.values())
pendingEventMap.clear()
pending.forEach((entry) => {
void sendAdminRuntimeLog(entry)
})
}, 1000)
}
async function sendAdminRuntimeLog(entry: {
level: string
message: string
category: string
module: string
detail: string
fingerprint: string
occurrenceCount: number
}) {
try {
await fetch('/api/v1/system/logs/admin-client', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level,
message,
category,
module,
level: entry.level,
message: entry.message,
category: entry.category,
module: entry.module,
url: window.location.href,
detail: normalizedDetail.slice(0, MAX_DETAIL_LENGTH),
detail: entry.detail.slice(0, MAX_DETAIL_LENGTH),
fingerprint: entry.fingerprint,
occurrence_count: entry.occurrenceCount,
}),
keepalive: true,
})

View File

@@ -81,6 +81,7 @@ const sectionTargets = [
{ key: 'brand', label: '品牌标识', terms: ['logo', '标题', 'subtitle'] },
{ key: 'earth_assets', label: '国界精度', terms: ['boundary', 'PMTiles', '边界'] },
{ key: 'tv', label: '电视直播', terms: ['TV', '直播源', '频道'] },
{ key: 'news_sources', label: '新闻源', terms: ['news', 'rss', '商业新闻', '电商', '财经', '新闻类型'] },
] },
{ routePath: '/collection-management', routeLabel: '采集管理', icon: Database, sections: [
{ key: 'collector_credentials', label: '采集器', terms: ['collector', 'credential', '凭证教程'] },

View File

@@ -1541,6 +1541,10 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
container-type: inline-size;
}
.an-hierarchy-list__header {
padding: 10px 10px 0;
}
.an-hierarchy-list__scroll,
.an-hierarchy-form-scroll {
min-height: 0;
@@ -1562,6 +1566,33 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
padding: 10px 0 4px;
}
.an-news-source-filters {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: 8px;
align-items: center;
}
.an-hierarchy-workspace {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--an-section-gap, 16px);
overflow: hidden;
}
@media (max-width: 900px) {
.an-news-source-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.an-news-source-filters {
grid-template-columns: 1fr;
}
}
.an-hierarchy-group-set {
display: grid;
gap: 6px;
@@ -2340,6 +2371,35 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
outline: none;
}
.an-news-feed-list {
display: grid;
gap: 10px;
}
.an-news-feed-card {
display: grid;
gap: 10px;
padding: 10px;
border: 1px solid var(--an-border);
border-radius: 7px;
background: var(--an-surface-alt);
}
.an-news-feed-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.an-news-feed-card__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.an-textarea {
height: auto;
min-height: 94px;
@@ -2403,7 +2463,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.an-select__content {
z-index: 300;
min-width: var(--radix-select-trigger-width);
max-width: min(360px, calc(100vw - 24px));
max-width: min(420px, calc(100vw - 24px));
background: var(--an-surface, #ffffff);
color: var(--an-text, #0f172a);
border: 1px solid var(--an-border);
@@ -2422,21 +2482,32 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
min-height: 30px;
max-width: 100%;
border-radius: 4px;
padding: 0 8px;
display: flex;
padding: 6px 28px 6px 8px;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: center;
gap: 8px;
cursor: pointer;
outline: none;
line-height: 1.25;
white-space: nowrap;
white-space: normal;
position: relative;
}
.an-select__item span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.an-select__indicator {
position: absolute;
top: 50%;
right: 8px;
display: inline-flex;
transform: translateY(-50%);
}
.an-select__item[data-highlighted] {
@@ -3324,9 +3395,15 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
line-height: 1.65;
}
.an-logs-page .an-page__body {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--an-section-gap);
}
.an-logs-layout {
min-height: 0;
height: min(760px, calc(100vh - 170px));
height: 100%;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: var(--an-section-gap);
@@ -3414,6 +3491,54 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.an-log-group-detail {
display: grid;
gap: 10px;
padding: 10px;
color: #dbeafe;
}
.an-log-group-summary,
.an-log-occurrence {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: rgba(15, 23, 42, 0.72);
padding: 10px;
}
.an-log-group-summary {
display: grid;
gap: 4px;
}
.an-log-group-summary span,
.an-log-occurrence p {
color: #94a3b8;
}
.an-log-occurrence {
display: grid;
gap: 8px;
}
.an-log-occurrence > div {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.an-log-occurrence pre {
min-width: 0;
border-radius: 6px;
background: rgba(2, 6, 23, 0.82);
padding: 10px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.an-playground {
min-height: 0;
height: 100%;

View File

@@ -119,6 +119,10 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: '智能星球工具栏与浮层协同', group: 'Earth', order: 18 },
en: { title: 'Intelligent Planet Toolbar and Overlay Coordination', group: 'Earth', order: 18 },
},
'earth-news-sources.md': {
zh: { title: '智能星球新闻源配置', group: 'Earth', order: 19 },
en: { title: 'Intelligent Planet News Source Configuration', group: 'Earth', order: 19 },
},
'frontend-admin-frontend-context.md': {
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },