release: bump version to 0.69.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.68.1",
|
||||
"version": "0.69.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
128
frontend/public/earth/js/controls.js
vendored
128
frontend/public/earth/js/controls.js
vendored
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || "增强状态";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
40
frontend/public/earth/js/realtime.js
Normal file
40
frontend/public/earth/js/realtime.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 抓取;如需抓取,请改为 RSS、Atom 或 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: '智能星球配置详情',
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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', '凭证教程'] },
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user