Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c21973197 | ||
|
|
51ae5e6ec9 | ||
|
|
1cf1f32ddd | ||
|
|
8f3ab88743 | ||
|
|
f8b43a995b | ||
|
|
d9adaf4134 | ||
|
|
40e51d5b20 | ||
|
|
93c1c1e550 |
@@ -13,6 +13,7 @@ from app.api.v1 import (
|
||||
collected_data,
|
||||
visualization,
|
||||
bgp,
|
||||
news,
|
||||
system_control,
|
||||
tv,
|
||||
)
|
||||
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
|
||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
|
||||
13
backend/app/api/v1/news.py
Normal file
13
backend/app/api/v1/news.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.services.earth_news import get_earth_news_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/earth-feed")
|
||||
async def get_earth_feed(
|
||||
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
||||
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
||||
):
|
||||
return await get_earth_news_payload(lat=lat, lon=lon)
|
||||
490
backend/app/services/earth_news.py
Normal file
490
backend/app/services/earth_news.py
Normal file
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
|
||||
REQUEST_TIMEOUT = 12.0
|
||||
MAX_ITEMS_PER_SOURCE = 6
|
||||
MAX_ITEMS_TOTAL = 12
|
||||
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionProfile:
|
||||
key: str
|
||||
label: str
|
||||
query: str
|
||||
accent: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsFeedSource:
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
feed_url: str
|
||||
homepage_url: str
|
||||
source_type: str = "rss"
|
||||
priority: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
url: str
|
||||
source: str
|
||||
feed_name: str
|
||||
feed_region: str
|
||||
homepage_url: str
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CachedRegionFeed:
|
||||
region: str
|
||||
fetched_at: datetime
|
||||
items: list[ParsedNewsItem]
|
||||
sources: list[NewsFeedSource]
|
||||
|
||||
|
||||
REGION_PROFILES: dict[str, RegionProfile] = {
|
||||
"americas": RegionProfile(
|
||||
key="americas",
|
||||
label="美洲焦点",
|
||||
query='Americas geopolitics OR Latin America OR "United States" OR Canada',
|
||||
accent="#79d3ff",
|
||||
),
|
||||
"europe": RegionProfile(
|
||||
key="europe",
|
||||
label="欧洲焦点",
|
||||
query='Europe geopolitics OR EU OR NATO OR "Eastern Europe"',
|
||||
accent="#8fd4ff",
|
||||
),
|
||||
"middle-east-africa": RegionProfile(
|
||||
key="middle-east-africa",
|
||||
label="中东与非洲焦点",
|
||||
query='"Middle East" OR Africa geopolitics OR Red Sea OR Gulf',
|
||||
accent="#ffb56a",
|
||||
),
|
||||
"asia-pacific": RegionProfile(
|
||||
key="asia-pacific",
|
||||
label="亚太焦点",
|
||||
query='"Asia Pacific" OR Indo-Pacific OR China OR Japan OR Korea OR ASEAN',
|
||||
accent="#78f2cf",
|
||||
),
|
||||
"global": RegionProfile(
|
||||
key="global",
|
||||
label="全球焦点",
|
||||
query='"world news" OR geopolitics OR "global affairs"',
|
||||
accent="#d6e6ff",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
||||
return (
|
||||
"https://news.google.com/rss/search?q="
|
||||
+ quote(query, safe="")
|
||||
+ f"&hl={hl}&gl={gl}&ceid={ceid}"
|
||||
)
|
||||
|
||||
|
||||
NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
|
||||
NewsFeedSource(
|
||||
id="bbc-world",
|
||||
name="BBC World",
|
||||
region="global",
|
||||
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
homepage_url="https://www.bbc.com/news/world",
|
||||
priority=10,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="dw-top",
|
||||
name="DW Top Stories",
|
||||
region="europe",
|
||||
feed_url="https://rss.dw.com/rdf/rss-en-top",
|
||||
homepage_url="https://www.dw.com/en/top-stories/s-9097",
|
||||
priority=20,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="global-scan",
|
||||
name="Global Monitor / World",
|
||||
region="global",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["global"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=30,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-americas",
|
||||
name="Global Monitor / Americas",
|
||||
region="americas",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["americas"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-europe",
|
||||
name="Global Monitor / Europe",
|
||||
region="europe",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["europe"].query,
|
||||
hl="en-GB",
|
||||
gl="GB",
|
||||
ceid="GB:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-mea",
|
||||
name="Global Monitor / MEA",
|
||||
region="middle-east-africa",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["middle-east-africa"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-apac",
|
||||
name="Global Monitor / APAC",
|
||||
region="asia-pacific",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["asia-pacific"].query,
|
||||
hl="en-SG",
|
||||
gl="SG",
|
||||
ceid="SG:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
|
||||
|
||||
|
||||
def determine_focus_region(lat: float | None, lon: float | None) -> str:
|
||||
if lat is None or lon is None:
|
||||
return "global"
|
||||
if -170 <= lon <= -30:
|
||||
return "americas"
|
||||
if -30 < lon <= 45:
|
||||
return "europe" if lat >= 30 else "middle-east-africa"
|
||||
if 45 < lon <= 150:
|
||||
return "middle-east-africa" if lat < 10 else "asia-pacific"
|
||||
return "asia-pacific"
|
||||
|
||||
|
||||
def get_region_profile(region: str) -> RegionProfile:
|
||||
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
key=lambda source: (source.priority, source.name),
|
||||
)
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
soup = BeautifulSoup(value, "html.parser")
|
||||
return re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).strip()
|
||||
|
||||
|
||||
def _truncate(value: str, limit: int = 180) -> str:
|
||||
text = value.strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _normalize_source_name(raw: str, fallback: str) -> str:
|
||||
text = html.unescape((raw or "").strip())
|
||||
if " - " in text:
|
||||
return text.split(" - ")[-1].strip() or fallback
|
||||
return text or fallback
|
||||
|
||||
|
||||
def _parse_datetime(raw: str | None) -> datetime | None:
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
for parser in (
|
||||
lambda value: parsedate_to_datetime(value),
|
||||
lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")),
|
||||
):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _extract_item_text(element: ET.Element, *names: str) -> str:
|
||||
for name in names:
|
||||
node = element.find(name)
|
||||
if node is not None and node.text:
|
||||
return node.text.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNewsItem]:
|
||||
root = ET.fromstring(xml_text)
|
||||
items: list[ParsedNewsItem] = []
|
||||
|
||||
rss_items = root.findall("./channel/item")
|
||||
atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry")
|
||||
nodes = rss_items or atom_entries
|
||||
|
||||
for node in nodes[:MAX_ITEMS_PER_SOURCE]:
|
||||
if node.tag.endswith("entry"):
|
||||
title = _extract_item_text(node, "{http://www.w3.org/2005/Atom}title")
|
||||
summary = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}summary",
|
||||
"{http://www.w3.org/2005/Atom}content",
|
||||
)
|
||||
link_node = node.find("{http://www.w3.org/2005/Atom}link")
|
||||
link = link_node.get("href", "").strip() if link_node is not None else ""
|
||||
published = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}updated",
|
||||
"{http://www.w3.org/2005/Atom}published",
|
||||
)
|
||||
else:
|
||||
title = _extract_item_text(node, "title")
|
||||
summary = _extract_item_text(node, "description", "content")
|
||||
link = _extract_item_text(node, "link")
|
||||
published = _extract_item_text(node, "pubDate", "published", "updated")
|
||||
|
||||
clean_title = html.unescape(title).strip()
|
||||
clean_summary = _truncate(_strip_html(summary), 180)
|
||||
if not clean_title or not link:
|
||||
continue
|
||||
|
||||
item_source = _normalize_source_name(clean_title, source.name)
|
||||
display_title = clean_title
|
||||
if source.source_type == "aggregated" and " - " in clean_title:
|
||||
parts = clean_title.rsplit(" - ", 1)
|
||||
display_title = parts[0].strip()
|
||||
item_source = _normalize_source_name(parts[1], source.name)
|
||||
|
||||
items.append(
|
||||
ParsedNewsItem(
|
||||
id=f"{source.id}:{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}",
|
||||
title=display_title,
|
||||
summary=clean_summary,
|
||||
url=link,
|
||||
source=item_source,
|
||||
feed_name=source.name,
|
||||
feed_region=source.region,
|
||||
homepage_url=source.homepage_url,
|
||||
published_at=_parse_datetime(published),
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": source.id,
|
||||
"name": source.name,
|
||||
"region": source.region,
|
||||
"homepage_url": source.homepage_url,
|
||||
}
|
||||
for source in sources
|
||||
]
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
|
||||
def _build_payload(
|
||||
*,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
active_region: str,
|
||||
items: list[ParsedNewsItem],
|
||||
sources: list[NewsFeedSource],
|
||||
errors: list[str],
|
||||
stale: bool,
|
||||
generated_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
profile = get_region_profile(active_region)
|
||||
timestamp = generated_at or datetime.now(UTC)
|
||||
return {
|
||||
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
|
||||
"focus": {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"region": active_region,
|
||||
"label": profile.label,
|
||||
"accent": profile.accent,
|
||||
},
|
||||
"sources": _serialize_sources(sources),
|
||||
"items": [_serialize_item(item, active_region=active_region) for item in items],
|
||||
"errors": errors,
|
||||
"stale": stale,
|
||||
}
|
||||
|
||||
|
||||
def _rank_and_trim_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]:
|
||||
deduped: dict[str, ParsedNewsItem] = {}
|
||||
for item in items:
|
||||
key = item.url.strip() or item.title.strip().lower()
|
||||
if key not in deduped:
|
||||
deduped[key] = item
|
||||
|
||||
return sorted(
|
||||
deduped.values(),
|
||||
key=lambda item: (
|
||||
item.feed_region != active_region,
|
||||
item.published_at is None,
|
||||
-(item.published_at.timestamp() if item.published_at else 0),
|
||||
item.feed_name,
|
||||
),
|
||||
)[:MAX_ITEMS_TOTAL]
|
||||
|
||||
|
||||
def _get_cached_region_feed(region: str) -> CachedRegionFeed | None:
|
||||
cached = _REGION_CACHE.get(region)
|
||||
if not cached:
|
||||
return None
|
||||
age_seconds = (datetime.now(UTC) - cached.fetched_at).total_seconds()
|
||||
if age_seconds > STALE_CACHE_MAX_AGE_SECONDS:
|
||||
return None
|
||||
return cached
|
||||
|
||||
|
||||
def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: list[NewsFeedSource]) -> None:
|
||||
_REGION_CACHE[region] = CachedRegionFeed(
|
||||
region=region,
|
||||
fetched_at=datetime.now(UTC),
|
||||
items=list(items),
|
||||
sources=list(sources),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_source(
|
||||
client: httpx.AsyncClient,
|
||||
source: NewsFeedSource,
|
||||
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None]:
|
||||
try:
|
||||
response = await client.get(source.feed_url)
|
||||
response.raise_for_status()
|
||||
return source, _parse_feed_entries(response.text, source), None
|
||||
except Exception as exc:
|
||||
return source, [], str(exc)
|
||||
|
||||
|
||||
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
errors: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as client:
|
||||
results = await asyncio.gather(*(_fetch_source(client, source) for source in sources))
|
||||
|
||||
fetched_items: list[ParsedNewsItem] = []
|
||||
for source, items, error in results:
|
||||
if error:
|
||||
errors.append(f"{source.name}: {error}")
|
||||
continue
|
||||
fetched_items.extend(items)
|
||||
|
||||
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
if ranked_items:
|
||||
_store_region_cache(active_region, items=ranked_items, sources=sources)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=ranked_items,
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
|
||||
cached = _get_cached_region_feed(active_region)
|
||||
if cached:
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=cached.items,
|
||||
sources=cached.sources,
|
||||
errors=errors,
|
||||
stale=True,
|
||||
generated_at=cached.fetched_at,
|
||||
)
|
||||
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=[],
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
@@ -8,6 +8,97 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.28.0] — 2026-04-20
|
||||
|
||||
### ✨ Features
|
||||
- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区
|
||||
- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现
|
||||
|
||||
### 🔧 Improvements
|
||||
- TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口
|
||||
- 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀
|
||||
- `tv.js`、`news.js` 进一步清理共享 HUDPanel 迁移后的残留逻辑,收紧 tab / resize / reform 相关局部 helper
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板右下角缩放时高度异常抬升、标题栏被顶出视口的问题
|
||||
- 修复直播/聚合 tab 切换时按钮高亮、内容切换和底边基准表现不一致的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
## [0.27.8] — 2026-04-20
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth HUD 共享 `HUDPanel` 默认展开/收缩逻辑继续收口,图例与图层面板统一使用同一套边缘阈值与箭头状态机
|
||||
- 保持新闻直播面板现有特例折叠行为不变,避免播放器区域被默认折叠逻辑影响
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复图例与图层面板展开/收缩箭头方向和实际动作不一致的问题
|
||||
- 修复拖动到屏幕底边附近时初始箭头、拖动中箭头和点击后动作不同步的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
### 🔧 Improvements
|
||||
- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压
|
||||
- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览
|
||||
- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题
|
||||
- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化
|
||||
- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题
|
||||
- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.6] — 2026-04-15
|
||||
|
||||
### 🔧 Improvements
|
||||
- BGP 告警页表格纵向 overflow 修复:补全 flex 布局链,tabs content-holder 正确撑满剩余高度
|
||||
- 用户管理表格横向滚动修复:采用 flex-fill 方案替换 `height: auto !important`,自定义滚动条 X 轨道位置对齐表格底部
|
||||
- Playground 宽布局隐藏"服务状态"按钮:侧边栏可见时不显示冗余入口
|
||||
- AI Chatbox 输入框失焦收起为单行,聚焦或有内容时展开完整 composer
|
||||
|
||||
---
|
||||
|
||||
## [0.27.4] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- info-card 改为懒加载动态挂载:页面初始 DOM 不再含隐藏的 `#info-panel` 节点,仅首次点击交互元素时创建
|
||||
|
||||
---
|
||||
|
||||
## [0.27.5] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- 统一控制台多页面滚动体验:BGP、alerts、采集数据、用户管理、任务、设置、Playground 等区域接入自定义滚动条与表格滚动容器
|
||||
- 优化 BGP 与 alerts 页响应式布局:顶部概览卡在窄宽度下优先重排,必要时才启用横向滚动,避免卡片裁切和全局滚动条接管
|
||||
- 调整 `situational alerts` 布局策略:统计卡按宽度在单行、两列和横滚之间切换,下方详情卡保持单行高度优先
|
||||
- 实时采集进度优化:一键采集完成后在未刷新页面时保留 100% 完成态,不再错误归零
|
||||
- 补充 UE5 MVP 融合方案文档,完善后续集成规划沉淀
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 BGP summary 与 alerts 顶部卡片在无真实溢出时误出现横向滚动的问题
|
||||
- 修复 alerts 页面缩窄后外层全局竖向滚动被接管的问题,恢复“一屏内、内部滚动”的布局逻辑
|
||||
- 修复 `situational alerts` 在两排布局下详情卡竖向溢出的问题,改为更稳定的分区响应式排版
|
||||
- 修复自定义滚动条交互反馈,悬停、聚焦、拖拽时颜色加深但不再显示多余外圈
|
||||
|
||||
---
|
||||
|
||||
## [0.27.3] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- TV panel meta 折叠展开方向稳定:底部锚定时向上生长,拖拽后(顶部锚定)通过 JS 补偿 top 保持播放器底部位置不变
|
||||
- 修复 TV panel 展开/折叠时视频区域跳动问题:移除面板 min-height,使播放器高度在两种状态下保持一致
|
||||
- 修正 TV panel meta toggle 箭头方向:展开朝下,折叠朝上
|
||||
- 修复图例面板折叠按钮失效(legend-bar-btn 补充进拖拽排除列表)
|
||||
- 调整图层搜索框图标尺寸为 20px,BR 缩放角标改为直角 L 形
|
||||
|
||||
---
|
||||
|
||||
## [0.27.2] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
165
docs/hud-panel-component-plan.md
Normal file
165
docs/hud-panel-component-plan.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# HUD Panel Component Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Unify Earth HUD panels into a reusable component layer so new panels can share:
|
||||
|
||||
- a consistent shell
|
||||
- a consistent header
|
||||
- a consistent action-button system
|
||||
- a consistent body and collapse pattern
|
||||
|
||||
## Scope
|
||||
|
||||
Target panels:
|
||||
|
||||
- `tv-panel`
|
||||
- `news-panel`
|
||||
- `legend`
|
||||
- `layer-panel`
|
||||
- `earth-stats`
|
||||
- `info-card`
|
||||
- settings modal header/actions
|
||||
|
||||
## Component Model
|
||||
|
||||
### Base shell
|
||||
|
||||
- `.hud-panel`
|
||||
- `.hud-panel--compact`
|
||||
- `.hud-panel--media`
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel-hidden`
|
||||
- `.hud-panel.is-dragging`
|
||||
- `.hud-panel.is-layout-animating`
|
||||
|
||||
### Header
|
||||
|
||||
- `.hud-panel__header`
|
||||
- `.hud-panel__title-group`
|
||||
- `.hud-panel__title`
|
||||
- `.hud-panel__subtitle`
|
||||
- `.hud-panel__chip`
|
||||
- `.hud-panel__actions`
|
||||
|
||||
Header baseline rule:
|
||||
|
||||
- Header title styling is fixed by the component layer and should not drift per panel
|
||||
- Title font size, font weight, letter spacing, line height, text color, and vertical alignment come from the shared header tokens and structure
|
||||
- Header divider, border treatment, inner spacing, and title-to-actions alignment are part of the same shared baseline
|
||||
- Panel-specific header differences should be limited to explicit variants such as `compact` or `media`, or token overrides with documented intent
|
||||
- “Looks close enough” local header overrides should be treated as temporary compatibility code and removed during migration
|
||||
|
||||
### Actions
|
||||
|
||||
- `.hud-panel__action`
|
||||
- `.hud-panel__action--icon`
|
||||
- `.hud-panel__action--collapse`
|
||||
- `.hud-panel__action--close`
|
||||
- `.hud-panel__action--refresh`
|
||||
- `.hud-panel__action--external`
|
||||
|
||||
Action-button baseline rule:
|
||||
|
||||
- Header action buttons must have one fixed default style baseline across all HUD panels
|
||||
- Default width behavior, padding, icon size, radius, alignment, hover, and active feedback all come from `.hud-panel__action`
|
||||
- Panel-specific differences must be expressed through explicit variants or token overrides, not ad-hoc local button rewrites
|
||||
- `close` buttons are part of the same default action system and must not silently fall back to a separate legacy box model
|
||||
|
||||
### Body
|
||||
|
||||
- `.hud-panel__body`
|
||||
- `.hud-panel__body--scroll`
|
||||
- `.hud-panel__body--collapsible`
|
||||
|
||||
### Collapse behavior
|
||||
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel--expand-up`
|
||||
- `.hud-panel--expand-down`
|
||||
|
||||
Adaptive collapse / expand rule:
|
||||
|
||||
- HUD panels support two expansion directions:
|
||||
- top-to-bottom expansion
|
||||
- bottom-to-top expansion
|
||||
- Expansion direction should be decided at runtime from available viewport space rather than hardcoded per panel
|
||||
- Use:
|
||||
- `d` = available distance from the header anchor to the viewport bottom edge
|
||||
- `h` = expected expanded panel height
|
||||
- buffer = `20px`
|
||||
- Collapsed-state direction rule:
|
||||
- if `d > h + 20px`, the next action direction is `expand-up`
|
||||
- if `d <= h + 20px`, the next action direction is `expand-down`
|
||||
- To avoid jitter around the threshold, the shared controller should keep a small hysteresis band:
|
||||
- if the current direction is already `up`, keep it until `d <= h`
|
||||
- if the current direction is already `down`, keep it until `d > h + 20px`
|
||||
- The opposite edge is still a safety guard:
|
||||
- if the chosen side cannot fit at all, fall back to the other side if it can fit
|
||||
- if neither side fully fits, choose the side with more space and let the body scroll
|
||||
- If neither direction fully fits, choose the direction with more available space and let the body scroll
|
||||
- Collapse icon direction must match the active expansion direction so the icon always describes the real open/close motion
|
||||
- The collapse icon describes the next action, not the current state
|
||||
- This mapping is fixed component behavior and must not drift per panel:
|
||||
- collapsed + expand-down => `expand_more`
|
||||
- expanded + expand-down => `expand_less`
|
||||
- collapsed + expand-up => `expand_less`
|
||||
- expanded + expand-up => `expand_more`
|
||||
- Panels must not combine icon-name swapping with extra CSS rotation for the same collapse control
|
||||
- Expansion direction and icon direction must come from one shared source of truth in the component controller
|
||||
- The direction decision should be recomputed when opening, resizing the viewport, or restoring a dragged panel near another edge
|
||||
|
||||
## Tokens
|
||||
|
||||
Promote panel differences into CSS variables instead of duplicating selectors:
|
||||
|
||||
- `--hud-panel-padding`
|
||||
- `--hud-header-padding`
|
||||
- `--hud-header-gap`
|
||||
- `--hud-action-padding`
|
||||
- `--hud-action-gap`
|
||||
- `--hud-action-icon-size`
|
||||
- `--hud-body-gap`
|
||||
- `--hud-body-max-height`
|
||||
- `--hud-chip-radius`
|
||||
- `--hud-title-font-size`
|
||||
- `--hud-title-font-weight`
|
||||
- `--hud-title-letter-spacing`
|
||||
- `--hud-title-line-height`
|
||||
- `--hud-title-color`
|
||||
- `--hud-header-border-color`
|
||||
- `--hud-header-divider-opacity`
|
||||
- `--hud-expand-direction`
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Build the shared component layer in `frontend/public/earth/css/hud.css`
|
||||
2. Migrate `tv-panel` and `news-panel` first as the reference implementation
|
||||
3. Migrate `legend` and `layer-panel` into a compact variant
|
||||
4. Migrate `earth-stats` and `info-card`
|
||||
5. Align settings modal header/actions with the same action system
|
||||
6. Remove legacy one-off button selectors after verification
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change panel behavior and data flow during the first pass
|
||||
- Keep old class names temporarily as compatibility hooks
|
||||
- Prefer variable overrides over per-panel reimplementation
|
||||
- Treat header action-button default styling as fixed component API, not per-panel design space
|
||||
- Treat header title typography, border, and divider styling as fixed component API, not per-panel design space
|
||||
- Treat collapse direction as a component behavior contract, not a one-off panel trick
|
||||
- Treat collapse icon semantics as a component behavior contract, not a per-panel visual preference
|
||||
- Verify header alignment and drag/collapse behavior after each migration batch
|
||||
|
||||
## First Implementation Batch
|
||||
|
||||
Batch 1 should only do:
|
||||
|
||||
- shared header structure
|
||||
- shared action-button system
|
||||
- shared title typography and header border/divider baseline
|
||||
- shared collapsible body pattern
|
||||
- adaptive collapse direction logic and direction-aware collapse icons
|
||||
- migration of `tv-panel` and `news-panel`
|
||||
|
||||
That keeps risk low while giving the rest of the HUD a stable target to migrate toward.
|
||||
981
docs/ue5_mvp_fused_plan.md
Normal file
981
docs/ue5_mvp_fused_plan.md
Normal file
@@ -0,0 +1,981 @@
|
||||
# 智能星球 UE5 客户端一期实施方案(融合版)
|
||||
|
||||
> 版本:v2.0
|
||||
> 日期:2026-04-14
|
||||
> 目标:把现有 Web Earth 项目,平滑推进到 **UE5 可用 MVP 客户端**
|
||||
> 适用对象:**UE 零基础新手**
|
||||
> 输出结果:一份 **能直接照着做** 的实施手册
|
||||
> 策略:**保留原 MVP 方案里适合入门的部分,吸收更稳的工程做法,降低你第一次做 UE 时踩坑概率**
|
||||
|
||||
---
|
||||
|
||||
## 一、这份融合版方案解决什么问题
|
||||
|
||||
你原来的 MVP 方案是靠谱的,优点很明显:
|
||||
|
||||
- 范围克制
|
||||
- 适合新手入门
|
||||
- 目标明确
|
||||
- 能较快做出“看得见、点得到”的成果
|
||||
|
||||
但它也有几个风险:
|
||||
|
||||
- 默认 `localhost` 一定通,这在 WSL2 + Windows + Docker 环境里不一定成立
|
||||
- 默认 UE 蓝图里直接做 HTTP + JSON 解析会很顺,这一步其实很容易卡
|
||||
- 默认“一上来就接真实后端”,新手会同时踩 UE、Cesium、网络、JSON、蓝图五个坑
|
||||
- 时间估计略乐观
|
||||
|
||||
所以这份融合版方案的核心思路是:
|
||||
|
||||
## 核心原则
|
||||
|
||||
**先做“本地数据可交互地球”,再做“真实后端对接”。**
|
||||
|
||||
也就是把一期再拆成两个更稳的里程碑:
|
||||
|
||||
### 里程碑 A:本地演示版
|
||||
先不接后端,只做:
|
||||
|
||||
- UE5 项目能打开
|
||||
- Cesium 地球能显示
|
||||
- 本地 JSON 里的点能正确落到地球
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能正常显示假状态
|
||||
|
||||
### 里程碑 B:后端接入版
|
||||
在 A 的基础上再做:
|
||||
|
||||
- HTTP 拉取真实后端数据
|
||||
- 显示真实 TOP500 数据
|
||||
- 显示后端在线状态
|
||||
- 为后续扩展海缆/BGP/卫星打基础
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
- 把问题拆开
|
||||
- 更容易调试
|
||||
- 更适合 UE 新手
|
||||
- 不会因为后端联调没通就把整个 UE 开发节奏打断
|
||||
|
||||
---
|
||||
|
||||
# 二、一期目标:做什么,不做什么
|
||||
|
||||
## 这次一期一定要做的
|
||||
|
||||
做一个 **可用的 UE5 客户端 MVP**,达到以下 6 项:
|
||||
|
||||
1. 能打开 UE 项目并看到 3D 地球
|
||||
2. 能在地球上显示超算数据点
|
||||
3. 能点击数据点弹出信息卡
|
||||
4. 能显示一个基础 HUD
|
||||
5. 能通过 HTTP 接入后端数据
|
||||
6. 能打包成 Windows 可执行程序
|
||||
|
||||
---
|
||||
|
||||
## 这次一期先不做的
|
||||
|
||||
这些全部放到后续阶段:
|
||||
|
||||
- 海缆路径渲染
|
||||
- 卫星轨迹与卫星图层
|
||||
- BGP 图层
|
||||
- WebSocket 实时更新
|
||||
- 粒子特效大升级
|
||||
- 自动巡航
|
||||
- 多屏/3D 偏振/大屏联动
|
||||
|
||||
一句话:
|
||||
|
||||
**一期不是“把 Web Earth 全搬到 UE”,而是“证明 UE 客户端链路能跑通”。**
|
||||
|
||||
---
|
||||
|
||||
# 三、UE 专有名词字典(零基础版)
|
||||
|
||||
这部分你最好先读一遍。后面所有步骤都围绕这些词。
|
||||
|
||||
## 1. Actor
|
||||
**Actor = 场景里的一个对象**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 一个地球控制器
|
||||
- 一个超算点
|
||||
- 一台相机
|
||||
- 一条海缆
|
||||
|
||||
这些在 UE 里都可以是 Actor。
|
||||
|
||||
---
|
||||
|
||||
## 2. Component
|
||||
**Component = 挂在 Actor 身上的功能零件**
|
||||
|
||||
比如一个超算点 Actor,可能有:
|
||||
|
||||
- 一个球形外观
|
||||
- 一个碰撞盒
|
||||
- 一个标签
|
||||
- 一个发光效果
|
||||
|
||||
这些零件就是 Component。
|
||||
|
||||
一句话:
|
||||
|
||||
**Actor 是整台机器,Component 是机器上的零件。**
|
||||
|
||||
---
|
||||
|
||||
## 3. Blueprint(蓝图)
|
||||
**Blueprint = UE 的可视化编程系统**
|
||||
|
||||
你不用先写代码,而是把很多“逻辑节点”拖出来,用线连接起来。
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 前端里的函数 + 事件监听
|
||||
- 只不过不是写文本代码,而是连线
|
||||
|
||||
---
|
||||
|
||||
## 4. Level / Map(关卡)
|
||||
**Level = 一个场景文件**
|
||||
|
||||
你可以把它理解成 Three.js 的一个 Scene。
|
||||
|
||||
本期只需要一个主场景:
|
||||
|
||||
- `Main`
|
||||
|
||||
---
|
||||
|
||||
## 5. Widget / UMG
|
||||
**Widget = UI 组件**
|
||||
**UMG = UE 的 UI 编辑系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 信息卡
|
||||
- 状态栏
|
||||
- 右上角连接状态
|
||||
- 图例
|
||||
- HUD 面板
|
||||
|
||||
这些都用 Widget 做。
|
||||
|
||||
---
|
||||
|
||||
## 6. Material(材质)
|
||||
**Material = 决定物体外观的系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 球体是什么颜色
|
||||
- 是否发光
|
||||
- 是否透明
|
||||
- 是否随性能大小变亮
|
||||
|
||||
这些都由材质控制。
|
||||
|
||||
---
|
||||
|
||||
## 7. Static Mesh
|
||||
**Static Mesh = 不会变形的 3D 模型**
|
||||
|
||||
比如:
|
||||
|
||||
- 球
|
||||
- 立方体
|
||||
- 平面
|
||||
- 某个固定模型
|
||||
|
||||
超算点一期里可以先直接用球体 Static Mesh。
|
||||
|
||||
---
|
||||
|
||||
## 8. Pawn
|
||||
**Pawn = 玩家控制的对象**
|
||||
|
||||
一期里你可以把它理解成:
|
||||
|
||||
- 带相机的飞行控制器
|
||||
|
||||
---
|
||||
|
||||
## 9. PlayerController
|
||||
**PlayerController = 处理输入的对象**
|
||||
|
||||
比如:
|
||||
|
||||
- 鼠标点击
|
||||
- 拖拽
|
||||
- 滚轮缩放
|
||||
|
||||
这些都由 PlayerController 或其相关逻辑来处理。
|
||||
|
||||
---
|
||||
|
||||
## 10. GameMode
|
||||
**GameMode = 游戏/场景的主规则配置入口**
|
||||
|
||||
它决定:
|
||||
|
||||
- 默认用哪个 Pawn
|
||||
- 默认用哪个 PlayerController
|
||||
|
||||
你可以把它理解成“主入口配置”。
|
||||
|
||||
---
|
||||
|
||||
## 11. Viewport
|
||||
**Viewport = 你看 3D 场景的窗口**
|
||||
|
||||
就是 UE 编辑器中间那块 3D 视图。
|
||||
|
||||
---
|
||||
|
||||
## 12. Outliner
|
||||
**Outliner = 当前场景对象列表**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- Scene 树
|
||||
- DOM 树
|
||||
- 资源树
|
||||
|
||||
---
|
||||
|
||||
## 13. Details Panel
|
||||
**Details Panel = 选中对象后的属性面板**
|
||||
|
||||
相当于“右侧属性编辑器”。
|
||||
|
||||
---
|
||||
|
||||
## 14. Cesium for Unreal
|
||||
**Cesium for Unreal = UE 里的地球插件**
|
||||
|
||||
它负责:
|
||||
|
||||
- 真实地球
|
||||
- 卫星影像
|
||||
- 地形
|
||||
- 经纬度坐标和 UE 世界坐标的转换
|
||||
|
||||
如果没有它,你得自己处理地球和坐标系统,会非常难。
|
||||
|
||||
---
|
||||
|
||||
## 15. Struct(结构体)
|
||||
**Struct = 数据结构定义**
|
||||
|
||||
你可以把它理解成 TypeScript 里的 `interface`。
|
||||
|
||||
比如:
|
||||
|
||||
```ts
|
||||
interface ComputePoint {
|
||||
id: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
performance: number
|
||||
}
|
||||
```
|
||||
|
||||
在 UE 里这类东西叫 Struct。
|
||||
|
||||
---
|
||||
|
||||
## 16. Event Dispatcher
|
||||
**Event Dispatcher = 事件分发器**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- EventEmitter
|
||||
- 发布订阅
|
||||
|
||||
比如:
|
||||
|
||||
“数据加载完毕”这个事件,就可以分发给其他蓝图。
|
||||
|
||||
---
|
||||
|
||||
## 17. Spline
|
||||
**Spline = 一条平滑曲线**
|
||||
|
||||
后面做海缆、轨迹时非常有用。
|
||||
一期可以先知道这个词,不一定马上用。
|
||||
|
||||
---
|
||||
|
||||
## 18. Niagara
|
||||
**Niagara = UE 粒子特效系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 流光
|
||||
- 光晕
|
||||
- 拖尾
|
||||
- 火花
|
||||
|
||||
一期先不重点碰它。
|
||||
|
||||
---
|
||||
|
||||
# 四、你的真实开发策略:两阶段起步
|
||||
|
||||
这是这份融合版和原方案最大的区别。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 A:本地演示版(先脱离后端)
|
||||
|
||||
### 目标
|
||||
先把下面这些完全打通:
|
||||
|
||||
- UE 项目启动正常
|
||||
- Cesium 地球正常
|
||||
- 相机可操作
|
||||
- 本地 JSON 文件能生成地球标记点
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能显示假数据
|
||||
|
||||
### 为什么一定要先做这个
|
||||
因为如果你一上来就接真实后端,你会同时碰到:
|
||||
|
||||
- WSL2 到 Windows 网络
|
||||
- Docker 端口映射
|
||||
- UE HTTP 请求
|
||||
- 蓝图 JSON 解析
|
||||
- Cesium 坐标转换
|
||||
- 标记点生成
|
||||
|
||||
新手很容易直接乱掉。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:后端接入版(再联调)
|
||||
|
||||
### 目标
|
||||
在 A 的基础上,加上:
|
||||
|
||||
- HTTP 拉真实后端数据
|
||||
- 显示真实 TOP500 点
|
||||
- 右上角显示后端在线状态
|
||||
- 为后续做更多图层留下数据接入层
|
||||
|
||||
---
|
||||
|
||||
# 五、环境准备
|
||||
|
||||
## 1. 你要安装的软件
|
||||
|
||||
### Epic Games Launcher
|
||||
用来下载和启动 UE。
|
||||
|
||||
### Unreal Engine 5.4
|
||||
建议直接用 5.4 稳定版。
|
||||
|
||||
### Visual Studio 2022
|
||||
虽然一期主要用 Blueprint,但 UE 的很多项目依赖 VS 环境。
|
||||
|
||||
安装组件:
|
||||
- Desktop development with C++
|
||||
- Game development with C++
|
||||
|
||||
### Git
|
||||
用来管理文档和后续工程。
|
||||
|
||||
### Cesium for Unreal
|
||||
用来做地球。
|
||||
|
||||
---
|
||||
|
||||
## 2. 你的环境约束
|
||||
|
||||
你现在是:
|
||||
|
||||
- 后端可能跑在 WSL2 / Docker
|
||||
- UE 必须跑在 Windows
|
||||
|
||||
所以你的真实运行方式通常会是:
|
||||
|
||||
- **Windows** 运行 UE5
|
||||
- **WSL2** 运行后端
|
||||
- 两者通过 HTTP 通信
|
||||
|
||||
这里最关键的一条是:
|
||||
|
||||
**不要默认 `localhost` 一定能通,必须先在 Windows 浏览器里验证。**
|
||||
|
||||
---
|
||||
|
||||
# 六、推荐的项目结构
|
||||
|
||||
## UE 项目目录内的 Content 结构
|
||||
|
||||
```text
|
||||
Content/
|
||||
Blueprints/
|
||||
Data/
|
||||
Widgets/
|
||||
Materials/
|
||||
Levels/
|
||||
FX/
|
||||
Textures/
|
||||
```
|
||||
|
||||
建议说明:
|
||||
|
||||
- `Blueprints/` 放逻辑蓝图
|
||||
- `Data/` 放本地 JSON、DataTable、Struct
|
||||
- `Widgets/` 放 UI
|
||||
- `Materials/` 放材质
|
||||
- `Levels/` 放场景
|
||||
- `FX/` 放特效
|
||||
- `Textures/` 放贴图
|
||||
|
||||
---
|
||||
|
||||
# 七、一期最小蓝图清单
|
||||
|
||||
一期只需要这几个核心蓝图。
|
||||
|
||||
## 1. `BP_GlobeCamera`
|
||||
作用:相机控制器
|
||||
|
||||
负责:
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
- 初始视角控制
|
||||
|
||||
---
|
||||
|
||||
## 2. `BP_PlanetGameMode`
|
||||
作用:指定默认的 Pawn 等
|
||||
|
||||
---
|
||||
|
||||
## 3. `BP_DataLoader`
|
||||
作用:负责读数据
|
||||
|
||||
一期建议支持两种来源:
|
||||
|
||||
- 本地 JSON
|
||||
- HTTP 接口
|
||||
|
||||
这样调试更稳。
|
||||
|
||||
---
|
||||
|
||||
## 4. `BP_ComputePoint`
|
||||
作用:一个超算点的显示对象
|
||||
|
||||
负责:
|
||||
- 接收一条数据
|
||||
- 放到正确经纬度位置
|
||||
- 显示外观
|
||||
- 处理点击
|
||||
|
||||
---
|
||||
|
||||
## 5. `WBP_InfoCard`
|
||||
作用:点开后显示详情
|
||||
|
||||
显示:
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
- 可选显示更多字段
|
||||
|
||||
---
|
||||
|
||||
## 6. `WBP_StatusBar`
|
||||
作用:右上角状态栏
|
||||
|
||||
显示:
|
||||
- 后端在线/离线
|
||||
- 当前加载条数
|
||||
- 当前模式(本地数据 / 真实后端)
|
||||
|
||||
---
|
||||
|
||||
# 八、数据层设计
|
||||
|
||||
一期不要一开始就完全照搬后端返回结构。
|
||||
你要先定义一个 UE 友好的结构。
|
||||
|
||||
## `S_ComputePoint`
|
||||
|
||||
字段建议:
|
||||
|
||||
- `PointId`:字符串,唯一 ID
|
||||
- `Name`:字符串
|
||||
- `Latitude`:浮点
|
||||
- `Longitude`:浮点
|
||||
- `Performance`:浮点
|
||||
- `CoreCount`:整数
|
||||
- `Country`:字符串
|
||||
- `Source`:字符串
|
||||
|
||||
这个结构同时适用于:
|
||||
|
||||
- 本地 JSON
|
||||
- 后端 API 返回结果转换后的对象
|
||||
|
||||
---
|
||||
|
||||
# 九、最稳的执行路线
|
||||
|
||||
下面是整个实施计划最重要的部分。
|
||||
|
||||
---
|
||||
|
||||
# Phase 0:安装和验证环境
|
||||
|
||||
## 目标
|
||||
确保你能:
|
||||
|
||||
- 安装 UE5.4
|
||||
- 启用 Cesium
|
||||
- 能打开一个空项目
|
||||
- 能在 Windows 浏览器访问你的后端
|
||||
|
||||
## 验收
|
||||
满足以下 4 条:
|
||||
|
||||
- UE 能打开
|
||||
- Cesium 能启用
|
||||
- 项目能创建
|
||||
- Windows 浏览器能访问后端 summary 接口
|
||||
|
||||
如果第 4 条做不到,不要继续推进真实接口联调。
|
||||
|
||||
---
|
||||
|
||||
# Phase 1:创建项目并把地球显示出来
|
||||
|
||||
## 目标
|
||||
打开项目后,能看到一个真实地球。
|
||||
|
||||
## 操作顺序
|
||||
|
||||
1. 新建 UE5 Blank Blueprint 项目
|
||||
2. 创建 `Main` 场景
|
||||
3. 启用 Cesium
|
||||
4. 添加:
|
||||
- `Cesium World Terrain`
|
||||
- `Cesium Sun Sky`
|
||||
- `CesiumGeoreference`
|
||||
5. 调整视角,让你能看到整个地球
|
||||
|
||||
## 验收
|
||||
能录一段短视频,里面能看到地球和镜头移动。
|
||||
|
||||
---
|
||||
|
||||
# Phase 2:做相机控制
|
||||
|
||||
## 目标
|
||||
让地球可以:
|
||||
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
|
||||
## 说明
|
||||
这里可以沿用原 MVP 方案的思路:
|
||||
|
||||
- `BP_GlobeCamera` 作为 Pawn
|
||||
- Spring Arm + Camera 组成相机结构
|
||||
- 用输入控制旋转和缩放
|
||||
|
||||
## 注意
|
||||
这一版相机只是“一期可用版”,不是最终镜头系统。
|
||||
|
||||
## 验收
|
||||
按 Play 后:
|
||||
|
||||
- 地球可旋转
|
||||
- 可缩放
|
||||
- 不会直接飞走或抖动失控
|
||||
|
||||
---
|
||||
|
||||
# Phase 3:先喂本地 JSON 数据
|
||||
|
||||
这是融合版方案里最关键的改动。
|
||||
|
||||
## 目标
|
||||
不接后端,先验证:
|
||||
|
||||
- 数据结构正常
|
||||
- JSON 能读
|
||||
- 点能生成
|
||||
- 点击交互正常
|
||||
|
||||
## 为什么先这么做
|
||||
因为这样可以把问题收缩成 3 件事:
|
||||
|
||||
- Cesium 坐标转换
|
||||
- 点渲染
|
||||
- UI 弹窗
|
||||
|
||||
不牵涉后端联调。
|
||||
|
||||
## 本地 JSON 示例格式
|
||||
|
||||
建议放在 `Content/Data/compute_points.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
},
|
||||
{
|
||||
"PointId": "top500_2",
|
||||
"Name": "Fugaku",
|
||||
"Latitude": 34.69,
|
||||
"Longitude": 135.19,
|
||||
"Performance": 442.0,
|
||||
"CoreCount": 7630848,
|
||||
"Country": "JP",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 推荐做法
|
||||
先做一个“本地模式”开关。
|
||||
|
||||
在 `BP_DataLoader` 里支持:
|
||||
|
||||
- Mode = LocalJson
|
||||
- Mode = HttpApi
|
||||
|
||||
先永远跑 `LocalJson`。
|
||||
|
||||
## 验收
|
||||
你应该能看到:
|
||||
|
||||
- 多个点出现在地球上
|
||||
- 大致位置正确
|
||||
- 点击能弹信息卡
|
||||
|
||||
---
|
||||
|
||||
# Phase 4:做超算点蓝图
|
||||
|
||||
## 目标
|
||||
完成 `BP_ComputePoint`
|
||||
|
||||
每个点要实现:
|
||||
|
||||
- 接收一条 `S_ComputePoint`
|
||||
- 经度纬度转成 UE 世界坐标
|
||||
- 在地球上显示为一个可见的发光球
|
||||
- 支持被点击
|
||||
|
||||
## 显示建议
|
||||
|
||||
### 外观
|
||||
先用最简单的球体 Static Mesh。
|
||||
|
||||
### 材质
|
||||
做一个发光材质:
|
||||
|
||||
- 红橙色
|
||||
- 自发光
|
||||
- 不追求复杂效果
|
||||
|
||||
### 大小
|
||||
球体要足够大,确保在地球尺度下看得见。
|
||||
|
||||
### 高度
|
||||
不要贴地表太近,建议悬浮在地表上方一个固定高度。
|
||||
|
||||
## 验收
|
||||
同一批数据点在地球上的位置大体合理。
|
||||
|
||||
---
|
||||
|
||||
# Phase 5:做信息卡
|
||||
|
||||
## 目标
|
||||
点击一个点后,弹出一个简单的信息卡。
|
||||
|
||||
## `WBP_InfoCard` 要显示的内容
|
||||
建议只显示最关键的 3 个字段:
|
||||
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
|
||||
一期先不要堆太多字段。
|
||||
|
||||
## 验收
|
||||
点击点 → 卡片出现
|
||||
点击关闭 → 卡片消失
|
||||
|
||||
---
|
||||
|
||||
# Phase 6:做基础 HUD
|
||||
|
||||
## 目标
|
||||
屏幕上始终有一个简单状态栏。
|
||||
|
||||
## `WBP_StatusBar` 显示内容建议
|
||||
- 当前模式:Local / HTTP
|
||||
- 已加载数据点数量
|
||||
- 后端状态:Unknown / Online / Offline
|
||||
|
||||
在本地模式阶段,状态可以先写死或显示 `Local Demo`。
|
||||
|
||||
## 验收
|
||||
不点击任何点时,屏幕右上角也有“系统正在工作”的感觉。
|
||||
|
||||
---
|
||||
|
||||
# Phase 7:再接真实后端
|
||||
|
||||
这是第二阶段开始。
|
||||
|
||||
## 目标
|
||||
把数据源从本地 JSON 切到 HTTP。
|
||||
|
||||
## 正确做法
|
||||
不要把 `BP_DataLoader` 重写。
|
||||
而是让它支持:
|
||||
|
||||
- LocalJsonLoader
|
||||
- HttpLoader
|
||||
|
||||
也就是:
|
||||
|
||||
**显示层不变,只替换数据来源。**
|
||||
|
||||
## 最重要的接口原则
|
||||
如果后端已有接口字段非常杂,不一定要 UE 直接吃。
|
||||
可以加一个“更适合 UE 的轻量接口”。
|
||||
|
||||
例如:
|
||||
|
||||
`/api/v1/ue/bootstrap/top500`
|
||||
|
||||
返回尽量扁平的数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 为什么推荐 UE 轻量接口
|
||||
因为 UE 不适合像前端 React 那样,层层解包一大堆复杂 JSON。
|
||||
|
||||
---
|
||||
|
||||
# Phase 8:做连接状态检测
|
||||
|
||||
## 目标
|
||||
让 HUD 能显示:
|
||||
|
||||
- 在线
|
||||
- 离线
|
||||
- 本地模式
|
||||
|
||||
## 正确实现思路
|
||||
建议用一个很小的状态请求,比如:
|
||||
|
||||
- summary 接口
|
||||
- health 接口
|
||||
- 或 UE 专用 ping 接口
|
||||
|
||||
不要让状态检测去依赖一个超大的数据接口。
|
||||
|
||||
## 验收
|
||||
后端关掉时,状态栏能明显变成 Offline。
|
||||
|
||||
---
|
||||
|
||||
# Phase 9:打包发布
|
||||
|
||||
## 目标
|
||||
把项目打包成 Windows 可执行程序。
|
||||
|
||||
## 注意
|
||||
打包是一期必须尝试的,但不要让它阻塞前面所有开发。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 编辑器里没稳定跑通前,不要反复纠结打包
|
||||
- 等 LocalJson 版和 HTTP 版都能在编辑器 Play 模式稳定运行后,再打包
|
||||
|
||||
## 验收
|
||||
双击 exe 可以运行,进入地球场景并正常展示数据。
|
||||
|
||||
---
|
||||
|
||||
# 十、建议的 14 天执行计划
|
||||
|
||||
这版比原 MVP 的时间估计更保守,也更适合新手。
|
||||
|
||||
## 第 1 天
|
||||
- 安装 UE5.4
|
||||
- 安装 Cesium
|
||||
- 创建空项目
|
||||
- 创建 Main 场景
|
||||
|
||||
## 第 2 天
|
||||
- 启用 Cesium
|
||||
- 把地球跑起来
|
||||
- 保存项目结构
|
||||
|
||||
## 第 3 天
|
||||
- 做 `BP_GlobeCamera`
|
||||
- 跑通旋转和缩放
|
||||
|
||||
## 第 4 天
|
||||
- 建 `S_ComputePoint`
|
||||
- 准备本地 JSON 文件
|
||||
- 做 `BP_DataLoader` 的本地模式
|
||||
|
||||
## 第 5 天
|
||||
- 做 `BP_ComputePoint`
|
||||
- 本地 JSON 批量生成点
|
||||
|
||||
## 第 6 天
|
||||
- 调整点大小、颜色、高度
|
||||
- 检查经纬度位置是否大致正确
|
||||
|
||||
## 第 7 天
|
||||
- 做 `WBP_InfoCard`
|
||||
- 跑通点击点弹卡片
|
||||
|
||||
## 第 8 天
|
||||
- 做 `WBP_StatusBar`
|
||||
- 显示本地模式状态和点数量
|
||||
|
||||
## 第 9 天
|
||||
- Windows 浏览器验证后端接口
|
||||
- 准备 HTTP 版加载逻辑
|
||||
|
||||
## 第 10 天
|
||||
- 实现 HTTP 拉真实数据
|
||||
- 先在日志里确认数据到了
|
||||
|
||||
## 第 11 天
|
||||
- 把 HTTP 数据接到点渲染
|
||||
- 切换 Local / HTTP 两种模式
|
||||
|
||||
## 第 12 天
|
||||
- 做连接状态 Online / Offline
|
||||
- 补错误提示
|
||||
|
||||
## 第 13 天
|
||||
- 测试完整链路
|
||||
- 修点选、缩放、HUD 细节
|
||||
|
||||
## 第 14 天
|
||||
- 进行第一次打包
|
||||
- 在 Windows 下运行 exe 验证
|
||||
|
||||
---
|
||||
|
||||
# 十一、这份方案和原 MVP 方案怎么融合
|
||||
|
||||
下面是合并关系。
|
||||
|
||||
## 保留原 MVP 方案的部分
|
||||
这些内容很好,建议继续用:
|
||||
|
||||
- 术语表
|
||||
- Phase 结构化写法
|
||||
- `BP_GlobeCamera`
|
||||
- `BP_ComputePoint`
|
||||
- `WBP_InfoCard`
|
||||
- `WBP_StatusBar`
|
||||
- 相机、点、信息卡、状态栏这 4 个核心对象
|
||||
- “先别做海缆、卫星、BGP”的范围控制
|
||||
|
||||
## 用融合版修正的部分
|
||||
这些是这份新文档加进去的:
|
||||
|
||||
- 两阶段起步:先本地 JSON,再真实后端
|
||||
- 不默认 `localhost` 一定通
|
||||
- 推荐做 UE 轻量接口,而不是死扛原始接口
|
||||
- 把打包放到后段,而不是过早纠结
|
||||
- 时间预估更保守
|
||||
- 明确“一期只是证明链路跑通”
|
||||
|
||||
---
|
||||
|
||||
# 十二、验收清单
|
||||
|
||||
## 环境
|
||||
- [ ] UE5.4 安装成功
|
||||
- [ ] Cesium 插件启用成功
|
||||
- [ ] Windows 能访问后端接口
|
||||
|
||||
## 本地演示版
|
||||
- [ ] 地球渲染正常
|
||||
- [ ] 鼠标可旋转和缩放
|
||||
- [ ] 本地 JSON 数据能生成点
|
||||
- [ ] 点的位置大体正确
|
||||
- [ ] 点击点能弹信息卡
|
||||
- [ ] HUD 可显示本地模式和点数量
|
||||
|
||||
## 后端接入版
|
||||
- [ ] HTTP 能拉取真实数据
|
||||
- [ ] HTTP 数据能生成点
|
||||
- [ ] HUD 能显示 Online/Offline
|
||||
- [ ] 切换 Local / HTTP 模式不崩
|
||||
- [ ] exe 能打包并运行
|
||||
|
||||
---
|
||||
|
||||
# 十三、后续路线(MVP 之后)
|
||||
|
||||
当这一期做完后,下一步顺序建议是:
|
||||
|
||||
1. 海缆路径
|
||||
2. 卫星点或轨迹
|
||||
3. 更稳的相机与巡航
|
||||
4. WebSocket 增量更新
|
||||
5. BGP 区域态势
|
||||
6. BGP 事件点
|
||||
7. 更强的粒子和视觉风格
|
||||
|
||||
也就是说:
|
||||
|
||||
**先补“静态层和镜头层”,再补“高频实时层”。**
|
||||
|
||||
---
|
||||
|
||||
# 十四、一句话总结
|
||||
|
||||
这份融合版方案的核心就是:
|
||||
|
||||
**保留原 MVP 的入门友好度,但改成“先本地 JSON、再真实后端”的两阶段实施路线,让你第一次做 UE 时更稳、更容易成功。**
|
||||
|
||||
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
|
||||
|
||||
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
|
||||
@@ -16,12 +16,19 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.2`
|
||||
- `dev` 当前开发分支历史推导到:`0.28.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab,并稳定 TV/news 的 reform、resize 与共享 HUD 行为 |
|
||||
| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 |
|
||||
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
|
||||
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复,Playground 响应式按钮与输入框收起优化 |
|
||||
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
|
||||
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |
|
||||
| `0.27.3` | improvement | `dev` | — | TV panel 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 |
|
||||
| `0.27.2` | improvement | `dev` | — | 修复 brand copy 宽度问题,提取 --brand-copy-width CSS 变量 |
|
||||
| `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 |
|
||||
| `0.27.0` | feature | `dev` | — | Earth HUD 重构:图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.27.2",
|
||||
"version": "0.28.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
--hud-font-size: calc(0.88rem * var(--hud-scale));
|
||||
--hud-font-size-sm: calc(0.75rem * var(--hud-scale));
|
||||
--hud-title-size: calc(1.02rem * var(--hud-scale));
|
||||
--hud-panel-header-title-size: calc(0.82rem * var(--hud-scale));
|
||||
--hud-kicker-size: calc(0.68rem * var(--hud-scale));
|
||||
--hud-surface-top: rgba(17, 31, 53, 0.84);
|
||||
--hud-surface-bottom: rgba(7, 17, 31, 0.76);
|
||||
|
||||
@@ -28,20 +28,22 @@
|
||||
|
||||
.stats-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.64rem * var(--hud-scale));
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Reuse hud-panel-close — just override size to match kicker line */
|
||||
.stats-drag-bar .hud-panel-close {
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.stats-drag-bar .hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(12px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── 2-column KPI grid ────────────────────────────────────────── */
|
||||
|
||||
@@ -65,20 +65,6 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hud-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--hud-gap-sm);
|
||||
margin-bottom: var(--hud-gap-sm);
|
||||
padding-bottom: var(--hud-gap-sm);
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.hud-panel-header .hud-panel-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hud-panel-drag-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -88,19 +74,72 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel__header,
|
||||
.hud-panel-header {
|
||||
--hud-header-padding: 0 0 var(--hud-gap-sm);
|
||||
--hud-header-gap: var(--hud-gap-sm);
|
||||
--hud-header-border-color: var(--hud-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--hud-header-gap);
|
||||
margin-bottom: var(--hud-gap-sm);
|
||||
padding: var(--hud-header-padding);
|
||||
border-bottom: 1px solid var(--hud-header-border-color);
|
||||
}
|
||||
|
||||
.hud-panel__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.hud-panel__title,
|
||||
.hud-panel__header .hud-panel-title,
|
||||
.hud-panel-header .hud-panel-title {
|
||||
margin: 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hud-panel__subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hud-panel__chip {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hud-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hud-panel__action,
|
||||
.hud-panel-close {
|
||||
align-self: flex-start;
|
||||
width: calc(var(--hud-title-size) * 1.24);
|
||||
height: calc(var(--hud-title-size) * 1.24);
|
||||
min-width: calc(var(--hud-title-size) * 1.24);
|
||||
padding: 0;
|
||||
--hud-action-padding: calc(7px * var(--hud-scale));
|
||||
--hud-action-icon-size: calc(16px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
padding: var(--hud-action-padding);
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: auto;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
@@ -110,17 +149,51 @@
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hud-panel__action .material-symbols-rounded,
|
||||
.hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(var(--hud-title-size) * 0.8);
|
||||
font-size: var(--hud-action-icon-size);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-panel-close:hover {
|
||||
.hud-panel__action:hover:not(:disabled),
|
||||
.hud-panel-close:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.hud-panel__action:disabled,
|
||||
.hud-panel-close:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hud-panel__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hud-panel__body--collapsible {
|
||||
--hud-body-collapse-gap: var(--hud-gap-sm);
|
||||
--hud-body-max-height: 1000px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
max-height: var(--hud-body-max-height);
|
||||
transition:
|
||||
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease,
|
||||
margin 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel--collapsed .hud-panel__body--collapsible {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: calc(-1 * var(--hud-body-collapse-gap));
|
||||
}
|
||||
|
||||
.hud-panel.is-dragging {
|
||||
transition: none !important;
|
||||
box-shadow:
|
||||
@@ -311,10 +384,22 @@
|
||||
.earth-settings-title {
|
||||
margin: 4px 0 0;
|
||||
color: var(--hud-title);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-settings-close {
|
||||
margin-top: 4px;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-content {
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
.info-card-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: calc(0.92rem * var(--hud-scale));
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
color: var(--hud-title);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@@ -170,6 +170,15 @@
|
||||
|
||||
.info-card-close {
|
||||
flex-shrink: 0;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-content {
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
.layer-panel-title {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
@@ -51,55 +51,71 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(22px * var(--hud-scale));
|
||||
height: calc(22px * var(--hud-scale));
|
||||
min-width: calc(22px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-panel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.layer-panel-btn .material-symbols-rounded {
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
/* Chevron rotates when collapsed */
|
||||
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
transition: color 0.18s ease;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
/* ── Search bar ───────────────────────────────────────────────── */
|
||||
|
||||
.layer-panel-search {
|
||||
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.layer-panel-search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
padding: calc(5px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
border: 1px solid rgba(201, 225, 247, 0.14);
|
||||
border-radius: calc(8px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transition: border-color 0.18s ease;
|
||||
box-sizing: border-box;
|
||||
height: calc(38px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.layer-panel-search-box:focus-within {
|
||||
border-color: rgba(201, 225, 247, 0.28);
|
||||
}
|
||||
|
||||
.layer-panel-search-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-panel-search-icon.material-symbols-rounded {
|
||||
font-size: calc(20px * var(--hud-scale));
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
.layer-panel-search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -27,38 +27,38 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Mode tabs ────────────────────────────────────────────────── */
|
||||
/* ── Current mode label ───────────────────────────────────────── */
|
||||
|
||||
.legend-tabs {
|
||||
.legend-current {
|
||||
display: flex;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
align-items: center;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-tab {
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease;
|
||||
.legend-title {
|
||||
flex: 0 0 auto;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-tab--active {
|
||||
.legend-current-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid rgba(120, 180, 255, 0.2);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
border-color: rgba(120, 180, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Bar action buttons ───────────────────────────────────────── */
|
||||
@@ -66,7 +66,7 @@
|
||||
.legend-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -74,54 +74,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
|
||||
.legend-bar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-bar-btn .material-symbols-rounded {
|
||||
font-size: calc(13px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Collapse chevron */
|
||||
#legend-collapse .material-symbols-rounded {
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
.legend--collapsed #legend-collapse .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ── Collapsible list body ────────────────────────────────────── */
|
||||
|
||||
.legend-body {
|
||||
max-height: calc(220px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend--collapsed .legend-body {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
--hud-body-collapse-gap: calc(4px * var(--hud-scale));
|
||||
--hud-body-max-height: calc(220px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── Item list ────────────────────────────────────────────────── */
|
||||
|
||||
193
frontend/public/earth/css/news-panel.css
Normal file
193
frontend/public/earth/css/news-panel.css
Normal file
@@ -0,0 +1,193 @@
|
||||
/* news-panel.css */
|
||||
|
||||
.news-panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.news-panel-title {
|
||||
margin: 0;
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.92rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.news-region-chip {
|
||||
--news-accent: #d6e6ff;
|
||||
border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
color: color-mix(in srgb, var(--news-accent) 82%, white);
|
||||
background: color-mix(in srgb, var(--news-accent) 12%, transparent);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-panel-subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-panel-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-panel-focus {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 16% 18%, rgba(123, 205, 255, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(104, 166, 232, 0.04));
|
||||
border: 1px solid rgba(205, 231, 255, 0.08);
|
||||
}
|
||||
|
||||
.news-focus-kicker,
|
||||
.news-board-status {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-focus-label {
|
||||
margin-top: calc(4px * var(--hud-scale));
|
||||
color: var(--hud-text);
|
||||
font-size: calc(1rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.news-focus-coords {
|
||||
margin-top: calc(3px * var(--hud-scale));
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-source-count {
|
||||
align-self: start;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-board {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-board-list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
padding-right: calc(4px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 220, 255, 0.36) transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 237, 255, 0.24), rgba(110, 176, 255, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.news-story-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
text-decoration: none;
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
border: 1px solid rgba(201, 225, 247, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(92, 151, 218, 0.03));
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.news-story-card:hover {
|
||||
border-color: rgba(214, 235, 255, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(92, 151, 218, 0.06));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.news-story-card--focus {
|
||||
border-color: rgba(127, 219, 255, 0.22);
|
||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.news-story-source,
|
||||
.news-story-time,
|
||||
.news-story-tag {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-story-source {
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.news-story-title {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.9rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.news-story-summary {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.news-story-tag {
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.news-board-empty {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
line-height: 1.5;
|
||||
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
|
||||
}
|
||||
@@ -5,18 +5,85 @@
|
||||
right: var(--hud-offset);
|
||||
width: calc(420px * var(--hud-scale));
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - (2 * var(--hud-offset)));
|
||||
min-width: calc(300px * var(--hud-scale));
|
||||
min-height: calc(340px * var(--hud-scale));
|
||||
padding: calc(18px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
z-index: 18;
|
||||
}
|
||||
|
||||
.tv-panel-header-copy {
|
||||
display: grid;
|
||||
gap: calc(3px * var(--hud-scale));
|
||||
.hud-panel-tv[data-active-tab="news"]:not([data-resized="true"]) {
|
||||
max-height: min(
|
||||
var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))),
|
||||
calc(100vh - (2 * var(--hud-offset)))
|
||||
);
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-reforming {
|
||||
transition:
|
||||
height 0.24s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: height, top;
|
||||
}
|
||||
|
||||
.hud-panel-tv .hud-panel__header {
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.tv-panel-header-title {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hud-panel-tv .hud-panel__header .hud-panel__action,
|
||||
.hud-panel-tv .hud-panel__header .hud-panel-close {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.hud-panel-tv .hud-panel__header .tv-panel-select,
|
||||
.hud-panel-tv .hud-panel__header .tv-panel-tab {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls--news {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tv-panel-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-tab-pane {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-panel-toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tv-panel-status {
|
||||
@@ -26,13 +93,6 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tv-panel-controls {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-sm);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -53,48 +113,19 @@
|
||||
color: #eef5fc;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-xs);
|
||||
.tv-panel-meta-wrap {
|
||||
overflow: hidden;
|
||||
max-height: calc(120px * var(--hud-scale));
|
||||
opacity: 1;
|
||||
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--hud-text);
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action--icon {
|
||||
padding: calc(9px * var(--hud-scale));
|
||||
border-radius: calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-action--icon .material-symbols-rounded {
|
||||
font-size: calc(18px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
.tv-panel-meta-wrap.is-collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-panel-action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.tv-panel-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
margin-top: calc(-1 * var(--hud-gap-sm));
|
||||
margin-bottom: calc(-1 * var(--hud-gap-sm));
|
||||
}
|
||||
|
||||
.tv-panel-meta {
|
||||
@@ -129,7 +160,7 @@
|
||||
|
||||
.tv-panel-player {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
flex: 1 0 auto;
|
||||
min-height: calc(220px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
@@ -163,33 +194,100 @@
|
||||
background: #050a14;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle {
|
||||
position: absolute;
|
||||
right: calc(8px * var(--hud-scale));
|
||||
bottom: calc(8px * var(--hud-scale));
|
||||
width: calc(18px * var(--hud-scale));
|
||||
height: calc(18px * var(--hud-scale));
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: nwse-resize;
|
||||
z-index: 2;
|
||||
.tv-panel-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle::before {
|
||||
.tv-panel-tab {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--hud-text-muted);
|
||||
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-tab:hover {
|
||||
color: var(--hud-text);
|
||||
border-color: rgba(214, 235, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.tv-panel-tab--active {
|
||||
color: var(--hud-accent-strong);
|
||||
border-color: rgba(120, 180, 255, 0.24);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
}
|
||||
|
||||
.tv-tab-pane[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Multi-edge resize handles ───────────────────────────────── */
|
||||
|
||||
.tv-panel-edge {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="r"] {
|
||||
right: 0;
|
||||
top: calc(12px * var(--hud-scale));
|
||||
bottom: calc(12px * var(--hud-scale));
|
||||
width: calc(6px * var(--hud-scale));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="b"] {
|
||||
bottom: 0;
|
||||
left: calc(12px * var(--hud-scale));
|
||||
right: calc(12px * var(--hud-scale));
|
||||
height: calc(6px * var(--hud-scale));
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="l"] {
|
||||
left: 0;
|
||||
top: calc(12px * var(--hud-scale));
|
||||
bottom: calc(12px * var(--hud-scale));
|
||||
width: calc(6px * var(--hud-scale));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="br"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="bl"] {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
/* 右下角视觉标记 */
|
||||
.tv-panel-edge[data-edge="br"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-right: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom-right-radius: calc(10px * var(--hud-scale));
|
||||
opacity: 0.78;
|
||||
transition: opacity 0.18s ease, border-color 0.18s ease;
|
||||
inset: calc(4px * var(--hud-scale));
|
||||
border-right: 2px solid rgba(223, 235, 248, 0.4);
|
||||
border-bottom: 2px solid rgba(223, 235, 248, 0.4);
|
||||
transition: border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle:hover::before {
|
||||
opacity: 1;
|
||||
border-color: rgba(244, 249, 255, 0.78);
|
||||
.tv-panel-edge[data-edge="br"]:hover::before {
|
||||
border-color: rgba(244, 249, 255, 0.75);
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-resizing {
|
||||
@@ -197,6 +295,25 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Whole panel is draggable; player overrides back to default */
|
||||
.hud-panel-tv:not(.is-resizing) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel-tv .tv-panel-player {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Disable iframe/video pointer capture while dragging so mouse events pass through */
|
||||
.hud-panel-tv.is-dragging .tv-panel-iframe,
|
||||
.hud-panel-tv.is-dragging .tv-panel-video {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<link rel="stylesheet" href="css/legend.css">
|
||||
<link rel="stylesheet" href="css/earth-stats.css">
|
||||
<link rel="stylesheet" href="css/tv-panel.css">
|
||||
<link rel="stylesheet" href="css/news-panel.css">
|
||||
<link rel="stylesheet" href="css/layer-panel.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
||||
@@ -50,7 +51,7 @@
|
||||
</defs>
|
||||
</svg>
|
||||
<div id="container" class="earth-app">
|
||||
<div class="earth-left-column">
|
||||
<div id="left-column" class="earth-left-column">
|
||||
<div id="brand-panel" class="hud-panel hud-panel-brand">
|
||||
<div id="brand-root"></div>
|
||||
</div>
|
||||
@@ -61,7 +62,7 @@
|
||||
<span class="material-symbols-rounded layer-panel-icon">layers</span>
|
||||
<span class="layer-panel-title">图层</span>
|
||||
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -69,18 +70,20 @@
|
||||
<div class="layer-panel-body" id="layer-panel-body">
|
||||
<!-- Search -->
|
||||
<div class="layer-panel-search">
|
||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||
<input
|
||||
type="text"
|
||||
id="layer-search-input"
|
||||
class="layer-panel-search-input"
|
||||
placeholder="搜索图层..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
<div class="layer-panel-search-box">
|
||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||
<input
|
||||
type="text"
|
||||
id="layer-search-input"
|
||||
class="layer-panel-search-input"
|
||||
placeholder="搜索图层..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layer rows -->
|
||||
@@ -143,20 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating detail panel — positioned near click by JS -->
|
||||
<div id="info-panel" class="hud-panel hud-panel-info hud-panel-draggable" aria-live="polite">
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="info-card-content" class="info-card-content"></div>
|
||||
</div>
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
</div>
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
@@ -182,6 +172,12 @@
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
<button id="toggle-news" class="floating-btn liquid-glass-surface earth-toolbar-btn active" title="全球态势新闻">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">newspaper</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开态势新闻</span>
|
||||
</button>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
@@ -228,24 +224,23 @@
|
||||
|
||||
|
||||
<div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend">
|
||||
<!-- Drag bar: mode tabs + collapse + close -->
|
||||
<!-- Drag bar: current mode + collapse + close -->
|
||||
<div class="legend-bar hud-panel-drag-handle">
|
||||
<div class="legend-tabs" id="legend-tabs">
|
||||
<button class="legend-tab legend-tab--active" data-legend-mode="cables">海缆</button>
|
||||
<button class="legend-tab" data-legend-mode="satellites">卫星</button>
|
||||
<button class="legend-tab" data-legend-mode="bgp">BGP</button>
|
||||
<div class="legend-current" id="legend-current">
|
||||
<span class="legend-title">图例</span>
|
||||
<span id="legend-current-label" class="legend-current-label">海缆</span>
|
||||
</div>
|
||||
<div class="legend-bar-actions">
|
||||
<button id="legend-collapse" class="legend-bar-btn" title="折叠">
|
||||
<button id="legend-collapse" class="legend-bar-btn hud-panel__action hud-panel__action--collapse" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="legend-bar-btn hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<button class="legend-bar-btn hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Collapsible list -->
|
||||
<div id="legend-body" class="legend-body">
|
||||
<div id="legend-body" class="legend-body hud-panel__body hud-panel__body--collapsible">
|
||||
<div class="legend-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,52 +294,104 @@
|
||||
<span id="camera-distance" hidden></span>
|
||||
</div>
|
||||
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<div class="tv-panel-header-copy">
|
||||
<h3 class="hud-panel-title">新闻直播</h3>
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true">
|
||||
<div class="hud-panel__header hud-panel-drag-handle">
|
||||
<div class="hud-panel__title-group">
|
||||
<span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span>
|
||||
</div>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tv-panel-controls">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-actions">
|
||||
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
<button id="tv-meta-toggle" class="hud-panel__action hud-panel__action--collapse tv-panel-meta-toggle" type="button" title="折叠新闻直播内容" aria-label="折叠新闻直播内容">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden>
|
||||
<div class="news-panel-title-row">
|
||||
<span class="news-panel-title">全球态势聚合</span>
|
||||
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
|
||||
</div>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud-panel__actions">
|
||||
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="tv-panel" aria-label="关闭媒体情报面板">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-panel-meta">
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
|
||||
<div class="tv-panel-content">
|
||||
<section id="tv-tab-live-pane" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live">
|
||||
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
|
||||
<div class="tv-panel-meta" id="tv-panel-meta">
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tv-tab-news-pane" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden>
|
||||
<div id="news-panel-body" class="news-panel-body">
|
||||
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
|
||||
|
||||
<div class="news-panel-focus">
|
||||
<div>
|
||||
<div class="news-focus-kicker">当前关注区域</div>
|
||||
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
|
||||
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
|
||||
</div>
|
||||
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
|
||||
</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>
|
||||
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
|
||||
</div>
|
||||
</div>
|
||||
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
|
||||
<div class="tv-panel-tabs" role="tablist" aria-label="媒体情报切换">
|
||||
<button id="tv-tab-live" class="tv-panel-tab tv-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-tab-live-pane">直播</button>
|
||||
<button id="tv-tab-news" class="tv-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="tv-tab-news-pane">聚合</button>
|
||||
</div>
|
||||
<button
|
||||
id="tv-resize-handle"
|
||||
class="tv-panel-resize-handle"
|
||||
type="button"
|
||||
aria-label="调整电视直播窗口大小"
|
||||
title="调整大小"
|
||||
></button>
|
||||
<div class="tv-panel-edge" data-edge="r"></div>
|
||||
<div class="tv-panel-edge" data-edge="b"></div>
|
||||
<div class="tv-panel-edge" data-edge="l"></div>
|
||||
<div class="tv-panel-edge" data-edge="br"></div>
|
||||
<div class="tv-panel-edge" data-edge="bl"></div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="earth-loading">
|
||||
|
||||
52
frontend/public/earth/js/controls.js
vendored
52
frontend/public/earth/js/controls.js
vendored
@@ -18,6 +18,11 @@ import {
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { ensureTVPanelReady } from "./tv.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import {
|
||||
ensureNewsPanelReady,
|
||||
updateNewsToggleUI,
|
||||
} from "./news.js";
|
||||
|
||||
export let autoRotate = true;
|
||||
export let zoomLevel = 1.0;
|
||||
@@ -91,6 +96,7 @@ function setHudPanelVisibility(panelId, visible) {
|
||||
syncSettingsToggle(panelId, visible);
|
||||
if (panelId === "tv-panel") {
|
||||
updateTVToggleUI(visible);
|
||||
updateNewsToggleUI(visible);
|
||||
if (visible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
@@ -174,7 +180,9 @@ function setupDraggableHudPanels() {
|
||||
if (!app || draggablePanels.length === 0) return;
|
||||
|
||||
draggablePanels.forEach((panel) => {
|
||||
const handle = panel.querySelector(".hud-panel-drag-handle");
|
||||
const handle = panel.dataset.dragSelf === "true"
|
||||
? panel
|
||||
: panel.querySelector(".hud-panel-drag-handle");
|
||||
if (!handle) return;
|
||||
|
||||
let isDragging = false;
|
||||
@@ -228,7 +236,7 @@ function setupDraggableHudPanels() {
|
||||
};
|
||||
|
||||
bindListener(handle, "pointerdown", (event) => {
|
||||
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close")) return;
|
||||
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
@@ -238,6 +246,8 @@ function setupDraggableHudPanels() {
|
||||
// If panel is inside a flow container (not a direct child of app), reparent
|
||||
// it so absolute positioning is relative to the app container.
|
||||
if (panel.parentElement !== app) {
|
||||
panel.dataset.originalParentId = panel.parentElement?.id || "";
|
||||
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
|
||||
const capturedWidth = panelRect.width;
|
||||
panel.style.position = "absolute";
|
||||
panel.style.width = `${capturedWidth}px`;
|
||||
@@ -613,13 +623,20 @@ function setupLayerPanel() {
|
||||
const emptyState = document.getElementById("layer-panel-empty");
|
||||
if (!panel) return;
|
||||
|
||||
const layerPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".layer-panel-header",
|
||||
body: "#layer-panel-body",
|
||||
collapseBtn,
|
||||
collapsedClass: "layer-panel--collapsed",
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开图层列表",
|
||||
collapseLabel: "折叠图层列表",
|
||||
});
|
||||
|
||||
bindListener(collapseBtn, "click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isCollapsed = panel.classList.toggle("layer-panel--collapsed");
|
||||
collapseBtn.title = isCollapsed ? "展开" : "折叠";
|
||||
collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
|
||||
const icon = collapseBtn.querySelector(".material-symbols-rounded");
|
||||
if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
|
||||
layerPanel.setCollapsed(!layerPanel.isCollapsed());
|
||||
});
|
||||
|
||||
if (searchInput) {
|
||||
@@ -797,6 +814,10 @@ function setupTerrainControls() {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
}
|
||||
updateNewsToggleUI(!document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden"));
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
@@ -929,11 +950,28 @@ function updateLayoutUI(container) {
|
||||
}
|
||||
|
||||
function resetPanelInlineLayout(panel) {
|
||||
const originalParentId = panel.dataset.originalParentId;
|
||||
if (originalParentId) {
|
||||
const originalParent = document.getElementById(originalParentId);
|
||||
if (originalParent) {
|
||||
const nextId = panel.dataset.originalNextSiblingId;
|
||||
const nextSibling = nextId ? document.getElementById(nextId) : null;
|
||||
if (nextSibling) {
|
||||
originalParent.insertBefore(panel, nextSibling);
|
||||
} else {
|
||||
originalParent.appendChild(panel);
|
||||
}
|
||||
}
|
||||
delete panel.dataset.originalParentId;
|
||||
delete panel.dataset.originalNextSiblingId;
|
||||
}
|
||||
panel.style.left = "";
|
||||
panel.style.top = "";
|
||||
panel.style.right = "";
|
||||
panel.style.bottom = "";
|
||||
panel.style.transform = "";
|
||||
panel.style.position = "";
|
||||
panel.style.width = "";
|
||||
delete panel.dataset.dragged;
|
||||
}
|
||||
|
||||
|
||||
233
frontend/public/earth/js/hud-panels.js
Normal file
233
frontend/public/earth/js/hud-panels.js
Normal file
@@ -0,0 +1,233 @@
|
||||
const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed";
|
||||
const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden";
|
||||
|
||||
function getHudScale() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale"));
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function getEdgeFlipThresholdPx() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset"));
|
||||
if (Number.isFinite(hudOffset) && hudOffset > 0) {
|
||||
return hudOffset;
|
||||
}
|
||||
return 20 * getHudScale();
|
||||
}
|
||||
|
||||
function clampExpandDirection(direction) {
|
||||
return direction === "up" ? "up" : "down";
|
||||
}
|
||||
|
||||
function resolveElement(target, root = document) {
|
||||
if (!target) return null;
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (typeof target === "string") {
|
||||
return root.querySelector(target);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickExpandDirection({
|
||||
panelRect,
|
||||
preferredDirection,
|
||||
}) {
|
||||
const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom);
|
||||
const edgeFlipThresholdPx = getEdgeFlipThresholdPx();
|
||||
const preferred = clampExpandDirection(preferredDirection);
|
||||
|
||||
// Pure edge-threshold contract:
|
||||
// - d < threshold => "up" family
|
||||
// - d >= threshold => "down" family
|
||||
// Do not pre-flip early based on expanded height.
|
||||
if (spaceBelow <= edgeFlipThresholdPx) return "up";
|
||||
if (spaceBelow > edgeFlipThresholdPx) return "down";
|
||||
return preferred;
|
||||
}
|
||||
|
||||
function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) {
|
||||
// The arrow always describes the next action and must stay aligned with the
|
||||
// real expansion direction chosen by the controller. Panels should not add
|
||||
// their own extra CSS rotation on top of this mapping.
|
||||
if (collapsed) {
|
||||
return {
|
||||
title: expandLabel,
|
||||
icon: direction === "up" ? "expand_less" : "expand_more",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: collapseLabel,
|
||||
icon: direction === "up" ? "expand_more" : "expand_less",
|
||||
};
|
||||
}
|
||||
|
||||
export function createHUDPanel({
|
||||
panel,
|
||||
header,
|
||||
body,
|
||||
collapseBtn,
|
||||
bodyCollapsedClass = "",
|
||||
preferredDirection = "down",
|
||||
collapsedClass = DEFAULT_COLLAPSED_CLASS,
|
||||
hiddenClass = DEFAULT_HIDDEN_CLASS,
|
||||
expandLabel = "展开",
|
||||
collapseLabel = "折叠",
|
||||
}) {
|
||||
const panelEl = resolveElement(panel);
|
||||
const headerEl = resolveElement(header, panelEl ?? document);
|
||||
const bodyEl = resolveElement(body, panelEl ?? document);
|
||||
const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document);
|
||||
|
||||
if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) {
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed() {},
|
||||
setVisible() {},
|
||||
syncLayout() {},
|
||||
destroy() {},
|
||||
isCollapsed() {
|
||||
return false;
|
||||
},
|
||||
isVisible() {
|
||||
return false;
|
||||
},
|
||||
getExpandDirection() {
|
||||
return clampExpandDirection(preferredDirection);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let currentDirection = clampExpandDirection(preferredDirection);
|
||||
|
||||
const shouldAnchorBottomDuringToggle = () =>
|
||||
panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== "";
|
||||
|
||||
const compensateTopForBottomAnchor = (beforeBottom) => {
|
||||
if (!shouldAnchorBottomDuringToggle()) return;
|
||||
const afterBottom = panelEl.getBoundingClientRect().bottom;
|
||||
const delta = afterBottom - beforeBottom;
|
||||
if (delta === 0) return;
|
||||
panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`;
|
||||
};
|
||||
|
||||
const syncDirection = () => {
|
||||
const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height);
|
||||
const nextDirection = pickExpandDirection({
|
||||
panelRect: panelEl.getBoundingClientRect(),
|
||||
preferredDirection,
|
||||
});
|
||||
|
||||
currentDirection = nextDirection;
|
||||
panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up");
|
||||
panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up");
|
||||
panelEl.dataset.expandDirection = nextDirection;
|
||||
};
|
||||
|
||||
const syncButton = () => {
|
||||
if (!(collapseBtnEl instanceof HTMLElement)) return;
|
||||
const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded");
|
||||
const { title, icon } = getCollapseButtonState({
|
||||
collapsed: panelEl.classList.contains(collapsedClass),
|
||||
direction: currentDirection,
|
||||
expandLabel,
|
||||
collapseLabel,
|
||||
});
|
||||
|
||||
collapseBtnEl.title = title;
|
||||
collapseBtnEl.setAttribute("aria-label", title);
|
||||
collapseBtnEl.dataset.expandDirection = currentDirection;
|
||||
if (iconEl) {
|
||||
iconEl.textContent = icon;
|
||||
}
|
||||
};
|
||||
|
||||
const syncLayout = () => {
|
||||
syncDirection();
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setCollapsed = (collapsed) => {
|
||||
syncDirection();
|
||||
const nextCollapsed = Boolean(collapsed);
|
||||
const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle();
|
||||
const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0;
|
||||
|
||||
if (shouldCompensate) {
|
||||
bodyEl.style.transition = "none";
|
||||
}
|
||||
|
||||
panelEl.classList.toggle(collapsedClass, nextCollapsed);
|
||||
if (bodyCollapsedClass) {
|
||||
bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed);
|
||||
}
|
||||
|
||||
if (shouldCompensate) {
|
||||
void panelEl.offsetHeight;
|
||||
compensateTopForBottomAnchor(bottomBefore);
|
||||
requestAnimationFrame(() => {
|
||||
bodyEl.style.transition = "";
|
||||
});
|
||||
}
|
||||
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setVisible = (visible) => {
|
||||
panelEl.classList.toggle(hiddenClass, !visible);
|
||||
if (visible) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewportChange = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass)) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
document.addEventListener("pointerup", handleViewportChange);
|
||||
document.addEventListener("pointermove", handlePointerMove);
|
||||
|
||||
syncLayout();
|
||||
requestAnimationFrame(syncLayout);
|
||||
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed,
|
||||
setVisible,
|
||||
syncLayout,
|
||||
destroy() {
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
document.removeEventListener("pointerup", handleViewportChange);
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
},
|
||||
isCollapsed() {
|
||||
return panelEl.classList.contains(collapsedClass);
|
||||
},
|
||||
isVisible() {
|
||||
return !panelEl.classList.contains(hiddenClass);
|
||||
},
|
||||
getExpandDirection() {
|
||||
return currentDirection;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCollapsibleHudPanel(options) {
|
||||
return createHUDPanel(options);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { showStatusMessage } from './ui.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
|
||||
const CARD_CONFIG = {
|
||||
cable: {
|
||||
@@ -105,6 +106,138 @@ function getPanel() {
|
||||
return document.getElementById('info-panel');
|
||||
}
|
||||
|
||||
function setupInfoCardDrag(panel) {
|
||||
const app = document.getElementById('container');
|
||||
if (!app) return;
|
||||
|
||||
const handle = panel.querySelector('.hud-panel-drag-handle');
|
||||
if (!handle) return;
|
||||
|
||||
let isDragging = false;
|
||||
let startPointerX = 0;
|
||||
let startPointerY = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
const stopDragging = () => {
|
||||
isDragging = false;
|
||||
panel.classList.remove('is-dragging');
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
const onMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const nextLeft = Math.min(
|
||||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||||
appRect.width - panelRect.width,
|
||||
);
|
||||
const nextTop = Math.min(
|
||||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||||
appRect.height - panelRect.height,
|
||||
);
|
||||
panel.style.left = `${nextLeft}px`;
|
||||
panel.style.top = `${nextTop}px`;
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
startLeft = panelRect.left - appRect.left;
|
||||
startTop = panelRect.top - appRect.top;
|
||||
panel.style.left = `${startLeft}px`;
|
||||
panel.style.top = `${startTop}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.classList.add('is-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', stopDragging);
|
||||
handle.addEventListener('pointercancel', stopDragging);
|
||||
handle.addEventListener('lostpointercapture', stopDragging);
|
||||
}
|
||||
|
||||
function mountCard() {
|
||||
if (cardMounted) return;
|
||||
|
||||
const container = document.getElementById('container');
|
||||
if (!container) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'info-panel';
|
||||
panel.className = 'hud-panel hud-panel-info';
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
panel.innerHTML = `
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="info-card-content" class="info-card-content"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(panel);
|
||||
|
||||
const card = panel.querySelector('#info-card');
|
||||
const content = panel.querySelector('#info-card-content');
|
||||
|
||||
// Prevent pointer events from reaching the earth canvas
|
||||
const stopEvent = (event) => { event.stopPropagation(); };
|
||||
[
|
||||
'mousemove', 'mousedown', 'mouseup', 'click', 'dblclick', 'wheel',
|
||||
'pointerdown', 'pointerup', 'pointermove',
|
||||
'touchstart', 'touchmove', 'touchend',
|
||||
].forEach((evt) => card.addEventListener(evt, stopEvent, { passive: false }));
|
||||
|
||||
// Close button
|
||||
const closeBtn = card.querySelector('.info-card-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
hideInfoCard();
|
||||
});
|
||||
}
|
||||
|
||||
// Copy value on label click
|
||||
content.addEventListener('click', async (event) => {
|
||||
const label = event.target.closest('.info-card-label');
|
||||
if (!label) return;
|
||||
|
||||
const property = label.closest('.info-card-property');
|
||||
const valueEl = property?.querySelector('.info-card-value');
|
||||
const value = valueEl?.textContent?.trim();
|
||||
|
||||
if (!value || value === '-') {
|
||||
showStatusMessage('无可复制内容', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showStatusMessage('复制失败', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
setupInfoCardDrag(panel);
|
||||
|
||||
cardMounted = true;
|
||||
}
|
||||
|
||||
function positionPanel(panel, x, y) {
|
||||
if (!panel) return;
|
||||
const margin = 12;
|
||||
@@ -142,71 +275,8 @@ function hidePanel() {
|
||||
if (panel) panel.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
export function initInfoCard() {
|
||||
const card = document.getElementById('info-card');
|
||||
const content = document.getElementById('info-card-content');
|
||||
if (!card || !content) return;
|
||||
|
||||
if (card.dataset.interactionBound !== 'true') {
|
||||
const stopEvent = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
[
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'mouseup',
|
||||
'click',
|
||||
'dblclick',
|
||||
'wheel',
|
||||
'pointerdown',
|
||||
'pointerup',
|
||||
'pointermove',
|
||||
'touchstart',
|
||||
'touchmove',
|
||||
'touchend',
|
||||
].forEach((eventName) => {
|
||||
card.addEventListener(eventName, stopEvent, { passive: false });
|
||||
});
|
||||
|
||||
// Close button wires the panel hide
|
||||
const closeBtn = card.querySelector('.info-card-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
hideInfoCard();
|
||||
});
|
||||
}
|
||||
|
||||
card.dataset.interactionBound = 'true';
|
||||
}
|
||||
|
||||
if (content.dataset.copyBound === 'true') return;
|
||||
|
||||
content.addEventListener('click', async (event) => {
|
||||
const label = event.target.closest('.info-card-label');
|
||||
if (!label) return;
|
||||
|
||||
const property = label.closest('.info-card-property');
|
||||
const valueEl = property?.querySelector('.info-card-value');
|
||||
const value = valueEl?.textContent?.trim();
|
||||
|
||||
if (!value || value === '-') {
|
||||
showStatusMessage('无可复制内容', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showStatusMessage('复制失败', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
content.dataset.copyBound = 'true';
|
||||
}
|
||||
// No-op: event binding now happens lazily in mountCard()
|
||||
export function initInfoCard() {}
|
||||
|
||||
export function setInfoCardNoBorder(noBorder = true) {
|
||||
const card = document.getElementById('info-card');
|
||||
@@ -222,6 +292,8 @@ export function showInfoCard(type, data, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
mountCard();
|
||||
|
||||
currentType = type;
|
||||
const card = document.getElementById('info-card');
|
||||
const icon = document.getElementById('info-card-icon');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
@@ -5,6 +7,7 @@ const LEGEND_MODES = {
|
||||
};
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
@@ -12,34 +15,33 @@ let legendItemsByMode = {
|
||||
};
|
||||
|
||||
export function initLegend() {
|
||||
// Tab click → switch mode
|
||||
const tabsEl = document.getElementById("legend-tabs");
|
||||
if (tabsEl) {
|
||||
tabsEl.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".legend-tab");
|
||||
if (!btn) return;
|
||||
const mode = btn.dataset.legendMode;
|
||||
if (mode) setLegendMode(mode);
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse toggle
|
||||
const collapseBtn = document.getElementById("legend-collapse");
|
||||
const legend = document.getElementById("legend");
|
||||
if (collapseBtn && legend) {
|
||||
legendPanel = createHUDPanel({
|
||||
panel: legend,
|
||||
header: ".legend-bar",
|
||||
body: "#legend-body",
|
||||
collapseBtn,
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开图例",
|
||||
collapseLabel: "折叠图例",
|
||||
});
|
||||
|
||||
collapseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
legend.classList.toggle("legend--collapsed");
|
||||
legendPanel?.setCollapsed(!(legendPanel?.isCollapsed() ?? false));
|
||||
});
|
||||
}
|
||||
|
||||
syncCurrentLabel(currentLegendMode);
|
||||
renderLegend(currentLegendMode);
|
||||
}
|
||||
|
||||
export function setLegendMode(mode) {
|
||||
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
|
||||
currentLegendMode = nextMode;
|
||||
syncTabs(nextMode);
|
||||
syncCurrentLabel(nextMode);
|
||||
renderLegend(nextMode);
|
||||
}
|
||||
|
||||
@@ -59,11 +61,10 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncTabs(mode) {
|
||||
const tabs = document.querySelectorAll("#legend-tabs .legend-tab");
|
||||
tabs.forEach((tab) => {
|
||||
tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode);
|
||||
});
|
||||
function syncCurrentLabel(mode) {
|
||||
const labelEl = document.getElementById("legend-current-label");
|
||||
if (!labelEl) return;
|
||||
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
} from "./legend.js";
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -173,6 +174,7 @@ const scratchCableCenter = new THREE.Vector3();
|
||||
const scratchCableDirection = new THREE.Vector3();
|
||||
const scratchBGPDirection = new THREE.Vector3();
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const scratchViewCenterWorld = new THREE.Vector3();
|
||||
|
||||
const cleanupFns = [];
|
||||
const DRAG_SMOOTHING_FACTOR = 0.18;
|
||||
@@ -194,6 +196,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
||||
"#earth-stats *",
|
||||
"#tv-panel",
|
||||
"#tv-panel *",
|
||||
"#news-panel",
|
||||
"#news-panel *",
|
||||
];
|
||||
|
||||
function bindListener(target, eventName, handler, options) {
|
||||
@@ -871,6 +875,20 @@ function updateStatsSummary() {
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentViewCenterCoords() {
|
||||
const earth = getEarth();
|
||||
if (!earth || !camera) return null;
|
||||
|
||||
scratchViewCenterWorld
|
||||
.copy(camera.position)
|
||||
.sub(earth.position)
|
||||
.normalize()
|
||||
.multiplyScalar(CONFIG.earthRadius);
|
||||
|
||||
earth.worldToLocal(scratchViewCenterWorld);
|
||||
return vector3ToLatLon(scratchViewCenterWorld);
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
console.error("全局错误:", event.error);
|
||||
});
|
||||
@@ -889,6 +907,7 @@ export function init() {
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
@@ -1693,6 +1712,7 @@ function animate() {
|
||||
updateSatellitePositions(deltaTime);
|
||||
updateBreathingPhase(deltaTime);
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
|
||||
334
frontend/public/earth/js/news.js
Normal file
334
frontend/public/earth/js/news.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { getActiveTVTab, openTVPanelTab, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
|
||||
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
|
||||
const FOCUS_UPDATE_INTERVAL_MS = 4000;
|
||||
const DATA_REFRESH_INTERVAL_MS = 180000;
|
||||
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
return {
|
||||
toggleBtn: document.getElementById("toggle-news"),
|
||||
refreshBtn: document.getElementById("news-refresh"),
|
||||
openBtn: document.getElementById("news-open-external"),
|
||||
status: document.getElementById("news-board-status"),
|
||||
focusLabel: document.getElementById("news-focus-label"),
|
||||
focusCoords: document.getElementById("news-focus-coords"),
|
||||
sourceCount: document.getElementById("news-source-count"),
|
||||
regionChip: document.getElementById("news-region-chip"),
|
||||
board: document.getElementById("news-board-list"),
|
||||
empty: document.getElementById("news-board-empty"),
|
||||
feedAnchor: document.getElementById("news-feed-anchor"),
|
||||
};
|
||||
}
|
||||
|
||||
function formatCoord(value, positiveLabel, negativeLabel) {
|
||||
const abs = Math.abs(value).toFixed(1);
|
||||
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(raw) {
|
||||
if (!raw) return "刚刚同步";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return "刚刚同步";
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.max(1, Math.round(diff / 60000));
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
export function updateNewsToggleUI(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = active ? "关闭态势新闻" : "打开态势新闻";
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyState(message) {
|
||||
const { board, empty, status, openBtn } = getElements();
|
||||
if (board) board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = message;
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "等待聚合新闻源";
|
||||
}
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
}
|
||||
|
||||
function renderPayload(nextPayload) {
|
||||
payload = nextPayload;
|
||||
const {
|
||||
board,
|
||||
empty,
|
||||
status,
|
||||
focusLabel,
|
||||
focusCoords,
|
||||
sourceCount,
|
||||
regionChip,
|
||||
openBtn,
|
||||
feedAnchor,
|
||||
} = getElements();
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
||||
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
focusLabel.textContent = focus.label || "全球焦点";
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
|
||||
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||
} else {
|
||||
focusCoords.textContent = "跟随当前视角自动聚焦";
|
||||
}
|
||||
|
||||
sourceCount.textContent = `${sources.length} 路聚合源`;
|
||||
if (nextPayload?.stale) {
|
||||
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length} 条`;
|
||||
} else {
|
||||
status.textContent = nextPayload?.errors?.length
|
||||
? `已聚合 ${items.length} 条,部分源不可用`
|
||||
: `已聚合 ${items.length} 条态势新闻`;
|
||||
}
|
||||
|
||||
if (feedAnchor) {
|
||||
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
|
||||
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
|
||||
}
|
||||
|
||||
if (openBtn) {
|
||||
openBtn.disabled = !feedAnchor?.href;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
board.innerHTML = items
|
||||
.map((item) => {
|
||||
const cardClass = item.is_focus_match
|
||||
? "news-story-card news-story-card--focus"
|
||||
: "news-story-card";
|
||||
const summary = item.summary
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div class="news-story-title">${item.title}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${item.region}</span>
|
||||
<span class="news-story-tag">${item.feed_name}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
const url = new URL(EARTH_NEWS_API, window.location.origin);
|
||||
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
|
||||
if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
window.clearTimeout(timeoutId);
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`新闻源请求失败: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "正在同步全球态势新闻...";
|
||||
}
|
||||
|
||||
refreshPromise = fetchNews(lat, lon)
|
||||
.then((nextPayload) => {
|
||||
renderPayload(nextPayload);
|
||||
lastFetchAt = Date.now();
|
||||
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
|
||||
}
|
||||
}
|
||||
return nextPayload;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("加载 Earth RSS 新闻失败:", error);
|
||||
const message = error?.name === "AbortError"
|
||||
? "新闻聚合请求超时,请稍后重试"
|
||||
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
|
||||
if (!payload) {
|
||||
renderEmptyState(message);
|
||||
} else if (!silent) {
|
||||
showStatusMessage("态势新闻同步失败", "error");
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function shouldRefreshForFocus(lat, lon, region) {
|
||||
const now = Date.now();
|
||||
if (!lastFocus) return true;
|
||||
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
|
||||
lastRegionSwitchAt = now;
|
||||
return true;
|
||||
}
|
||||
if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true;
|
||||
if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false;
|
||||
const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0));
|
||||
const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0));
|
||||
return latDrift >= 18 || lonDrift >= 25;
|
||||
}
|
||||
|
||||
function inferRegion(lat, lon) {
|
||||
if (typeof lat !== "number" || typeof lon !== "number") return "global";
|
||||
if (lon >= -170 && lon <= -30) return "americas";
|
||||
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
|
||||
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
|
||||
return "asia-pacific";
|
||||
}
|
||||
|
||||
function openCurrentSourceHomepage() {
|
||||
const { feedAnchor } = getElements();
|
||||
if (feedAnchor?.href) {
|
||||
window.open(feedAnchor.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNewsViewFocus(coords) {
|
||||
if (!initialized) return;
|
||||
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
|
||||
|
||||
const region = inferRegion(coords.lat, coords.lon);
|
||||
const nextFocus = {
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
region,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
|
||||
lastFocus = nextFocus;
|
||||
if (shouldRefresh) {
|
||||
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureNewsPanelReady() {
|
||||
if (!initialized) {
|
||||
initNewsPanel();
|
||||
}
|
||||
if (!payload) {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { toggleBtn, refreshBtn, openBtn } = getElements();
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
const openNewsTab = async () => {
|
||||
openTVPanelTab("news");
|
||||
updateNewsToggleUI(true);
|
||||
try {
|
||||
await ensureNewsPanelReady();
|
||||
} catch {
|
||||
// surface already handled
|
||||
}
|
||||
};
|
||||
|
||||
toggleBtn?.addEventListener("click", async () => {
|
||||
const visible = isTVPanelVisible();
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
|
||||
if (!visible) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
setTVPanelVisible(false);
|
||||
updateNewsToggleUI(false);
|
||||
});
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
});
|
||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
}
|
||||
});
|
||||
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
|
||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const TV_STREAMS_API = "/api/v1/tv/streams";
|
||||
const TV_PROXY_API = "/api/v1/tv/proxy";
|
||||
@@ -20,6 +21,22 @@ let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let hlsPlayer = null;
|
||||
let hlsRecoveryAttempts = 0;
|
||||
let metaAutoCollapseTimer = null;
|
||||
let tvPanel = null;
|
||||
let activeTab = "live";
|
||||
const failedSourceIds = new Set();
|
||||
let probeTimer = null;
|
||||
let reformCleanupTimer = null;
|
||||
|
||||
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_HUD_OFFSET_PX = 20;
|
||||
const MIN_NEWS_TAB_HEIGHT_PX = 280;
|
||||
const PANEL_RESIZE_MARGIN_PX = 12;
|
||||
const TV_PANEL_MIN_WIDTH_PX = 360;
|
||||
const TV_PANEL_MIN_HEIGHT_PX = 340;
|
||||
const REFORM_CLEANUP_MS = 280;
|
||||
const REFORM_RESTORE_ANCHOR_DATA_KEY = "reformRestoreAnchor";
|
||||
|
||||
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
|
||||
const HLS_RETRY_CONFIG = {
|
||||
@@ -33,7 +50,6 @@ function getElements() {
|
||||
return {
|
||||
panel: document.getElementById("tv-panel"),
|
||||
toggleBtn: document.getElementById("toggle-tv"),
|
||||
resizeHandle: document.getElementById("tv-resize-handle"),
|
||||
select: document.getElementById("tv-source-select"),
|
||||
title: document.getElementById("tv-source-title"),
|
||||
meta: document.getElementById("tv-source-meta"),
|
||||
@@ -45,9 +61,58 @@ function getElements() {
|
||||
empty: document.getElementById("tv-empty-state"),
|
||||
refreshBtn: document.getElementById("tv-refresh"),
|
||||
openBtn: document.getElementById("tv-open-external"),
|
||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||
newsHeaderControls: document.getElementById("tv-header-controls-news"),
|
||||
liveTabBtn: document.getElementById("tv-tab-live"),
|
||||
newsTabBtn: document.getElementById("tv-tab-news"),
|
||||
livePane: document.getElementById("tv-tab-live-pane"),
|
||||
newsPane: document.getElementById("tv-tab-news-pane"),
|
||||
};
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
tvPanel?.setCollapsed(collapsed);
|
||||
}
|
||||
|
||||
function syncPanelActiveTab(tab = activeTab) {
|
||||
const { panel } = getElements();
|
||||
if (panel instanceof HTMLElement) {
|
||||
panel.dataset.activeTab = tab;
|
||||
}
|
||||
}
|
||||
|
||||
function syncNewsDefaultMaxHeight() {
|
||||
const { panel } = getElements();
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
|
||||
const earthStats = document.getElementById("earth-stats");
|
||||
const hudOffset = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-offset"),
|
||||
);
|
||||
const resolvedOffset = Number.isFinite(hudOffset) ? hudOffset : DEFAULT_HUD_OFFSET_PX;
|
||||
|
||||
if (!(earthStats instanceof HTMLElement)) {
|
||||
panel.style.removeProperty("--tv-news-default-max-height");
|
||||
return;
|
||||
}
|
||||
|
||||
const statsRect = earthStats.getBoundingClientRect();
|
||||
const availableHeight = Math.max(
|
||||
Math.round(MIN_NEWS_TAB_HEIGHT_PX * getHudScale()),
|
||||
Math.floor(window.innerHeight - resolvedOffset - statsRect.bottom),
|
||||
);
|
||||
|
||||
panel.style.setProperty("--tv-news-default-max-height", `${availableHeight}px`);
|
||||
}
|
||||
|
||||
function autoExpandMeta() {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
setMetaCollapsed(false);
|
||||
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
||||
}
|
||||
|
||||
function clearPanelPositioningForResize(panel) {
|
||||
panel.style.left = `${panel.offsetLeft}px`;
|
||||
panel.style.top = `${panel.offsetTop}px`;
|
||||
@@ -65,80 +130,96 @@ function getHudScale() {
|
||||
}
|
||||
|
||||
function setupResizeHandle() {
|
||||
const { panel, resizeHandle } = getElements();
|
||||
const { panel } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
|
||||
|
||||
let resizing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
let startHeight = 0;
|
||||
let activeEdge = "";
|
||||
const resizeStart = {
|
||||
pointerX: 0,
|
||||
pointerY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
resizing = false;
|
||||
activeEdge = "";
|
||||
panel.classList.remove("is-resizing");
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
resizeHandle.addEventListener("pointerdown", (event) => {
|
||||
if (document.getElementById("container")?.classList.contains("layout-expanded")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizing = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
|
||||
clearPanelPositioningForResize(panel);
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
startWidth = rect.width;
|
||||
startHeight = rect.height;
|
||||
panel.classList.add("is-resizing");
|
||||
document.body.style.userSelect = "none";
|
||||
resizeHandle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
resizeHandle.addEventListener("pointermove", (event) => {
|
||||
const onMove = (event) => {
|
||||
if (!resizing) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const currentLeft = panelRect.left - containerRect.left;
|
||||
const currentTop = panelRect.top - containerRect.top;
|
||||
const hudScale = getHudScale();
|
||||
const minWidth = Math.max(320, Math.round(360 * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(340 * hudScale));
|
||||
const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12);
|
||||
const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12);
|
||||
const nextWidth = Math.min(
|
||||
maxWidth,
|
||||
Math.max(minWidth, startWidth + (event.clientX - startX)),
|
||||
);
|
||||
const nextHeight = Math.min(
|
||||
maxHeight,
|
||||
Math.max(minHeight, startHeight + (event.clientY - startY)),
|
||||
);
|
||||
const minWidth = Math.max(320, Math.round(TV_PANEL_MIN_WIDTH_PX * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(TV_PANEL_MIN_HEIGHT_PX * hudScale));
|
||||
const dx = event.clientX - resizeStart.pointerX;
|
||||
const dy = event.clientY - resizeStart.pointerY;
|
||||
|
||||
panel.style.width = `${nextWidth}px`;
|
||||
panel.style.minHeight = `${nextHeight}px`;
|
||||
if (activeEdge.includes("r")) {
|
||||
const maxW = containerRect.width - resizeStart.left - PANEL_RESIZE_MARGIN_PX;
|
||||
panel.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
|
||||
}
|
||||
if (activeEdge.includes("l")) {
|
||||
const newW = Math.max(minWidth, resizeStart.width - dx);
|
||||
panel.style.width = `${newW}px`;
|
||||
panel.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
|
||||
}
|
||||
if (activeEdge.includes("b")) {
|
||||
const maxH = containerRect.height - resizeStart.top - PANEL_RESIZE_MARGIN_PX;
|
||||
panel.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
|
||||
}
|
||||
};
|
||||
|
||||
panel.querySelectorAll(".tv-panel-edge[data-edge]").forEach((edgeEl) => {
|
||||
edgeEl.addEventListener("pointerdown", (event) => {
|
||||
if (container.classList.contains("layout-expanded")) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
activeEdge = edgeEl.dataset.edge ?? "";
|
||||
resizing = true;
|
||||
resizeStart.pointerX = event.clientX;
|
||||
resizeStart.pointerY = event.clientY;
|
||||
|
||||
clearPanelPositioningForResize(panel);
|
||||
panel.dataset.resized = "true";
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
resizeStart.width = rect.width;
|
||||
resizeStart.height = rect.height;
|
||||
resizeStart.left = rect.left - cRect.left;
|
||||
resizeStart.top = rect.top - cRect.top;
|
||||
panel.style.width = `${resizeStart.width}px`;
|
||||
panel.style.height = `${resizeStart.height}px`;
|
||||
panel.style.minHeight = "";
|
||||
|
||||
panel.classList.add("is-resizing");
|
||||
document.body.style.userSelect = "none";
|
||||
edgeEl.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
edgeEl.addEventListener("pointermove", onMove);
|
||||
edgeEl.addEventListener("pointerup", stopResize);
|
||||
edgeEl.addEventListener("pointercancel", stopResize);
|
||||
edgeEl.addEventListener("lostpointercapture", stopResize);
|
||||
});
|
||||
|
||||
resizeHandle.addEventListener("pointerup", stopResize);
|
||||
resizeHandle.addEventListener("pointercancel", stopResize);
|
||||
resizeHandle.addEventListener("lostpointercapture", stopResize);
|
||||
}
|
||||
|
||||
function updateToggleButton(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
toggleBtn.classList.toggle("active", visible);
|
||||
const active = visible && activeTab === "live";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播";
|
||||
tooltip.textContent = active ? "关闭新闻直播" : "打开新闻直播";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +233,154 @@ function syncSettingsToggle(visible) {
|
||||
function setPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
tvPanel?.setVisible(visible);
|
||||
updateToggleButton(visible);
|
||||
syncSettingsToggle(visible);
|
||||
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
||||
detail: { visible },
|
||||
}));
|
||||
}
|
||||
|
||||
export function setTVPanelVisible(visible) {
|
||||
setPanelVisible(visible);
|
||||
}
|
||||
|
||||
function clearReformState() {
|
||||
const { panel } = getElements();
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
panel.classList.remove("is-reforming");
|
||||
panel.style.height = "";
|
||||
if (panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] === "true") {
|
||||
panel.style.top = "";
|
||||
panel.style.bottom = "";
|
||||
delete panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY];
|
||||
}
|
||||
if (reformCleanupTimer) {
|
||||
clearTimeout(reformCleanupTimer);
|
||||
reformCleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function animateTabReform(applyChange) {
|
||||
const { panel } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
if (!(panel instanceof HTMLElement)) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
clearReformState();
|
||||
|
||||
const reformStartRect = panel.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const reformSnapshot = {
|
||||
height: reformStartRect.height,
|
||||
anchoredBottom: reformStartRect.bottom - containerRect.top,
|
||||
shouldRestoreDefaultAnchoring: panel.dataset.dragged !== "true",
|
||||
};
|
||||
|
||||
if (reformSnapshot.shouldRestoreDefaultAnchoring) {
|
||||
panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] = "true";
|
||||
panel.style.bottom = "auto";
|
||||
}
|
||||
|
||||
panel.style.top = `${reformSnapshot.anchoredBottom - reformSnapshot.height}px`;
|
||||
panel.style.height = `${reformSnapshot.height}px`;
|
||||
panel.classList.add("is-reforming");
|
||||
void panel.offsetHeight;
|
||||
|
||||
applyChange();
|
||||
|
||||
panel.style.height = "auto";
|
||||
const targetHeight = panel.getBoundingClientRect().height;
|
||||
panel.style.height = `${reformSnapshot.height}px`;
|
||||
void panel.offsetHeight;
|
||||
const targetTop = reformSnapshot.anchoredBottom - targetHeight;
|
||||
|
||||
const finalizeReform = () => {
|
||||
panel.removeEventListener("transitionend", handleReformTransitionEnd);
|
||||
clearReformState();
|
||||
};
|
||||
|
||||
const handleReformTransitionEnd = (event) => {
|
||||
if (event.target === panel && event.propertyName === "height") {
|
||||
finalizeReform();
|
||||
}
|
||||
};
|
||||
|
||||
panel.addEventListener("transitionend", handleReformTransitionEnd);
|
||||
reformCleanupTimer = window.setTimeout(finalizeReform, REFORM_CLEANUP_MS);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
panel.style.top = `${targetTop}px`;
|
||||
panel.style.height = `${targetHeight}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTabState(target, isActive, activeClassName = "") {
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
target.hidden = !isActive;
|
||||
if (activeClassName) {
|
||||
target.classList.toggle(activeClassName, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTabButtonState(button, isActive) {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.classList.toggle("tv-panel-tab--active", isActive);
|
||||
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
}
|
||||
|
||||
function setActiveTab(tab) {
|
||||
const nextTab = tab === "news" ? "news" : "live";
|
||||
if (activeTab === nextTab) return;
|
||||
|
||||
animateTabReform(() => {
|
||||
activeTab = nextTab;
|
||||
syncPanelActiveTab(nextTab);
|
||||
syncNewsDefaultMaxHeight();
|
||||
const {
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
liveHeaderControls,
|
||||
newsHeaderControls,
|
||||
livePane,
|
||||
newsPane,
|
||||
} = getElements();
|
||||
|
||||
updateTabButtonState(liveTabBtn, nextTab === "live");
|
||||
updateTabButtonState(newsTabBtn, nextTab === "news");
|
||||
updateTabState(liveHeaderControls, nextTab === "live");
|
||||
updateTabState(newsHeaderControls, nextTab === "news");
|
||||
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
|
||||
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
|
||||
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
|
||||
detail: { tab: nextTab },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function openTVPanelTab(tab = "live") {
|
||||
setPanelVisible(true);
|
||||
if (activeTab === tab) return;
|
||||
setActiveTab(tab);
|
||||
}
|
||||
|
||||
export function isTVPanelVisible() {
|
||||
return tvPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
|
||||
}
|
||||
|
||||
export function getActiveTVTab() {
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
function getEmbeddedUrl(source) {
|
||||
@@ -338,7 +564,7 @@ function attachVideoSource(video, source) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmbeddedFallback(source)) {
|
||||
if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
@@ -371,6 +597,59 @@ function findSourceById(sourceId) {
|
||||
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
|
||||
}
|
||||
|
||||
function markSourceFailed(sourceId) {
|
||||
if (!sourceId) return;
|
||||
failedSourceIds.add(sourceId);
|
||||
renderSourceOptions();
|
||||
if (!probeTimer) {
|
||||
probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSourceFailed(sourceId) {
|
||||
if (!failedSourceIds.has(sourceId)) return;
|
||||
failedSourceIds.delete(sourceId);
|
||||
renderSourceOptions();
|
||||
if (failedSourceIds.size === 0 && probeTimer) {
|
||||
clearInterval(probeTimer);
|
||||
probeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeFailedSources() {
|
||||
if (failedSourceIds.size === 0) {
|
||||
clearInterval(probeTimer);
|
||||
probeTimer = null;
|
||||
return;
|
||||
}
|
||||
for (const sourceId of [...failedSourceIds]) {
|
||||
const source = findSourceById(sourceId);
|
||||
if (!source) { failedSourceIds.delete(sourceId); continue; }
|
||||
const probeUrl = source.stream_url || source.embed_url;
|
||||
if (!probeUrl) continue;
|
||||
try {
|
||||
const resp = await fetch(probeUrl, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (resp.ok) clearSourceFailed(sourceId);
|
||||
} catch {
|
||||
// 仍然失效,保持标记
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryFallbackSource() {
|
||||
const fallback = tvPayload?.fallback_source;
|
||||
if (!fallback || fallback.id === currentSourceId) return false;
|
||||
markSourceFailed(currentSourceId);
|
||||
currentSourceId = fallback.id;
|
||||
const { select } = getElements();
|
||||
if (select) select.value = currentSourceId;
|
||||
renderSource(fallback);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCurrentSource() {
|
||||
return findSourceById(currentSourceId);
|
||||
}
|
||||
@@ -433,10 +712,11 @@ function renderSourceOptions() {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||||
const option = document.createElement("option");
|
||||
option.value = source.id;
|
||||
option.textContent = `${source.name}${marker}`;
|
||||
option.textContent = `${source.name}${defaultMark}${failMark}`;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -502,6 +782,7 @@ function renderSource(source) {
|
||||
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
|
||||
);
|
||||
updateOpenButton(source);
|
||||
autoExpandMeta();
|
||||
}
|
||||
|
||||
function resolveInitialSourceId() {
|
||||
@@ -567,22 +848,54 @@ export function initTVPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
|
||||
const {
|
||||
select,
|
||||
refreshBtn,
|
||||
iframe,
|
||||
video,
|
||||
toggleBtn,
|
||||
panel,
|
||||
metaToggle,
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
} = getElements();
|
||||
|
||||
updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
|
||||
if (panel && metaToggle) {
|
||||
tvPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".hud-panel__header",
|
||||
body: "#tv-meta-wrap",
|
||||
collapseBtn: metaToggle,
|
||||
bodyCollapsedClass: "is-collapsed",
|
||||
preferredDirection: "up",
|
||||
expandLabel: "展开新闻直播信息",
|
||||
collapseLabel: "折叠新闻直播信息",
|
||||
});
|
||||
}
|
||||
|
||||
updateToggleButton(tvPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(tvPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
toggleBtn?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true;
|
||||
setPanelVisible(nextVisible);
|
||||
if (nextVisible) {
|
||||
const currentlyVisible = tvPanel?.isVisible() ?? false;
|
||||
if (!currentlyVisible) {
|
||||
setPanelVisible(true);
|
||||
setActiveTab("live");
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
} else {
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab !== "live") {
|
||||
setActiveTab("live");
|
||||
showStatusMessage("已切换到新闻直播", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelVisible(false);
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
@@ -592,26 +905,46 @@ export function initTVPanel() {
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !(tvPanel?.isCollapsed() ?? false);
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", () => {
|
||||
refreshTVPanel();
|
||||
});
|
||||
|
||||
liveTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("live");
|
||||
});
|
||||
newsTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("news");
|
||||
});
|
||||
|
||||
iframe?.addEventListener("load", () => {
|
||||
if (iframe.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("loadedmetadata", () => {
|
||||
if (video.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("error", () => {
|
||||
const currentSource = getCurrentSource();
|
||||
if (!showEmbeddedFallback(currentSource)) {
|
||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
|
||||
setupResizeHandle();
|
||||
syncPanelActiveTab("live");
|
||||
syncNewsDefaultMaxHeight();
|
||||
setActiveTab("live");
|
||||
|
||||
window.addEventListener("resize", syncNewsDefaultMaxHeight);
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ function Scrollbar({
|
||||
{scrollbar.y.visible ? (
|
||||
<div
|
||||
className="scrollbar__thumb scrollbar__thumb--y"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
height: `${scrollbar.y.thumbSize}px`,
|
||||
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
|
||||
@@ -284,6 +285,7 @@ function Scrollbar({
|
||||
{scrollbar.x.visible ? (
|
||||
<div
|
||||
className="scrollbar__thumb scrollbar__thumb--x"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
width: `${scrollbar.x.thumbSize}px`,
|
||||
transform: `translateX(${scrollbar.x.thumbOffset}px)`,
|
||||
|
||||
37
frontend/src/components/Scrollbar/TableScrollRegion.tsx
Normal file
37
frontend/src/components/Scrollbar/TableScrollRegion.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
|
||||
import ScrollbarOverlay from './ScrollbarOverlay'
|
||||
|
||||
interface TableScrollRegionProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
targetSelector?: string
|
||||
}
|
||||
|
||||
const TableScrollRegion = forwardRef<HTMLDivElement, TableScrollRegionProps>(function TableScrollRegion(
|
||||
{
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
targetSelector = '.ant-table-body',
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={['table-scroll-region', className].filter(Boolean).join(' ')}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
<ScrollbarOverlay containerRef={containerRef} targetSelector={targetSelector} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default TableScrollRegion
|
||||
26
frontend/src/components/TableActions/TableActions.tsx
Normal file
26
frontend/src/components/TableActions/TableActions.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Dropdown } from 'antd'
|
||||
import { MoreOutlined } from '@ant-design/icons'
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean
|
||||
items: MenuProps['items']
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** onCell style for action columns — prevents overflow ellipsis and text wrapping */
|
||||
export const actionCellProps = {
|
||||
style: { whiteSpace: 'nowrap' as const, textOverflow: 'clip' as const },
|
||||
}
|
||||
|
||||
export function TableActions({ collapsed, items, children }: Props) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Dropdown trigger={['click']} menu={{ items }}>
|
||||
<Button type="text" size="small" icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
return <div style={{ display: 'inline-flex', gap: 4 }}>{children}</div>
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { useCollapsedActions } from './useCollapsedActions'
|
||||
export { useWebSocket } from './useWebSocket'
|
||||
|
||||
39
frontend/src/hooks/useCollapsedActions.ts
Normal file
39
frontend/src/hooks/useCollapsedActions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 监听容器宽度,宽时展开操作按钮,窄时收入 Dropdown。
|
||||
* @param threshold 折叠阈值(px),默认 700
|
||||
* @returns [collapsed, callbackRef]
|
||||
*/
|
||||
export function useCollapsedActions(threshold = 700) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const ref = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
elementRef.current = el
|
||||
|
||||
if (!el || typeof ResizeObserver === 'undefined') return
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setCollapsed(entry.contentRect.width < threshold)
|
||||
})
|
||||
observer.observe(el)
|
||||
observerRef.current = observer
|
||||
},
|
||||
[threshold],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
elementRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [collapsed, ref] as const
|
||||
}
|
||||
@@ -140,7 +140,8 @@ body {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.18s ease, background 0.18s ease;
|
||||
outline: none;
|
||||
transition: opacity 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.scrollbar__thumb::after {
|
||||
@@ -173,6 +174,7 @@ body {
|
||||
}
|
||||
|
||||
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar:focus-within .scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar__track--dragging .scrollbar__thumb {
|
||||
opacity: 1;
|
||||
@@ -182,12 +184,15 @@ body {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scrollbar__thumb:hover {
|
||||
background: rgba(216, 226, 240, 0.68);
|
||||
.scrollbar__thumb:hover,
|
||||
.scrollbar__thumb:focus-visible {
|
||||
background: rgba(125, 146, 174, 0.82);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.scrollbar__track--dragging .scrollbar__thumb {
|
||||
background: rgba(226, 235, 246, 0.82);
|
||||
background: rgba(92, 115, 146, 0.9);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.dashboard-brand {
|
||||
@@ -385,6 +390,10 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playground-card__icon-button:hover {
|
||||
color: #1677ff !important;
|
||||
background: rgba(22, 119, 255, 0.08) !important;
|
||||
@@ -398,9 +407,11 @@ body {
|
||||
|
||||
.playground-card__scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.playground-card__scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.playground-card__scroll::-webkit-scrollbar,
|
||||
@@ -527,6 +538,21 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.playground-chat__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playground-chat__input-row .playground-chat__input.ant-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap--expanded .playground-chat__input-row .playground-chat__send-button.ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playground-chat__input.ant-input {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
@@ -536,6 +562,15 @@ body {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap--expanded .playground-chat__input.ant-input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap:not(.playground-chat__input-wrap--expanded) .playground-chat__input.ant-input {
|
||||
margin-top: 0;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.playground-chat__input.ant-input:focus,
|
||||
.playground-chat__input.ant-input-focused {
|
||||
box-shadow: none;
|
||||
@@ -1251,25 +1286,21 @@ body {
|
||||
.playground-result-modal__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
.playground-result-modal__content .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.82);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
.playground-result__blocks-scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.playground-result__blocks-scroll.scrollbar {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -1285,6 +1316,10 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playground-shell__sidebar {
|
||||
display: none;
|
||||
}
|
||||
@@ -1490,10 +1525,26 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-body {
|
||||
height: auto !important;
|
||||
/* users table: flex-fill approach so overlay x-track aligns with table bottom */
|
||||
.users-table-region .ant-table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 0 !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
|
||||
.data-source-table-region .ant-table-wrapper,
|
||||
.data-source-table-region .ant-spin-nested-loading,
|
||||
.data-source-table-region .ant-spin-container {
|
||||
@@ -1583,14 +1634,14 @@ body {
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-body,
|
||||
.data-source-table-region .ant-table-content {
|
||||
.table-scroll-region .ant-table-body,
|
||||
.table-scroll-region .ant-table-content {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-body::-webkit-scrollbar,
|
||||
.data-source-table-region .ant-table-content::-webkit-scrollbar {
|
||||
.table-scroll-region .ant-table-body::-webkit-scrollbar,
|
||||
.table-scroll-region .ant-table-content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -1757,7 +1808,10 @@ body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
@@ -1836,10 +1890,11 @@ body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.bgp-page__brief-modal-body .scrollbar__viewport {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bgp-page__brief-evidence {
|
||||
@@ -1857,6 +1912,10 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.alerts-brief-drawer .scrollbar__viewport {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.alerts-brief-drawer__loading {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
@@ -1887,8 +1946,25 @@ body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alerts-tab-panel > .ant-space-item,
|
||||
.system-alerts-page__stack > .ant-space-item,
|
||||
.bgp-alerts-page__stack > .ant-space-item,
|
||||
.situational-alerts-page__stack > .ant-space-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alerts-tab-panel > .ant-space-item:last-child,
|
||||
.system-alerts-page__stack > .ant-space-item:last-child,
|
||||
.bgp-alerts-page__stack > .ant-space-item:last-child,
|
||||
.situational-alerts-page__stack > .ant-space-item:last-child {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.system-alerts-page__table-card,
|
||||
@@ -1915,8 +1991,22 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs,
|
||||
.bgp-alerts-page__tabs .ant-tabs-content-holder,
|
||||
.bgp-alerts-page__tabs {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs .ant-tabs-content,
|
||||
.bgp-alerts-page__tabs .ant-tabs-tabpane {
|
||||
min-width: 0;
|
||||
@@ -2128,29 +2218,71 @@ body {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact {
|
||||
flex-wrap: nowrap !important;
|
||||
.bgp-page__summary-scroll.scrollbar,
|
||||
.alerts-summary-scroll.scrollbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bgp-page__summary-scroll .scrollbar__viewport,
|
||||
.alerts-summary-scroll .scrollbar__viewport {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
.bgp-page__summary-grid,
|
||||
.alerts-summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: nowrap !important;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.82);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
.bgp-page__summary-cell,
|
||||
.alerts-summary-cell {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.situational-alerts-page__summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.situational-alerts-page__panels-grid {
|
||||
display: flex;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.situational-alerts-page__panels-scroll .scrollbar__viewport {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.situational-alerts-page__summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.situational-alerts-page__summary-grid .alerts-summary-cell {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.situational-alerts-page__summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.situational-alerts-page__summary-grid .alerts-summary-cell {
|
||||
width: 220px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bgp-page__table-card,
|
||||
@@ -2408,25 +2540,21 @@ body {
|
||||
.settings-panel-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.settings-panel-scroll.scrollbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-panel-scroll .scrollbar__viewport {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 6px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.settings-panel-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.settings-panel-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.8);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.settings-panel-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.settings-panel-scroll .scrollbar__viewport > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-pane .data-source-table-region .ant-table-container {
|
||||
@@ -2447,34 +2575,25 @@ body {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar {
|
||||
|
||||
.settings-tv-edit-modal .ant-modal-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-tv-edit-modal__body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
height: min(80vh, 640px);
|
||||
min-height: 0;
|
||||
padding: 16px 0 0 24px;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
.settings-tv-edit-modal__scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-tv-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.data-list-workspace {
|
||||
min-height: 0;
|
||||
@@ -3273,7 +3392,6 @@ body {
|
||||
|
||||
.dashboard-restart-log {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
@@ -3281,16 +3399,11 @@ body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.dashboard-restart-log::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.dashboard-restart-log::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
.dashboard-restart-log .scrollbar__viewport {
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
} from 'antd'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
|
||||
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
@@ -171,20 +171,22 @@ export function BGPAlertsPanel() {
|
||||
|
||||
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll bgp-alerts-page__summary-scroll">
|
||||
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Card className="bgp-alerts-page__table-card">
|
||||
<Tabs
|
||||
@@ -194,7 +196,7 @@ export function BGPAlertsPanel() {
|
||||
key: 'incidents',
|
||||
label: 'BGP 事件',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||
<Table<BGPIncident>
|
||||
columns={incidentColumns}
|
||||
dataSource={incidents}
|
||||
@@ -204,14 +206,14 @@ export function BGPAlertsPanel() {
|
||||
scroll={{ x: 1200, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: 'BGP 异常',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||
<Table<BGPAnomaly>
|
||||
columns={anomalyColumns}
|
||||
dataSource={anomalies}
|
||||
@@ -221,7 +223,7 @@ export function BGPAlertsPanel() {
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -244,14 +246,14 @@ export function BGPAlertsPanel() {
|
||||
<Spin tip="正在生成 BGP AI 简报..." />
|
||||
</div>
|
||||
) : brief ? (
|
||||
<div className="bgp-page__brief-modal-body">
|
||||
<Scrollbar className="bgp-page__brief-modal-body">
|
||||
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
|
||||
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<Text type="secondary">当前没有可查看的 BGP 简报。</Text>
|
||||
)}
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
|
||||
import {
|
||||
getSituationalAwarenessGateway,
|
||||
@@ -119,42 +118,46 @@ export function SituationalAlertsPanel() {
|
||||
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll situational-alerts-page__summary-scroll">
|
||||
<div className="alerts-summary-grid situational-alerts-page__summary-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="系统告警侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="BGP 风险侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll situational-alerts-page__panels-scroll">
|
||||
<div className="alerts-summary-grid situational-alerts-page__panels-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||
<Card title="系统告警侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||
<Card title="BGP 风险侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Space>
|
||||
|
||||
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
@@ -216,20 +216,22 @@ export function SystemAlertsPanel() {
|
||||
|
||||
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll system-alerts-page__summary-scroll">
|
||||
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Card className="system-alerts-page__table-card" title="系统告警列表">
|
||||
<div className="table-scroll-region system-alerts-page__table-region">
|
||||
<TableScrollRegion className="system-alerts-page__table-region">
|
||||
<Table<AlertRecord>
|
||||
columns={columns}
|
||||
dataSource={alerts}
|
||||
@@ -239,7 +241,7 @@ export function SystemAlertsPanel() {
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
@@ -260,7 +262,7 @@ export function SystemAlertsPanel() {
|
||||
</Modal>
|
||||
|
||||
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
<div className="alerts-brief-drawer">
|
||||
<Scrollbar className="alerts-brief-drawer">
|
||||
{briefLoading ? (
|
||||
<div className="alerts-brief-drawer__loading">
|
||||
<Spin tip="正在汇总系统告警事实并生成简报..." />
|
||||
@@ -293,7 +295,7 @@ export function SystemAlertsPanel() {
|
||||
</Card>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Drawer>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
} from 'antd'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import {
|
||||
type BGPAnomaly,
|
||||
@@ -708,37 +708,36 @@ function BGP() {
|
||||
/>
|
||||
|
||||
<Card className="bgp-page__summary-card">
|
||||
<Row
|
||||
gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]}
|
||||
className={`bgp-page__summary-grid${compactViewport ? ' bgp-page__summary-grid--compact' : ''}`}
|
||||
wrap={!compactViewport}
|
||||
>
|
||||
{summaryItems.map((item) => (
|
||||
<Col
|
||||
key={item.label}
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={8}
|
||||
flex={compactViewport ? '180px' : undefined}
|
||||
>
|
||||
<div className="bgp-page__summary-item">
|
||||
<div className="bgp-page__summary-label">{item.label}</div>
|
||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||
<Scrollbar className="bgp-page__summary-scroll">
|
||||
<div
|
||||
className="bgp-page__summary-grid"
|
||||
style={{ gap: `${compactViewport ? 8 : 12}px` }}
|
||||
>
|
||||
{summaryItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="bgp-page__summary-cell"
|
||||
style={{ width: compactViewport ? '180px' : '220px' }}
|
||||
>
|
||||
<div className="bgp-page__summary-item">
|
||||
<div className="bgp-page__summary-label">{item.label}</div>
|
||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Card>
|
||||
|
||||
<Card className="bgp-page__table-card">
|
||||
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
||||
<TableScrollRegion ref={tableRegionRef} className="bgp-page__table-region">
|
||||
<Tabs
|
||||
className="bgp-page__tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -755,7 +754,7 @@ function BGP() {
|
||||
destroyOnHidden
|
||||
>
|
||||
{brief ? (
|
||||
<div className="bgp-page__brief-modal-body">
|
||||
<Scrollbar className="bgp-page__brief-modal-body">
|
||||
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
|
||||
<div className="bgp-page__brief-evidence">
|
||||
{brief.facts.length > 0 ? (
|
||||
@@ -785,7 +784,7 @@ function BGP() {
|
||||
</div>
|
||||
) : null}
|
||||
<MarkdownRenderer markdown={brief.content_markdown} />
|
||||
</div>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<Text type="secondary">当前没有可查看的简报内容。</Text>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
@@ -532,11 +533,11 @@ function Dashboard() {
|
||||
|
||||
<div className="dashboard-restart-section">
|
||||
<Text className="dashboard-restart-section__label">终端输出</Text>
|
||||
<div className="dashboard-restart-log">
|
||||
<Scrollbar className="dashboard-restart-log">
|
||||
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
|
||||
<div key={`${line}-${index}`}>{line}</div>
|
||||
)) : <div>等待操作</div>}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -939,7 +940,7 @@ function DataList() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
|
||||
<TableScrollRegion className="data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
@@ -960,7 +961,7 @@ function DataList() {
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import {
|
||||
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||||
@@ -116,18 +118,39 @@ function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgres
|
||||
if (!batch || batch.sourceIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running)
|
||||
if (hasRunningItem) {
|
||||
return batch
|
||||
function resolveTerminalBatchItem(
|
||||
sourceId: number,
|
||||
batch: BulkProgressBatch,
|
||||
builtInSources: BuiltInDataSource[],
|
||||
taskProgress: Record<number, TaskTrackerState>,
|
||||
): BulkProgressItem | null {
|
||||
const currentItem = batch.items[sourceId]
|
||||
const source = builtInSources.find((item) => item.id === sourceId)
|
||||
const trackedTask = taskProgress[sourceId]
|
||||
|
||||
const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false
|
||||
if (isRunning) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allFinished = batch.sourceIds.every((sourceId) => {
|
||||
const status = batch.items[sourceId]?.status
|
||||
return Boolean(status && status !== 'running')
|
||||
})
|
||||
const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null
|
||||
if (!status || status === 'running') {
|
||||
return null
|
||||
}
|
||||
|
||||
return allFinished ? null : batch
|
||||
return {
|
||||
task_id: trackedTask?.task_id ?? currentItem?.task_id ?? source?.task_id ?? null,
|
||||
progress:
|
||||
status === 'success'
|
||||
? 100
|
||||
: trackedTask?.progress ?? source?.progress ?? currentItem?.progress ?? 0,
|
||||
is_running: false,
|
||||
phase: trackedTask?.phase ?? source?.phase ?? currentItem?.phase ?? null,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
interface WebSocketTaskMessage {
|
||||
@@ -192,6 +215,8 @@ function DataSources() {
|
||||
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
|
||||
const [customTableHeight, setCustomTableHeight] = useState(360)
|
||||
const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions()
|
||||
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -436,6 +461,41 @@ function DataSources() {
|
||||
return () => clearInterval(interval)
|
||||
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkProgressBatch) return
|
||||
|
||||
let changed = false
|
||||
const nextItems = { ...bulkProgressBatch.items }
|
||||
|
||||
for (const sourceId of bulkProgressBatch.sourceIds) {
|
||||
const nextItem = resolveTerminalBatchItem(sourceId, bulkProgressBatch, builtInSources, taskProgress)
|
||||
if (!nextItem) continue
|
||||
|
||||
const previousItem = bulkProgressBatch.items[sourceId]
|
||||
if (
|
||||
previousItem?.status === nextItem.status &&
|
||||
previousItem?.is_running === nextItem.is_running &&
|
||||
previousItem?.progress === nextItem.progress &&
|
||||
previousItem?.phase === nextItem.phase
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextItems[sourceId] = nextItem
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!changed) return
|
||||
|
||||
setBulkProgressBatch((prev) => {
|
||||
if (!prev) return prev
|
||||
return finalizeBulkProgressBatch({
|
||||
...prev,
|
||||
items: nextItems,
|
||||
})
|
||||
})
|
||||
}, [bulkProgressBatch, builtInSources, taskProgress])
|
||||
|
||||
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
||||
const force = options?.force ?? false
|
||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
||||
@@ -884,10 +944,29 @@ function DataSources() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right' as const,
|
||||
width: builtinActionsCollapsed ? 40 : 164,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: BuiltInDataSource) => (
|
||||
<Space size="small">
|
||||
<TableActions
|
||||
collapsed={builtinActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'trigger',
|
||||
label: '触发',
|
||||
icon: <SyncOutlined />,
|
||||
disabled: !record.is_active,
|
||||
onClick: () => handleTrigger(record.id),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: record.is_active ? '禁用' : '启用',
|
||||
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
danger: record.is_active,
|
||||
onClick: () => handleToggle(record.id, record.is_active),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -907,7 +986,7 @@ function DataSources() {
|
||||
>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -945,30 +1024,56 @@ function DataSources() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right' as const,
|
||||
width: customActionsCollapsed ? 40 : 228,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: CustomDataSource) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={record.is_active ? '禁用' : '启用'}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此配置?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
<TableActions
|
||||
collapsed={customActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => openDrawer(record),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: record.is_active ? '禁用' : '启用',
|
||||
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
danger: record.is_active,
|
||||
onClick: () => handleToggleCustom(record.id, record.is_active),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: '确定删除此配置?',
|
||||
onOk: () => handleDelete(record.id),
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}>编辑</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
danger={record.is_active}
|
||||
style={record.is_active ? undefined : { color: '#52c41a' }}
|
||||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Popconfirm title="确定删除此配置?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -978,7 +1083,7 @@ function DataSources() {
|
||||
key: 'builtin',
|
||||
label: '内置数据源',
|
||||
children: (
|
||||
<div className="page-shell__body data-source-builtin-tab">
|
||||
<div className="page-shell__body data-source-builtin-tab" ref={builtinContainerRef}>
|
||||
<div className="data-source-bulk-toolbar">
|
||||
<div className="data-source-bulk-toolbar__meta">
|
||||
<div className="data-source-bulk-toolbar__title">采集实时进度</div>
|
||||
@@ -1064,7 +1169,7 @@ function DataSources() {
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="page-shell__body data-source-custom-tab">
|
||||
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
|
||||
<div className="data-source-custom-toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
添加数据源
|
||||
|
||||
@@ -22,6 +22,8 @@ import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
@@ -237,8 +239,10 @@ function Playground() {
|
||||
const [editingContent, setEditingContent] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [composerFocused, setComposerFocused] = useState(false)
|
||||
const pollTimerRef = useRef<number | null>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const messagesShellRef = useRef<HTMLDivElement | null>(null)
|
||||
const forceScrollToBottomRef = useRef(true)
|
||||
|
||||
const selectedPreset = useMemo(
|
||||
@@ -575,7 +579,7 @@ function Playground() {
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="playground-card__scroll">
|
||||
<Scrollbar className="playground-card__scroll">
|
||||
<Spin spinning={statusLoading}>
|
||||
{providerStatus ? (
|
||||
<div className="playground-provider-panel">
|
||||
@@ -617,7 +621,7 @@ function Playground() {
|
||||
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -678,7 +682,7 @@ function Playground() {
|
||||
title="AI Chatbox"
|
||||
extra={(
|
||||
<Space size={4}>
|
||||
<Tooltip title="服务状态">
|
||||
<Tooltip title="服务状态" className="playground-chat__service-btn">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
@@ -699,7 +703,7 @@ function Playground() {
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div className="playground-chat__messages-shell">
|
||||
<div ref={messagesShellRef} className="playground-chat__messages-shell">
|
||||
<div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
|
||||
{messages.map((entry) => (
|
||||
<div key={entry.id} className={`playground-message playground-message--${entry.role}`}>
|
||||
@@ -760,8 +764,9 @@ function Playground() {
|
||||
<Button size="small" type="primary" loading={editSaving} onClick={() => void handleSaveEditMessage(entry)}>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollbarOverlay containerRef={messagesShellRef} targetSelector=".playground-chat__messages" />
|
||||
</div>
|
||||
</div>
|
||||
) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
|
||||
<div className="playground-message__markdown">
|
||||
@@ -845,31 +850,50 @@ function Playground() {
|
||||
</div>
|
||||
|
||||
<div className="playground-chat__composer">
|
||||
<div className="playground-chat__input-wrap">
|
||||
<div className="playground-preset-strip__actions">
|
||||
{PLAYGROUND_PRESETS.map((preset) => (
|
||||
<Tag.CheckableTag
|
||||
key={preset.key}
|
||||
checked={preset.key === selectedPreset.key}
|
||||
onChange={() => handleApplyPreset(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
<div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
|
||||
{(composerFocused || !!inputValue) && (
|
||||
<div className="playground-preset-strip__actions">
|
||||
{PLAYGROUND_PRESETS.map((preset) => (
|
||||
<Tag.CheckableTag
|
||||
key={preset.key}
|
||||
checked={preset.key === selectedPreset.key}
|
||||
onChange={() => handleApplyPreset(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="playground-chat__input-row">
|
||||
<Input.TextArea
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
|
||||
placeholder="在这里输入本次分析请求..."
|
||||
className="playground-chat__input"
|
||||
onFocus={() => setComposerFocused(true)}
|
||||
onBlur={() => setComposerFocused(false)}
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!(composerFocused || !!inputValue) && (
|
||||
<Tooltip title={sendButtonTooltip}>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
className={`playground-chat__send-button${requestPending || streaming ? ' playground-chat__send-button--stop' : ''}`}
|
||||
icon={requestPending || streaming ? <BorderOutlined /> : <ArrowUpOutlined />}
|
||||
onClick={requestPending || streaming ? handleStop : () => void handleSend()}
|
||||
aria-label={sendButtonTooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
autoSize={{ minRows: 4, maxRows: 10 }}
|
||||
placeholder="在这里输入本次分析请求。你可以写观察、问题、目标,或者直接贴一段待分析事实。"
|
||||
className="playground-chat__input"
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(composerFocused || !!inputValue) && (
|
||||
<div className="playground-chat__actions">
|
||||
<div className="playground-chat__hints">
|
||||
<Tag>{title}</Tag>
|
||||
@@ -886,8 +910,9 @@ function Playground() {
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1000,42 +1025,42 @@ function Playground() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="playground-result-modal__content">
|
||||
<Scrollbar className="playground-result-modal__content">
|
||||
<Space direction="vertical" size={16} className="playground-result__stack" style={{ width: '100%' }}>
|
||||
{analysis.text_blocks.length ? (
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>文本块</Text>
|
||||
<div className="playground-result__blocks-scroll">
|
||||
<Scrollbar className="playground-result__blocks-scroll">
|
||||
{analysis.text_blocks.map((block, index) => (
|
||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||
<pre>{block}</pre>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
{analysis.thinking_blocks.length ? (
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>Thinking Blocks</Text>
|
||||
<div className="playground-result__blocks-scroll">
|
||||
<Scrollbar className="playground-result__blocks-scroll">
|
||||
{analysis.thinking_blocks.map((block, index) => (
|
||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||
<pre>{block}</pre>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>Raw Response</Text>
|
||||
<div className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
|
||||
<Scrollbar className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
|
||||
<Card size="small">
|
||||
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -6,15 +9,19 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -91,7 +98,7 @@ function SettingsPanel({
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card" loading={loading}>
|
||||
<div className="settings-panel-scroll">{children}</div>
|
||||
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
@@ -106,11 +113,14 @@ function Settings() {
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
||||
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
||||
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
|
||||
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
|
||||
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
|
||||
const [systemForm] = Form.useForm<SystemSettings>()
|
||||
const [notificationForm] = Form.useForm<NotificationSettings>()
|
||||
const [securityForm] = Form.useForm<SecuritySettings>()
|
||||
const [tvEditForm] = Form.useForm<TVStreamSource>()
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
@@ -204,90 +214,95 @@ function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => {
|
||||
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev))
|
||||
}
|
||||
|
||||
const updateTvSourceField = <K extends keyof TVStreamSource>(
|
||||
sourceId: string,
|
||||
field: K,
|
||||
value: TVStreamSource[K]
|
||||
) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.map((source) => {
|
||||
if (field === 'is_fallback' && value === true) {
|
||||
return { ...source, is_fallback: source.id === sourceId }
|
||||
}
|
||||
if (source.id === sourceId) {
|
||||
return { ...source, [field]: value }
|
||||
}
|
||||
return source
|
||||
})
|
||||
|
||||
const nextDefaultSourceId =
|
||||
field === 'is_enabled' && value === false && prev.default_source_id === sourceId
|
||||
? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || ''
|
||||
: prev.default_source_id
|
||||
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
})
|
||||
const setDefaultSource = (sourceId: string) => {
|
||||
if (!tvSettings) return
|
||||
const next = { ...tvSettings, default_source_id: sourceId }
|
||||
setTvSettings(next)
|
||||
saveTvSettings(next)
|
||||
}
|
||||
|
||||
const addTvSource = () => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextIndex = prev.sources.length + 1
|
||||
const newSource: TVStreamSource = {
|
||||
id: `manual-tv-${Date.now()}`,
|
||||
name: `新闻直播源 ${nextIndex}`,
|
||||
provider: 'Manual',
|
||||
region: 'Global',
|
||||
language: 'und',
|
||||
source_type: 'iframe',
|
||||
embed_url: '',
|
||||
stream_url: '',
|
||||
homepage_url: '',
|
||||
poster_url: '',
|
||||
youtube_video_id: '',
|
||||
youtube_channel: '',
|
||||
is_enabled: true,
|
||||
is_fallback: false,
|
||||
sort_order: nextIndex * 10,
|
||||
collector_source: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sources: [...prev.sources, newSource],
|
||||
}
|
||||
})
|
||||
const nextIndex = (tvSettings?.sources.length || 0) + 1
|
||||
const newSource: TVStreamSource = {
|
||||
id: `manual-tv-${Date.now()}`,
|
||||
name: `新闻直播源 ${nextIndex}`,
|
||||
provider: 'Manual',
|
||||
region: 'Global',
|
||||
language: 'und',
|
||||
source_type: 'iframe',
|
||||
embed_url: '',
|
||||
stream_url: '',
|
||||
homepage_url: '',
|
||||
poster_url: '',
|
||||
youtube_video_id: '',
|
||||
youtube_channel: '',
|
||||
is_enabled: true,
|
||||
is_fallback: false,
|
||||
sort_order: nextIndex * 10,
|
||||
collector_source: null,
|
||||
notes: '',
|
||||
}
|
||||
setEditingSource(newSource)
|
||||
tvEditForm.setFieldsValue(newSource)
|
||||
}
|
||||
|
||||
const removeTvSource = (sourceId: string) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.filter((source) => source.id !== sourceId)
|
||||
const nextDefaultSourceId =
|
||||
prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
const confirmEditSource = async () => {
|
||||
if (!editingSource || !tvSettings) return
|
||||
const values = tvEditForm.getFieldsValue()
|
||||
const nextSources = tvSettings.sources
|
||||
.map((source) => {
|
||||
if (source.id === editingSource.id) return { ...source, ...values }
|
||||
if (values.is_fallback) return { ...source, is_fallback: false }
|
||||
return source
|
||||
})
|
||||
|
||||
if (!tvSettings.sources.some((source) => source.id === editingSource.id)) {
|
||||
nextSources.push({
|
||||
...editingSource,
|
||||
...values,
|
||||
})
|
||||
if (values.is_fallback) {
|
||||
for (let index = 0; index < nextSources.length - 1; index += 1) {
|
||||
nextSources[index] = { ...nextSources[index], is_fallback: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const nextDefaultSourceId =
|
||||
values.is_enabled === false && tvSettings.default_source_id === editingSource.id
|
||||
? nextSources.find((s) => s.id !== editingSource.id && s.is_enabled)?.id || ''
|
||||
: tvSettings.default_source_id
|
||||
const next = { ...tvSettings, default_source_id: nextDefaultSourceId, sources: nextSources }
|
||||
setTvSettings(next)
|
||||
setEditingSource(null)
|
||||
await saveTvSettings(next)
|
||||
}
|
||||
|
||||
const saveTvSettings = async () => {
|
||||
const removeTvSource = async (sourceId: string) => {
|
||||
if (!tvSettings) return
|
||||
|
||||
const nextSources = tvSettings.sources.filter((source) => source.id !== sourceId)
|
||||
const nextDefaultSourceId =
|
||||
tvSettings.default_source_id === sourceId ? nextSources[0]?.id || '' : tvSettings.default_source_id
|
||||
const next = {
|
||||
...tvSettings,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
|
||||
setTvSettings(next)
|
||||
if (editingSource?.id === sourceId) {
|
||||
setEditingSource(null)
|
||||
}
|
||||
await saveTvSettings(next)
|
||||
}
|
||||
|
||||
const saveTvSettings = async (next?: TVSettings) => {
|
||||
const toSave = next ?? tvSettings
|
||||
if (!toSave) return
|
||||
try {
|
||||
setSavingTvSettings(true)
|
||||
await axios.put('/api/v1/settings/tv', tvSettings)
|
||||
await axios.put('/api/v1/settings/tv', toSave)
|
||||
message.success('电视直播配置已保存')
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
@@ -400,105 +415,39 @@ function Settings() {
|
||||
const tvSourceColumns = [
|
||||
{
|
||||
title: '频道',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 220,
|
||||
render: (_: string, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} />
|
||||
<Input
|
||||
value={record.provider}
|
||||
placeholder="提供方"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
|
||||
/>
|
||||
width: 180,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{record.name}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{record.provider}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '区域 / 语言',
|
||||
key: 'locale',
|
||||
width: 160,
|
||||
width: 130,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.region}
|
||||
placeholder="区域"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'region', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.language}
|
||||
placeholder="语言"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'language', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary">{record.region} · {record.language}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'source_type',
|
||||
key: 'source_type',
|
||||
width: 120,
|
||||
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => (
|
||||
<Select
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(nextValue) => updateTvSourceField(record.id, 'source_type', nextValue)}
|
||||
options={[
|
||||
{ value: 'iframe', label: 'iframe' },
|
||||
{ value: 'hls', label: 'hls' },
|
||||
{ value: 'video', label: 'video' },
|
||||
{ value: 'youtube', label: 'youtube' },
|
||||
{ value: 'external', label: 'external' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '播放地址',
|
||||
key: 'urls',
|
||||
width: 320,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.embed_url}
|
||||
placeholder="嵌入地址 / iframe 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'embed_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.stream_url}
|
||||
placeholder="流地址 / HLS 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'stream_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_video_id}
|
||||
placeholder="YouTube 视频 ID(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_video_id', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_channel}
|
||||
placeholder="YouTube 频道 Handle / URL(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_channel', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '官网',
|
||||
dataIndex: 'homepage_url',
|
||||
key: 'homepage_url',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'homepage_url', event.target.value)} />
|
||||
),
|
||||
width: 90,
|
||||
render: (value: string) => <Tag>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
width: 130,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} />
|
||||
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} />
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' as const }}>
|
||||
<Tag color={record.is_enabled ? 'success' : 'default'}>{record.is_enabled ? '启用' : '禁用'}</Tag>
|
||||
{record.id === tvSettings?.default_source_id && <Tag color="gold">默认</Tag>}
|
||||
{record.is_fallback && <Tag color="blue">备用</Tag>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -506,20 +455,82 @@ function Settings() {
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
|
||||
),
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
fixed: 'right' as const,
|
||||
width: tvActionsCollapsed ? 40 : 258,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
|
||||
删除
|
||||
</Button>
|
||||
<TableActions
|
||||
collapsed={tvActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'default',
|
||||
label: '设为默认',
|
||||
icon: <CheckCircleOutlined />,
|
||||
disabled: record.id === tvSettings?.default_source_id,
|
||||
onClick: () => setDefaultSource(record.id),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setEditingSource(record)
|
||||
tvEditForm.setFieldsValue(record)
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
disabled: record.id === tvSettings?.default_source_id,
|
||||
onClick: () => {
|
||||
void removeTvSource(record.id)
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={record.id === tvSettings?.default_source_id}
|
||||
onClick={() => setDefaultSource(record.id)}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingSource(record)
|
||||
tvEditForm.setFieldsValue(record)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={record.id === tvSettings?.default_source_id}
|
||||
onClick={() => {
|
||||
void removeTvSource(record.id)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -610,51 +621,111 @@ function Settings() {
|
||||
key: 'tv',
|
||||
label: '电视直播',
|
||||
children: (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card settings-panel-card--table" loading={loading}>
|
||||
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}>
|
||||
<div className="settings-tv-toolbar">
|
||||
<div className="settings-tv-toolbar__controls">
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">默认直播源</Text>
|
||||
<Select
|
||||
value={tvSettings?.default_source_id}
|
||||
style={{ minWidth: 260 }}
|
||||
options={(tvSettings?.sources || []).map((source) => ({
|
||||
value: source.id,
|
||||
label: source.name,
|
||||
}))}
|
||||
onChange={(value) => updateTvSetting('default_source_id', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">自动回退</Text>
|
||||
<Switch
|
||||
checked={tvSettings?.auto_fallback || false}
|
||||
onChange={(checked) => updateTvSetting('auto_fallback', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-tv-toolbar__actions">
|
||||
<Button onClick={addTvSource}>新增直播源</Button>
|
||||
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}>
|
||||
保存电视直播配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1500, y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-pane" ref={tvTableRef}>
|
||||
<Card
|
||||
className="settings-panel-card settings-panel-card--table"
|
||||
loading={loading}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<TableScrollRegion
|
||||
className="data-source-table-region"
|
||||
style={{ flex: '1 1 auto', minHeight: 0 }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</TableScrollRegion>
|
||||
<Tooltip title="新增直播源">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addTvSource}
|
||||
style={{ width: '100%', borderRadius: 0, borderTop: '1px solid rgba(0,0,0,0.06)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
<Modal
|
||||
title={editingSource?.id.startsWith('manual-tv-') ? '新增直播源' : '编辑直播源'}
|
||||
open={editingSource !== null}
|
||||
onOk={confirmEditSource}
|
||||
onCancel={() => {
|
||||
setEditingSource(null)
|
||||
tvEditForm.resetFields()
|
||||
}}
|
||||
okText="保存"
|
||||
okButtonProps={{ loading: savingTvSettings }}
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
centered
|
||||
destroyOnHidden
|
||||
className="settings-tv-edit-modal"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div className="settings-tv-edit-modal__body">
|
||||
<Scrollbar className="settings-tv-edit-modal__scroll">
|
||||
<Form form={tvEditForm} layout="vertical" style={{ paddingBottom: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name="name" label="频道名称" rules={[{ required: true, message: '请输入频道名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供方">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="region" label="区域">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label="语言">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="source_type" label="类型">
|
||||
<Select options={[
|
||||
{ value: 'iframe', label: 'iframe' },
|
||||
{ value: 'hls', label: 'HLS' },
|
||||
{ value: 'video', label: 'video' },
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'external', label: 'external(仅外部打开)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="embed_url" label="嵌入地址 / iframe 地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="stream_url" label="流地址 / HLS 地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="youtube_video_id" label="YouTube 视频 ID">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="youtube_channel" label="YouTube 频道 Handle / URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="homepage_url" label="官网地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_fallback" label="设为备用源" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -668,7 +739,7 @@ function Settings() {
|
||||
loading={loading}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<TableScrollRegion ref={collectorTableRegionRef} className="data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={collectorColumns}
|
||||
@@ -678,7 +749,7 @@ function Settings() {
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
|
||||
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
interface Task {
|
||||
@@ -146,9 +147,9 @@ function Tasks() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="table-scroll-region">
|
||||
<TableScrollRegion>
|
||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Button, Tag, message, Modal, Form, Input, Select } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -18,9 +21,8 @@ function Users() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
const [form] = Form.useForm()
|
||||
const [actionsCollapsed, containerRef] = useCollapsedActions()
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
@@ -36,24 +38,6 @@ function Users() {
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
||||
setTableHeight(Math.max(220, regionHeight - 56))
|
||||
}
|
||||
|
||||
updateTableHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateTableHeight)
|
||||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [users.length])
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null)
|
||||
form.resetFields()
|
||||
@@ -126,12 +110,21 @@ function Users() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right' as const,
|
||||
width: actionsCollapsed ? 56 : 172,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: User) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
<TableActions
|
||||
collapsed={actionsCollapsed}
|
||||
items={[
|
||||
{ key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: () => handleEdit(record) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, onClick: () => handleDelete(record.id) },
|
||||
]}
|
||||
>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -143,17 +136,19 @@ function Users() {
|
||||
<h2 style={{ margin: 0 }}>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}>
|
||||
<div className="page-shell__body" ref={containerRef}>
|
||||
<TableScrollRegion className="data-source-table-region users-table-region">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={false}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user