Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5dd4f12f8 | ||
|
|
75cb214f23 | ||
|
|
4c21973197 | ||
|
|
51ae5e6ec9 | ||
|
|
1cf1f32ddd | ||
|
|
8f3ab88743 | ||
|
|
f8b43a995b |
10
README.md
10
README.md
@@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
|
||||
详细文档:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md)
|
||||
- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md)
|
||||
- [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md)
|
||||
|
||||
## 前端页面布局规范
|
||||
|
||||
@@ -346,7 +346,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
当前推荐参考实现:
|
||||
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
完整使用说明见:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
|
||||
当前支持:
|
||||
|
||||
|
||||
@@ -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,98 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.28.2] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- 修正媒体情报面板在 `电视直播 / 态势聚合` tab 间切换时的尺寸记忆逻辑,切回原 tab 后可恢复各自大小状态
|
||||
- 清理 `docs/` 根目录遗留的旧路径文档,只保留新的分组目录和归档目录,结束同一文档双路径并存状态
|
||||
|
||||
### 🔧 Improvements
|
||||
- `media-panel` 切换逻辑改成按 tab 分别记忆尺寸状态,避免 `A -> B -> A` 时继续共用同一套外层尺寸
|
||||
- 目录整理真正完成收尾:旧的 `docs/*.md` 平铺计划文档删除,继续以 `docs/agents / earth / backend / frontend / ops / ue5 / deprecated` 为唯一入口
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复拉伸 `media-panel` 后切换 tab 时,`news-panel` 高度回退到旧默认值的问题
|
||||
- 修复拉伸后切换 tab 导致面板视觉锚点异常的问题,切换时改为围绕当前卡片自身右下角进行尺寸恢复
|
||||
|
||||
---
|
||||
|
||||
## [0.28.1] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- 收口 Earth 媒体情报面板命名,明确外层 `media-panel` 与内部 `tv-panel / news-panel` 的职责边界
|
||||
- 整理 `docs/` 目录分组,并将已完成或已废弃的计划文档归档到 `docs/deprecated`
|
||||
|
||||
### 🔧 Improvements
|
||||
- 底部 tab 语义统一为 `media-panel-tabs / media-panel-tab`,并将文案更新为“电视直播 / 态势聚合”
|
||||
- 补充媒体面板、聚合新闻模块的注释说明,减少 `tv-panel` 同时指代外层壳和内层直播 pane 的阅读歧义
|
||||
- 更新 README、AI Provider README 与历史文档互链,适配新的 `docs/agents / docs/earth / docs/frontend / docs/backend / docs/ops / docs/ue5` 分组结构
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板标题组在头部撑出多余空白的问题,去掉 `hud-panel__title-group` 的无效弹性占位
|
||||
- 修复聚合 tab 头部仍保留冗余固定标题的问题,现在仅显示区域标签
|
||||
|
||||
---
|
||||
|
||||
## [0.28.0] — 2026-04-20
|
||||
|
||||
### ✨ Features
|
||||
- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区
|
||||
- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现
|
||||
|
||||
说明:
|
||||
- 当前结构中,外层 HUD 壳为 `media-panel`,内部 tab 内容区分别为 `tv-panel` 和 `news-panel`
|
||||
|
||||
### 🔧 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
|
||||
@@ -137,7 +229,7 @@ Released: 2026-04-12
|
||||
|
||||
- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
|
||||
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
|
||||
- Added [docs/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/earth/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -223,7 +315,7 @@ Released: 2026-04-10
|
||||
|
||||
- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling.
|
||||
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -261,7 +353,7 @@ Released: 2026-04-10
|
||||
### Improved
|
||||
|
||||
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
|
||||
## 0.24.6
|
||||
|
||||
@@ -278,7 +370,7 @@ Released: 2026-04-10
|
||||
- Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
|
||||
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
|
||||
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
|
||||
- Improved [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
- Improved [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -384,8 +476,8 @@ Released: 2026-04-09
|
||||
### Added
|
||||
|
||||
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
|
||||
- Added [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
- Added [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -490,7 +582,7 @@ Released: 2026-04-07
|
||||
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
|
||||
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
|
||||
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
|
||||
- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
|
||||
### Improved
|
||||
@@ -616,7 +708,7 @@ Released: 2026-04-02
|
||||
|
||||
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
|
||||
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/prefix-geography-plan.md).
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/earth/prefix-geography-plan.md).
|
||||
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
|
||||
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
|
||||
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
|
||||
@@ -631,7 +723,7 @@ Released: 2026-04-02
|
||||
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
|
||||
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
|
||||
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -789,7 +881,7 @@ Released: 2026-03-31
|
||||
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
|
||||
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md).
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/backend/system-service-control.md).
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -992,7 +1084,7 @@ Released: 2026-03-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md).
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-module-plan.md).
|
||||
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
|
||||
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ This document connects three existing planning threads into one implementation r
|
||||
|
||||
Related documents:
|
||||
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agent-architecture-plan.md)
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/agents/datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agents/agent-architecture-plan.md)
|
||||
|
||||
|
||||
## Big Picture
|
||||
17
docs/deprecated/README.md
Normal file
17
docs/deprecated/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Deprecated Docs
|
||||
|
||||
这个目录用于存放两类文档:
|
||||
|
||||
1. 已经完成、主要保留为历史记录的实施计划
|
||||
2. 已被现有实现或新方案替代的旧计划
|
||||
|
||||
放到这里并不代表这些文档“错误”,而是表示:
|
||||
|
||||
- 它们不再适合作为当前开发的主指导文档
|
||||
- 如果要了解历史决策、演进路径或旧设计背景,仍然可以参考
|
||||
|
||||
当前归档原则:
|
||||
|
||||
- 明确写明“已完成”的计划,优先归档
|
||||
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
|
||||
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
|
||||
165
docs/deprecated/hud-panel-component-plan.md
Normal file
165
docs/deprecated/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.
|
||||
@@ -187,7 +187,7 @@ Current reality:
|
||||
- that is expected, because incidents are aggregated and de-noised
|
||||
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
|
||||
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/bgp-region-aggregation-plan.md).
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-region-aggregation-plan.md).
|
||||
|
||||
So the immediate next milestone is:
|
||||
|
||||
@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
|
||||
|
||||
## Why This Layer Exists
|
||||
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md):
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md):
|
||||
|
||||
- incident density is naturally low
|
||||
- anomaly density is higher, but still not enough to keep the globe expressive all the time
|
||||
@@ -290,7 +290,7 @@ Each feature should include:
|
||||
|
||||
## Earth Rendering Plan
|
||||
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/bgp-earth-rendering-plan.md).
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-earth-rendering-plan.md).
|
||||
|
||||
### Layer Relationship
|
||||
|
||||
436
docs/earth/earth-celestial-background-plan.md
Normal file
436
docs/earth/earth-celestial-background-plan.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Earth 天球背景与日月位置实施方案
|
||||
|
||||
## 目标
|
||||
|
||||
为 Earth 大屏增加一套真正可用的天文背景层,覆盖三件事:
|
||||
|
||||
1. 用真实天球背景替换当前随机星点
|
||||
2. 在当前时间下显示太阳与月亮的相对位置
|
||||
3. 让太阳方向同时驱动地球受光,形成更可信的昼夜关系
|
||||
|
||||
本方案优先追求:
|
||||
|
||||
- 与当前 Three.js Earth 架构兼容
|
||||
- 风险可控
|
||||
- 先落地一版真实感明显提升的 V1
|
||||
- 为后续更严格的天文参考系升级预留余地
|
||||
|
||||
## 当前现状
|
||||
|
||||
当前 Earth 的基础条件已经具备:
|
||||
|
||||
- 地球、云层、地形、网格都基于 Three.js,主渲染入口在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 地球实体创建在 [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 当前所谓“宇宙背景”只是 `createStars()` 生成的随机星点,不是真实星图
|
||||
- Earth 已有倾角常量 `EARTH_CONFIG.tiltRad`,位于 [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 主循环 `animate()` 已稳定运行,可在其中接入天体更新逻辑
|
||||
|
||||
这意味着:
|
||||
|
||||
- 不需要重写 Earth
|
||||
- 可以在现有 scene/world 层新增一个 celestial layer
|
||||
- 第一阶段不必拆 Earth / satellite / cable 的参考系
|
||||
|
||||
## 总体策略
|
||||
|
||||
采用“两层现实”设计:
|
||||
|
||||
### 1. 世界层(world-space celestial layer)
|
||||
|
||||
用于放置:
|
||||
|
||||
- 天球背景
|
||||
- 太阳
|
||||
- 月亮
|
||||
- 太阳光方向
|
||||
|
||||
这些对象不挂在 `earthObj` 上,而是直接放在 `scene` 中。
|
||||
|
||||
### 2. 地球层(earth-fixed layer)
|
||||
|
||||
继续保持当前结构:
|
||||
|
||||
- 海缆
|
||||
- 登陆点
|
||||
- 卫星点与轨迹
|
||||
- BGP 覆盖
|
||||
- 地球纹理、云层、地形
|
||||
|
||||
这些对象继续挂在 `earthObj` 下,不打断现有交互。
|
||||
|
||||
## 为什么先这样做
|
||||
|
||||
当前用户交互是“拖动地球本体”,而不是“移动相机绕惯性系观测”。
|
||||
如果现在直接做严格惯性参考系改造,会同时影响:
|
||||
|
||||
- `earthObj.rotation`
|
||||
- 卫星轨迹与锁定逻辑
|
||||
- 海缆与登陆点附着关系
|
||||
- resetView / autoRotate / hover / click 等交互链路
|
||||
|
||||
所以第一阶段只做:
|
||||
|
||||
- 真正的天空
|
||||
- 真正的日月方向
|
||||
- 不碰现有 Earth 附着对象的语义
|
||||
|
||||
## 推荐技术选型
|
||||
|
||||
### 天文计算库
|
||||
|
||||
推荐:
|
||||
|
||||
- [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
|
||||
原因:
|
||||
|
||||
- 有 JavaScript 版本
|
||||
- 支持 Sun / Moon 的矢量与坐标变换
|
||||
- 精度、可扩展性都比轻量太阳高度角库更适合本项目
|
||||
- 后续若要加行星、月相、黄道、赤道网,也能继续沿用
|
||||
|
||||
不作为主选的库:
|
||||
|
||||
- [SunCalc](https://github.com/mourner/suncalc)
|
||||
|
||||
原因:
|
||||
|
||||
- 更偏本地观察者视角的太阳/月亮高度角
|
||||
- 用于“地面日出日落”很好
|
||||
- 但不如 Astronomy Engine 适合做真实天球与后续空间参考系扩展
|
||||
|
||||
### Three.js 表现层
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 天球:内翻球壳 + 星图纹理
|
||||
- 太阳:`THREE.Sprite`
|
||||
- 月亮:`THREE.Sprite` 或小型 `THREE.Mesh`
|
||||
- 太阳光:`THREE.DirectionalLight`
|
||||
|
||||
参考:
|
||||
|
||||
- [Three.js SpriteMaterial](https://threejs.org/docs/pages/SpriteMaterial.html)
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
## Phase 1:真实天球背景
|
||||
|
||||
### 目标
|
||||
|
||||
用真实全天星图替换当前随机星点背景。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 新增一张全天星图纹理
|
||||
|
||||
建议路径:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
纹理要求:
|
||||
|
||||
- 等距矩形投影
|
||||
- 赤经/赤纬坐标展开
|
||||
- 无地平线、无地景遮挡
|
||||
- 尽量深色、弱干扰,适合大屏 HUD 叠加
|
||||
|
||||
2. 新增天球球壳
|
||||
|
||||
新增模块:
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
建议接口:
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date, camera, earth)
|
||||
export function disposeCelestialLayer()
|
||||
```
|
||||
|
||||
3. 实现一个大半径内翻球体
|
||||
|
||||
建议参数:
|
||||
|
||||
- 半径:`600 ~ 900`
|
||||
- 材质:`MeshBasicMaterial`
|
||||
- `side: THREE.BackSide`
|
||||
- 不受场景光照影响
|
||||
- 始终围绕场景中心
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 初始加载后背景不再是随机星点
|
||||
- 旋转地球时,背景保持为稳定天球而不是跟地球一起转
|
||||
- 不明显干扰海缆/卫星/BGP 的前景识别
|
||||
|
||||
## Phase 2:太阳与月亮真实位置
|
||||
|
||||
### 目标
|
||||
|
||||
在当前 UTC 时间下,计算太阳与月亮在天球中的方向,并显示出来。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 在 `celestial.js` 内封装天体位置计算
|
||||
|
||||
建议函数:
|
||||
|
||||
```js
|
||||
function getSunDirection(date)
|
||||
function getMoonDirection(date)
|
||||
```
|
||||
|
||||
输出统一为 world-space `THREE.Vector3`
|
||||
|
||||
2. 太阳显示
|
||||
|
||||
- 一个暖色发光 sprite
|
||||
- 比月亮更大、更亮
|
||||
- 可选添加柔和 halo
|
||||
|
||||
3. 月亮显示
|
||||
|
||||
- 一个较小 sprite 或 sphere
|
||||
- 灰白偏冷色
|
||||
- 后续 Phase 3 再做月相
|
||||
|
||||
4. 更新频率
|
||||
|
||||
不要每帧重新做完整天文计算,建议:
|
||||
|
||||
- 每 30 秒或 60 秒重算一次真实位置
|
||||
- 渲染帧内做平滑过渡
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 页面可见太阳与月亮两个对象
|
||||
- 时间变化时位置会更新
|
||||
- 日月不会跟随地球局部旋转而错误附着
|
||||
|
||||
## Phase 3:太阳驱动地球受光
|
||||
|
||||
### 目标
|
||||
|
||||
让地球光照方向与太阳方向一致,不再使用写死的固定主光。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 替换或接管当前主定向光
|
||||
|
||||
当前 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 中 `addLights()` 里用了固定方向的 `DirectionalLight`。
|
||||
|
||||
建议改为:
|
||||
|
||||
- 保留环境补光
|
||||
- 主太阳光方向由 `sunDirection` 决定
|
||||
|
||||
2. 太阳光参数建议
|
||||
|
||||
- `DirectionalLight` 颜色偏暖白
|
||||
- 强度略高于当前主光
|
||||
- 保留一个弱背光作为氛围补偿,避免背面过死黑
|
||||
|
||||
3. 先不做物理级大气散射
|
||||
|
||||
第一版只要求:
|
||||
|
||||
- 亮面与暗面方向真实
|
||||
- 云层和大气仍保持当前风格
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 地球明暗面会随太阳方向改变
|
||||
- 太阳 sprite 和地球亮面方向一致
|
||||
- 不破坏现有海缆、卫星、BGP 的可见性
|
||||
|
||||
## Phase 4:月相与天文细节增强
|
||||
|
||||
### 目标
|
||||
|
||||
在日月真实位置基础上增加更强的“天文可信度”。
|
||||
|
||||
### 可选项
|
||||
|
||||
1. 月相
|
||||
|
||||
- 根据日月夹角计算 illuminated fraction
|
||||
- 用月相纹理或 shader 表达盈亏
|
||||
|
||||
2. 赤道/黄道辅助线
|
||||
|
||||
- 可作为开发调试层,不默认显示
|
||||
|
||||
3. 太阳 terminator 增强
|
||||
|
||||
- 给地球夜面加入更自然的 night tint
|
||||
- 未来可叠加城市夜光纹理
|
||||
|
||||
4. 天文时间入口
|
||||
|
||||
- 设置中加入“当前时刻 / 指定时刻 / 加速时间”模式
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 月亮不再只是一个静态圆点
|
||||
- 后续扩展行星或观测模式时无需推倒重来
|
||||
|
||||
## Phase 5:严格参考系升级(可选,不作为 V1 必做)
|
||||
|
||||
### 目标
|
||||
|
||||
把 Earth 从“用户旋转球体”升级为“真实地球姿态 + 用户观察姿态”的双层模型。
|
||||
|
||||
### 需要处理的问题
|
||||
|
||||
- 地球自转角与 UTC 的一致性
|
||||
- 赤道坐标系、地固坐标系、相机交互层分离
|
||||
- 卫星轨道显示与 Earth 旋转同步关系
|
||||
- resetView 和 autoRotate 的语义重定
|
||||
|
||||
### 风险
|
||||
|
||||
这一步会影响:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [frontend/public/earth/js/cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
|
||||
因此不建议与 V1 同时推进。
|
||||
|
||||
## 代码改造清单
|
||||
|
||||
## 1. 新增文件
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 管理天球背景、太阳、月亮
|
||||
- 对外暴露 init/update/dispose
|
||||
|
||||
## 2. 修改 `constants.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
|
||||
新增:
|
||||
|
||||
```js
|
||||
export const CELESTIAL_CONFIG = {
|
||||
sphereRadius: 800,
|
||||
updateIntervalMs: 60000,
|
||||
sunSpriteScale: 28,
|
||||
moonSpriteScale: 16,
|
||||
sunLightIntensity: 1.25,
|
||||
ambientIntensity: 0.28,
|
||||
backLightIntensity: 0.18,
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 修改 `main.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
1. `init()` 中:
|
||||
- 初始化 celestial layer
|
||||
2. `addLights()` 中:
|
||||
- 把固定太阳光改成可更新的 celestial sun light
|
||||
3. `animate()` 中:
|
||||
- 每帧调 `updateCelestialLayer()`
|
||||
4. `destroy()` 中:
|
||||
- 清理 celestial 资源
|
||||
|
||||
## 4. 修改 `earth.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
- `createStars()` 逐步退役
|
||||
- 第一阶段可先保留作为 fallback
|
||||
- 当真实星图加载成功后,不再显示随机星点
|
||||
|
||||
## 5. 新增资源
|
||||
|
||||
目录建议:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/`
|
||||
|
||||
建议至少包含:
|
||||
|
||||
- `starmap_equatorial_4k.jpg`
|
||||
- `sun_glow.png`
|
||||
- `moon_albedo_2k.jpg`
|
||||
|
||||
## 数据流设计
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["main.js:init()"] --> B["initCelestialLayer(scene)"]
|
||||
B --> C["创建天球球壳"]
|
||||
B --> D["创建太阳 sprite + 主定向光"]
|
||||
B --> E["创建月亮 sprite"]
|
||||
|
||||
F["animate()"] --> G["updateCelestialLayer(now, camera, earth)"]
|
||||
G --> H["Astronomy Engine 计算 Sun/Moon 方向"]
|
||||
H --> I["更新 sun sprite / moon sprite 位置"]
|
||||
H --> J["更新太阳 DirectionalLight 方向"]
|
||||
J --> K["地球昼夜方向变化"]
|
||||
```
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
### 1. 星图投影方向容易反
|
||||
|
||||
这会表现为:
|
||||
|
||||
- 星图左右镜像
|
||||
- 赤经方向颠倒
|
||||
- 日月位置和背景对不上
|
||||
|
||||
建议:
|
||||
|
||||
- 先做一个开发调试模式
|
||||
- 显示赤经/赤纬参考点,快速校正纹理朝向
|
||||
|
||||
### 2. 不要让天球跟随 Earth 旋转
|
||||
|
||||
天球背景和日月必须属于 scene/world,而不是 `earthObj`。
|
||||
|
||||
### 3. 不要每帧做重型天文计算
|
||||
|
||||
真实位置更新应节流,否则会浪费 CPU。
|
||||
|
||||
### 4. 月亮先求“方向正确”,再求“月相精致”
|
||||
|
||||
月相属于第二步优化,不应阻塞 V1 上线。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 新建 `celestial.js`
|
||||
2. 用星图球壳替换随机星点
|
||||
3. 接入 Astronomy Engine
|
||||
4. 加太阳/月亮 sprite
|
||||
5. 用太阳方向驱动主光
|
||||
6. 再决定要不要做月相和更严格参考系
|
||||
|
||||
## 最终建议
|
||||
|
||||
对于当前 Planet Earth,最稳妥的方案是:
|
||||
|
||||
- 先做真实天球背景
|
||||
- 再做真实太阳/月亮方向
|
||||
- 再让太阳驱动地球受光
|
||||
- 暂时不做 Earth 参考系重构
|
||||
|
||||
这样可以在不破坏现有 Earth 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。
|
||||
@@ -30,7 +30,7 @@
|
||||
- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py)
|
||||
- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
|
||||
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
|
||||
### 2. 本地运行与配置打通
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
相关文件:
|
||||
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
|
||||
## 当前限制
|
||||
@@ -16,12 +16,18 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.28.2`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.28.2` | bugfix | `dev` | `pending` | 修正媒体情报 tab 尺寸记忆与切换锚点逻辑,并清理 docs 根目录遗留旧路径文档 |
|
||||
| `0.28.1` | bugfix | `dev` | `pending` | 收口 Earth 媒体情报面板命名与 tab 文案,整理 docs 分组并归档已完成/废弃计划文档 |
|
||||
| `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 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.27.5",
|
||||
"version": "0.28.2",
|
||||
"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,22 +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;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.hud-panel-drag-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -90,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,
|
||||
@@ -112,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:
|
||||
@@ -313,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 {
|
||||
@@ -458,4 +541,4 @@
|
||||
/* .hud-panel-legend layout-expanded rule lives in legend.css */
|
||||
/* .hud-panel-stats layout-expanded rule lives in earth-stats.css */
|
||||
/* .hud-panel-layers layout-expanded rule lives in layer-panel.css */
|
||||
/* .hud-panel-tv layout-expanded rule lives in tv-panel.css */
|
||||
/* .hud-panel-media layout-expanded rule lives in tv-panel.css */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
@@ -51,38 +51,33 @@
|
||||
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:展开时朝上(可折叠),折叠时朝下(可展开) */
|
||||
.layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(0deg);
|
||||
transition: color 0.18s ease;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
/* ── Search bar ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────── */
|
||||
|
||||
182
frontend/public/earth/css/news-panel.css
Normal file
182
frontend/public/earth/css/news-panel.css
Normal file
@@ -0,0 +1,182 @@
|
||||
/* news-panel.css */
|
||||
|
||||
.news-panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
/* tv-panel */
|
||||
/* media-panel
|
||||
* Outer HUD shell: #media-panel
|
||||
* Inner live pane: #tv-panel
|
||||
* Inner news pane: #news-panel
|
||||
*/
|
||||
|
||||
.hud-panel-tv {
|
||||
.hud-panel-media {
|
||||
bottom: var(--hud-offset);
|
||||
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));
|
||||
padding: calc(10px * var(--hud-scale));
|
||||
display: flex;
|
||||
@@ -13,14 +18,28 @@
|
||||
z-index: 18;
|
||||
}
|
||||
|
||||
/* header 内嵌 select + actions */
|
||||
.hud-panel-tv .hud-panel-header {
|
||||
.hud-panel-media[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-media.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-media .hud-panel__header {
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.hud-panel-tv .hud-panel-header .hud-panel-close {
|
||||
align-self: center;
|
||||
.hud-panel-media .hud-panel__title-group {
|
||||
flex: 0 0 auto;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-title {
|
||||
@@ -29,13 +48,53 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* select / action 在 drag-handle 内,恢复正常指针 */
|
||||
.hud-panel-tv .hud-panel-header .tv-panel-select,
|
||||
.hud-panel-tv .hud-panel-header .tv-panel-action {
|
||||
.hud-panel-media .hud-panel__header .hud-panel__action,
|
||||
.hud-panel-media .hud-panel__header .hud-panel-close {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header .tv-panel-select,
|
||||
.hud-panel-media .hud-panel__header .media-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 {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
@@ -63,58 +122,6 @@
|
||||
color: #eef5fc;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action--icon .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-panel-action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.tv-panel-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* toggle 箭头:展开时朝下,折叠时朝上 */
|
||||
.tv-panel-meta-toggle .material-symbols-rounded {
|
||||
transition: transform 0.22s ease;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.tv-panel-meta-toggle.is-collapsed .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* meta wrap:折叠时用负 margin 抵消 flex gap,无死区 */
|
||||
.tv-panel-meta-wrap {
|
||||
overflow: hidden;
|
||||
max-height: calc(120px * var(--hud-scale));
|
||||
@@ -196,6 +203,41 @@
|
||||
background: #050a14;
|
||||
}
|
||||
|
||||
.media-panel-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.media-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;
|
||||
}
|
||||
|
||||
.media-panel-tab:hover {
|
||||
color: var(--hud-text);
|
||||
border-color: rgba(214, 235, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.media-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 {
|
||||
@@ -257,35 +299,35 @@
|
||||
border-color: rgba(244, 249, 255, 0.75);
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-resizing {
|
||||
.hud-panel-media.is-resizing {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Whole panel is draggable; player overrides back to default */
|
||||
.hud-panel-tv:not(.is-resizing) {
|
||||
.hud-panel-media:not(.is-resizing) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-dragging {
|
||||
.hud-panel-media.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel-tv .tv-panel-player {
|
||||
.hud-panel-media .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 {
|
||||
.hud-panel-media.is-dragging .tv-panel-iframe,
|
||||
.hud-panel-media.is-dragging .tv-panel-video {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
||||
.earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
/* TV panel keeps its fixed width on all screen sizes.
|
||||
/* Media panel keeps its fixed width on all screen sizes.
|
||||
Responsive stretching removed — width only changes if user manually resizes. */
|
||||
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
|
||||
@@ -171,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>
|
||||
@@ -217,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>
|
||||
@@ -288,45 +294,97 @@
|
||||
<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" data-drag-self="true">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<span class="hud-panel-title tv-panel-header-title">新闻直播</span>
|
||||
<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>
|
||||
</button>
|
||||
<button id="tv-meta-toggle" class="tv-panel-action tv-panel-action--icon tv-panel-meta-toggle" type="button" title="频道信息" aria-label="频道信息">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
<div id="media-panel" class="hud-panel hud-panel-media hud-panel-draggable" data-panel-key="media-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>
|
||||
<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 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="media-panel" aria-label="关闭媒体情报面板">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</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-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 class="tv-panel-content">
|
||||
<section id="tv-panel" 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="news-panel" 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="media-panel-tabs" role="tablist" aria-label="媒体情报切换">
|
||||
<button id="tv-tab-live" class="media-panel-tab media-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-panel">电视直播</button>
|
||||
<button id="tv-tab-news" class="media-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="news-panel">态势聚合</button>
|
||||
</div>
|
||||
<div class="tv-panel-edge" data-edge="r"></div>
|
||||
<div class="tv-panel-edge" data-edge="b"></div>
|
||||
@@ -394,7 +452,7 @@
|
||||
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
39
frontend/public/earth/js/controls.js
vendored
39
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;
|
||||
@@ -30,7 +35,7 @@ let cleanupFns = [];
|
||||
const HUD_PANEL_IDS = [
|
||||
"legend",
|
||||
"earth-stats",
|
||||
"tv-panel",
|
||||
"media-panel",
|
||||
"layer-toggles",
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
@@ -89,8 +94,9 @@ function setHudPanelVisibility(panelId, visible) {
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
syncSettingsToggle(panelId, visible);
|
||||
if (panelId === "tv-panel") {
|
||||
if (panelId === "media-panel") {
|
||||
updateTVToggleUI(visible);
|
||||
updateNewsToggleUI(visible);
|
||||
if (visible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
@@ -230,7 +236,7 @@ function setupDraggableHudPanels() {
|
||||
};
|
||||
|
||||
bindListener(handle, "pointerdown", (event) => {
|
||||
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-action, .tv-panel-player, .tv-panel-edge, .legend-bar-btn")) return;
|
||||
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
@@ -617,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) {
|
||||
@@ -794,13 +807,17 @@ function setupTerrainControls() {
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
});
|
||||
|
||||
const tvVisible = !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(tvVisible);
|
||||
if (tvVisible) {
|
||||
const mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(mediaVisible);
|
||||
if (mediaVisible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
}
|
||||
updateNewsToggleUI(mediaVisible);
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -192,8 +194,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
||||
"#legend *",
|
||||
"#earth-stats",
|
||||
"#earth-stats *",
|
||||
"#tv-panel",
|
||||
"#tv-panel *",
|
||||
"#media-panel",
|
||||
"#media-panel *",
|
||||
];
|
||||
|
||||
function bindListener(target, eventName, handler, options) {
|
||||
@@ -871,6 +873,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 +905,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 +1710,7 @@ function animate() {
|
||||
updateSatellitePositions(deltaTime);
|
||||
updateBreathingPhase(deltaTime);
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
|
||||
338
frontend/public/earth/js/news.js
Normal file
338
frontend/public/earth/js/news.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { getActiveTVTab, openTVPanelTab, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
// - this module renders into inner pane: #news-panel
|
||||
|
||||
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
|
||||
const FOCUS_UPDATE_INTERVAL_MS = 4000;
|
||||
const DATA_REFRESH_INTERVAL_MS = 180000;
|
||||
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
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,11 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
// Naming convention:
|
||||
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
|
||||
// - #tv-panel is the inner live tab pane
|
||||
// - #news-panel is the inner aggregation-news tab pane
|
||||
|
||||
const TV_STREAMS_API = "/api/v1/tv/streams";
|
||||
const TV_PROXY_API = "/api/v1/tv/proxy";
|
||||
@@ -21,8 +27,25 @@ let refreshPromise = null;
|
||||
let hlsPlayer = null;
|
||||
let hlsRecoveryAttempts = 0;
|
||||
let metaAutoCollapseTimer = null;
|
||||
let mediaPanel = null;
|
||||
let activeTab = "live";
|
||||
const failedSourceIds = new Set();
|
||||
let probeTimer = null;
|
||||
let reformCleanupTimer = null;
|
||||
const tabPanelState = {
|
||||
live: null,
|
||||
news: 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 = {
|
||||
@@ -34,9 +57,9 @@ const HLS_RETRY_CONFIG = {
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
panel: document.getElementById("tv-panel"),
|
||||
// Outer media shell node.
|
||||
panel: document.getElementById("media-panel"),
|
||||
toggleBtn: document.getElementById("toggle-tv"),
|
||||
resizeHandle: null,
|
||||
select: document.getElementById("tv-source-select"),
|
||||
title: document.getElementById("tv-source-title"),
|
||||
meta: document.getElementById("tv-source-meta"),
|
||||
@@ -48,45 +71,53 @@ function getElements() {
|
||||
empty: document.getElementById("tv-empty-state"),
|
||||
refreshBtn: document.getElementById("tv-refresh"),
|
||||
openBtn: document.getElementById("tv-open-external"),
|
||||
metaPanel: document.getElementById("tv-panel-meta"),
|
||||
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"),
|
||||
// Inner tab panes.
|
||||
livePane: document.getElementById("tv-panel"),
|
||||
newsPane: document.getElementById("news-panel"),
|
||||
};
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
const { metaWrap, metaToggle, panel } = getElements();
|
||||
if (!metaWrap) return;
|
||||
mediaPanel?.setCollapsed(collapsed);
|
||||
}
|
||||
|
||||
const isDragged = panel?.dataset.dragged === "true" && panel.style.top;
|
||||
|
||||
if (isDragged) {
|
||||
// Top-anchored panel: toggle instantly and compensate top so the player
|
||||
// (panel bottom) stays visually fixed.
|
||||
const bottomBefore = panel.getBoundingClientRect().bottom;
|
||||
|
||||
metaWrap.style.transition = "none";
|
||||
metaWrap.classList.toggle("is-collapsed", collapsed);
|
||||
metaToggle?.classList.toggle("is-collapsed", collapsed);
|
||||
|
||||
// Force synchronous reflow to get updated panel height
|
||||
void panel.offsetHeight;
|
||||
|
||||
const delta = panel.getBoundingClientRect().bottom - bottomBefore;
|
||||
if (delta !== 0) {
|
||||
panel.style.top = `${parseFloat(panel.style.top) - delta}px`;
|
||||
}
|
||||
|
||||
// Restore CSS transition after this paint
|
||||
requestAnimationFrame(() => {
|
||||
metaWrap.style.transition = "";
|
||||
});
|
||||
} else {
|
||||
metaWrap.classList.toggle("is-collapsed", collapsed);
|
||||
metaToggle?.classList.toggle("is-collapsed", 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);
|
||||
@@ -102,6 +133,82 @@ function clearPanelPositioningForResize(panel) {
|
||||
panel.dataset.dragged = "true";
|
||||
}
|
||||
|
||||
function readPanelLayoutState(panel) {
|
||||
return {
|
||||
width: panel.style.width || "",
|
||||
height: panel.style.height || "",
|
||||
resized: panel.dataset.resized === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function resetPanelLayoutState(panel) {
|
||||
panel.style.width = "";
|
||||
panel.style.height = "";
|
||||
delete panel.dataset.resized;
|
||||
}
|
||||
|
||||
function captureTabState(tab = activeTab) {
|
||||
const { panel } = getElements();
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
tabPanelState[tab] = {
|
||||
layout: readPanelLayoutState(panel),
|
||||
metaCollapsed:
|
||||
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreTabState(tab, panel, container, anchor = null) {
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
|
||||
const snapshot = tabPanelState[tab];
|
||||
if (!snapshot?.layout) {
|
||||
resetPanelLayoutState(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
const { layout } = snapshot;
|
||||
panel.style.width = layout.width;
|
||||
panel.style.height = layout.height;
|
||||
|
||||
if (layout.resized) {
|
||||
panel.dataset.resized = "true";
|
||||
} else {
|
||||
delete panel.dataset.resized;
|
||||
}
|
||||
|
||||
if (tab === "live" && snapshot.metaCollapsed !== null) {
|
||||
setMetaCollapsed(snapshot.metaCollapsed);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (anchor) {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
|
||||
const targetLeft = anchor.right - containerRect.left - panelRect.width;
|
||||
const targetTop = anchor.bottom - containerRect.top - panelRect.height;
|
||||
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
|
||||
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
|
||||
const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
|
||||
const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
|
||||
|
||||
panel.style.left = `${clampedLeft}px`;
|
||||
panel.style.top = `${clampedTop}px`;
|
||||
panel.style.right = "auto";
|
||||
panel.style.bottom = "auto";
|
||||
panel.style.transform = "none";
|
||||
panel.dataset.dragged = "true";
|
||||
} else {
|
||||
panel.style.right = "";
|
||||
panel.style.bottom = "";
|
||||
panel.style.left = "";
|
||||
panel.style.top = "";
|
||||
panel.style.transform = "";
|
||||
delete panel.dataset.dragged;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getHudScale() {
|
||||
const scale = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
@@ -109,6 +216,27 @@ function getHudScale() {
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function clampPanelToContainer(panel, container) {
|
||||
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
|
||||
if (panel.dataset.dragged !== "true") return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
|
||||
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
|
||||
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
|
||||
const currentLeft = panelRect.left - containerRect.left;
|
||||
const currentTop = panelRect.top - containerRect.top;
|
||||
const clampedLeft = Math.min(maxLeft, Math.max(0, currentLeft));
|
||||
const clampedTop = Math.min(maxTop, Math.max(0, currentTop));
|
||||
|
||||
panel.style.left = `${clampedLeft}px`;
|
||||
panel.style.top = `${clampedTop}px`;
|
||||
panel.style.right = "auto";
|
||||
panel.style.bottom = "auto";
|
||||
panel.style.transform = "none";
|
||||
}
|
||||
|
||||
function setupResizeHandle() {
|
||||
const { panel } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
@@ -116,12 +244,14 @@ function setupResizeHandle() {
|
||||
|
||||
let resizing = false;
|
||||
let activeEdge = "";
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
let startHeight = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
const resizeStart = {
|
||||
pointerX: 0,
|
||||
pointerY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
resizing = false;
|
||||
@@ -134,23 +264,23 @@ function setupResizeHandle() {
|
||||
if (!resizing) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hudScale = getHudScale();
|
||||
const minWidth = Math.max(320, Math.round(360 * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(340 * hudScale));
|
||||
const dx = event.clientX - startX;
|
||||
const dy = 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;
|
||||
|
||||
if (activeEdge.includes("r")) {
|
||||
const maxW = containerRect.width - startLeft - 12;
|
||||
panel.style.width = `${Math.min(maxW, Math.max(minWidth, startWidth + dx))}px`;
|
||||
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, startWidth - dx);
|
||||
const newW = Math.max(minWidth, resizeStart.width - dx);
|
||||
panel.style.width = `${newW}px`;
|
||||
panel.style.left = `${Math.max(0, startLeft + startWidth - newW)}px`;
|
||||
panel.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
|
||||
}
|
||||
if (activeEdge.includes("b")) {
|
||||
const maxH = containerRect.height - startTop - 12;
|
||||
panel.style.minHeight = `${Math.min(maxH, Math.max(minHeight, startHeight + dy))}px`;
|
||||
const maxH = containerRect.height - resizeStart.top - PANEL_RESIZE_MARGIN_PX;
|
||||
panel.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,17 +292,21 @@ function setupResizeHandle() {
|
||||
|
||||
activeEdge = edgeEl.dataset.edge ?? "";
|
||||
resizing = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
resizeStart.pointerX = event.clientX;
|
||||
resizeStart.pointerY = event.clientY;
|
||||
|
||||
clearPanelPositioningForResize(panel);
|
||||
panel.dataset.resized = "true";
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
startWidth = rect.width;
|
||||
startHeight = rect.height;
|
||||
startLeft = rect.left - cRect.left;
|
||||
startTop = rect.top - cRect.top;
|
||||
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";
|
||||
@@ -189,15 +323,16 @@ function setupResizeHandle() {
|
||||
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 ? "关闭新闻直播" : "打开新闻直播";
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(visible) {
|
||||
const input = document.querySelector('[data-settings-panel="tv-panel"]');
|
||||
const input = document.querySelector('[data-settings-panel="media-panel"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
}
|
||||
@@ -206,9 +341,201 @@ function syncSettingsToggle(visible) {
|
||||
function setPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
mediaPanel?.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.dataset.resized === "true") {
|
||||
applyChange();
|
||||
requestAnimationFrame(() => {
|
||||
clampPanelToContainer(panel, container);
|
||||
});
|
||||
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("media-panel-tab--active", isActive);
|
||||
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
}
|
||||
|
||||
function setActiveTab(tab) {
|
||||
const nextTab = tab === "news" ? "news" : "live";
|
||||
if (activeTab === nextTab) return;
|
||||
|
||||
captureTabState(activeTab);
|
||||
|
||||
const { panel } = getElements();
|
||||
const targetSnapshot = tabPanelState[nextTab];
|
||||
const currentIsCustom =
|
||||
panel instanceof HTMLElement && panel.dataset.resized === "true";
|
||||
const targetIsCustom = Boolean(targetSnapshot?.layout?.resized);
|
||||
const container = document.getElementById("container");
|
||||
const currentAnchor =
|
||||
panel instanceof HTMLElement && container instanceof HTMLElement
|
||||
? (() => {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
return {
|
||||
right: panelRect.right,
|
||||
bottom: panelRect.bottom,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
const applyTabSwitch = (restoreLayoutState = false) => {
|
||||
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");
|
||||
|
||||
if (
|
||||
restoreLayoutState &&
|
||||
panel instanceof HTMLElement &&
|
||||
container instanceof HTMLElement
|
||||
) {
|
||||
restoreTabState(nextTab, panel, container, currentAnchor);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
captureTabState(nextTab);
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
|
||||
detail: { tab: nextTab },
|
||||
}));
|
||||
};
|
||||
|
||||
if (currentIsCustom || targetIsCustom) {
|
||||
applyTabSwitch(true);
|
||||
return;
|
||||
}
|
||||
|
||||
animateTabReform(() => applyTabSwitch(false));
|
||||
}
|
||||
|
||||
export function openTVPanelTab(tab = "live") {
|
||||
setPanelVisible(true);
|
||||
if (activeTab === tab) return;
|
||||
setActiveTab(tab);
|
||||
}
|
||||
|
||||
export function isTVPanelVisible() {
|
||||
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
|
||||
}
|
||||
|
||||
export function getActiveTVTab() {
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
function getEmbeddedUrl(source) {
|
||||
@@ -392,7 +719,7 @@ function attachVideoSource(video, source) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmbeddedFallback(source)) {
|
||||
if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
@@ -425,6 +752,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);
|
||||
}
|
||||
@@ -487,10 +867,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);
|
||||
});
|
||||
|
||||
@@ -622,22 +1003,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) {
|
||||
mediaPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".hud-panel__header",
|
||||
body: "#tv-meta-wrap",
|
||||
collapseBtn: metaToggle,
|
||||
bodyCollapsedClass: "is-collapsed",
|
||||
preferredDirection: "up",
|
||||
expandLabel: "展开新闻直播信息",
|
||||
collapseLabel: "折叠新闻直播信息",
|
||||
});
|
||||
}
|
||||
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(mediaPanel?.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 = mediaPanel?.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) => {
|
||||
@@ -647,10 +1060,9 @@ export function initTVPanel() {
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
|
||||
const { metaToggle } = getElements();
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !metaToggle.classList.contains("is-collapsed");
|
||||
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
|
||||
@@ -658,22 +1070,38 @@ export function initTVPanel() {
|
||||
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();
|
||||
updateTabButtonState(liveTabBtn, true);
|
||||
updateTabButtonState(newsTabBtn, false);
|
||||
captureTabState("live");
|
||||
|
||||
window.addEventListener("resize", syncNewsDefaultMaxHeight);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -390,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;
|
||||
@@ -534,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;
|
||||
@@ -543,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;
|
||||
@@ -1288,6 +1316,10 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playground-shell__sidebar {
|
||||
display: none;
|
||||
}
|
||||
@@ -1493,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 {
|
||||
@@ -1911,6 +1959,14 @@ body {
|
||||
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,
|
||||
.bgp-alerts-page__table-card {
|
||||
flex: 1 1 auto;
|
||||
@@ -1935,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;
|
||||
@@ -2505,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;
|
||||
|
||||
@@ -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
|
||||
@@ -213,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 () => {
|
||||
@@ -940,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"
|
||||
@@ -963,7 +986,7 @@ function DataSources() {
|
||||
>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -1001,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>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -1034,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>
|
||||
@@ -1120,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()}>
|
||||
添加数据源
|
||||
|
||||
@@ -239,6 +239,7 @@ 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)
|
||||
@@ -681,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"
|
||||
@@ -849,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>
|
||||
@@ -890,8 +910,9 @@ function Playground() {
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,11 +9,13 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
@@ -108,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 {
|
||||
@@ -206,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) {
|
||||
@@ -402,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>
|
||||
),
|
||||
},
|
||||
@@ -508,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>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -612,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>
|
||||
<TableScrollRegion className="data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1500, y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</TableScrollRegion>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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'
|
||||
@@ -19,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)
|
||||
@@ -37,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()
|
||||
@@ -127,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>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -144,14 +136,16 @@ function Users() {
|
||||
<h2 style={{ margin: 0 }}>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<TableScrollRegion ref={tableRegionRef} className="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"
|
||||
/>
|
||||
</TableScrollRegion>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.27.5"
|
||||
version = "0.28.2"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user