Compare commits

...

5 Commits

Author SHA1 Message Date
linkong
5b623a6385 release: bump version to 0.34.0 2026-04-22 12:49:37 +08:00
rayd1o
0082cf3fbd release: bump version to 0.33.0 2026-04-22 05:28:54 +08:00
rayd1o
3ae4acdff8 release: bump version to 0.32.0 2026-04-22 04:41:39 +08:00
rayd1o
437efc848c release: bump version to 0.31.3 2026-04-22 03:52:09 +08:00
rayd1o
003a46ac30 release: bump version to 0.31.2 2026-04-21 23:50:35 +08:00
42 changed files with 4726 additions and 1103 deletions

View File

@@ -20,3 +20,7 @@
- [x] 在 activity layer 之后继续补 `route leak``path instability / flap` detector - [x] 在 activity layer 之后继续补 `route leak``path instability / flap` detector
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation降低后续维护复杂度 - [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation降低后续维护复杂度
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性 - [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector

View File

@@ -1 +1 @@
0.31.1 0.34.0

View File

@@ -420,6 +420,7 @@ async def list_datasources(
collector_list.append( collector_list.append(
{ {
"id": datasource.id, "id": datasource.id,
"source": datasource.source,
"name": datasource.name, "name": datasource.name,
"module": datasource.module, "module": datasource.module,
"priority": datasource.priority, "priority": datasource.priority,

View File

@@ -30,6 +30,7 @@ COLLECTOR_URL_KEYS = {
"iptoasn_prefix_geo": "iptoasn.combined_url", "iptoasn_prefix_geo": "iptoasn.combined_url",
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url", "opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
"nro_delegated_prefix_geo": "nro.delegated_stats_url", "nro_delegated_prefix_geo": "nro.delegated_stats_url",
"news_live_streams": "news_live_streams.channels_url",
} }

View File

@@ -86,3 +86,11 @@ opengeofeed:
nro: nro:
# NRO delegated stats 下载地址 # NRO delegated stats 下载地址
delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats" delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats"
news_live_streams:
# IPTV-org 频道元数据 JSON
channels_url: "https://iptv-org.github.io/api/channels.json"
# IPTV-org 频道播放流 JSON
streams_url: "https://iptv-org.github.io/api/streams.json"
# IPTV-org 台标 JSON
logos_url: "https://iptv-org.github.io/api/logos.json"

View File

@@ -1,10 +1,16 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from urllib.parse import urlparse
import httpx import httpx
from sqlalchemy import select
from app.core.data_sources import get_data_sources_config
from app.models.datasource_config import DataSourceConfig
from app.services.collectors.base import BaseCollector from app.services.collectors.base import BaseCollector
@@ -18,52 +24,537 @@ class NewsLiveStreamsCollector(BaseCollector):
data_type = "news_live_stream" data_type = "news_live_stream"
fail_on_empty = False fail_on_empty = False
DEFAULT_TIMEOUT = 45.0
DEFAULT_HEADERS = {
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
"Accept": "application/json",
}
RESPONSE_CANDIDATE_KEYS = ("sources", "streams", "channels", "items", "results", "data")
DEFAULT_ADAPTER = "iptv_org"
DEFAULT_IPTV_ORG_STREAMS_URL = "https://iptv-org.github.io/api/streams.json"
DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json"
DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather")
DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment")
DEFAULT_IPTV_ORG_MAX_SOURCES = 120
async def fetch(self) -> list[dict[str, Any]]: async def fetch(self) -> list[dict[str, Any]]:
request_url = (self._resolved_url or "").strip() request_url = (self._resolved_url or "").strip()
if not request_url: if not request_url:
return [] return []
async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client: datasource_config = await self._load_datasource_config()
response = await client.get( effective_config = self._get_effective_config(datasource_config)
adapter = str(effective_config.get("adapter") or "").strip().lower()
if adapter == "iptv_org":
return await self._fetch_iptv_org(request_url, effective_config)
request_headers = self._build_request_headers(datasource_config)
request_config = self._get_request_config(datasource_config)
request_params = self._build_request_params(datasource_config)
request_json = self._build_request_json_body(datasource_config)
request_data = self._build_request_form_body(datasource_config)
timeout = self._get_timeout(datasource_config)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.request(
request_config["method"],
request_url, request_url,
headers={ headers=request_headers,
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)", params=request_params or None,
"Accept": "application/json", json=request_json,
}, data=request_data,
) )
response.raise_for_status() response.raise_for_status()
return self.parse_response(response.json()) return self.parse_response(
response.json(),
response_path=request_config["response_path"],
)
def parse_response(self, response: Any) -> list[dict[str, Any]]: async def _load_datasource_config(self) -> DataSourceConfig | None:
if isinstance(response, dict): if not self._db_session:
candidates = response.get("sources") or response.get("streams") or response.get("data") or [] return None
elif isinstance(response, list):
candidates = response result = await self._db_session.execute(
select(DataSourceConfig)
.where(DataSourceConfig.name == self.name)
.where(DataSourceConfig.is_active.is_(True))
.limit(1)
)
return result.scalar_one_or_none()
def _get_effective_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
payload = dict(datasource_config.config or {}) if datasource_config else {}
if payload:
return payload
yaml_config = get_data_sources_config()
return {
"adapter": self.DEFAULT_ADAPTER,
"streams_url": yaml_config.get_yaml_value("news_live_streams.streams_url")
or self.DEFAULT_IPTV_ORG_STREAMS_URL,
"logos_url": yaml_config.get_yaml_value("news_live_streams.logos_url")
or self.DEFAULT_IPTV_ORG_LOGOS_URL,
"news_categories": list(self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES),
"exclude_categories": list(self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES),
"max_sources": self.DEFAULT_IPTV_ORG_MAX_SOURCES,
}
def _get_request_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
payload = self._get_effective_config(datasource_config)
raw_method = payload.get("method") or payload.get("request_method") or "GET"
method = str(raw_method).strip().upper() or "GET"
if method not in {"GET", "POST"}:
method = "GET"
response_path = payload.get("response_path") or payload.get("payload_path") or payload.get("items_path")
if isinstance(response_path, str):
response_path = response_path.strip()
else: else:
candidates = [] response_path = None
return {
"method": method,
"response_path": response_path or None,
}
def _get_timeout(self, datasource_config: DataSourceConfig | None) -> float:
payload = self._get_effective_config(datasource_config)
try:
return float(payload.get("timeout", self.DEFAULT_TIMEOUT))
except (TypeError, ValueError):
return self.DEFAULT_TIMEOUT
def _build_request_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
headers = dict(self.DEFAULT_HEADERS)
if datasource_config:
headers.update(self._normalize_headers(datasource_config.headers))
headers.update(self._build_auth_headers(datasource_config))
return headers
def _build_request_params(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
params: dict[str, Any] = {}
if not datasource_config:
return params
payload = datasource_config.config or {}
candidate = payload.get("params") or payload.get("query_params")
if isinstance(candidate, dict):
params.update(candidate)
if datasource_config.auth_type == "api_key":
auth_config = datasource_config.auth_config or {}
if str(auth_config.get("in") or auth_config.get("location") or "header").lower() == "query":
api_key = auth_config.get("api_key")
key_name = auth_config.get("key_name") or auth_config.get("param_name") or "api_key"
if api_key and key_name:
params[str(key_name)] = api_key
return params
def _build_request_json_body(self, datasource_config: DataSourceConfig | None) -> Any:
if not datasource_config:
return None
payload = datasource_config.config or {}
body = payload.get("json_body")
if body is None and str(payload.get("body_type") or "").lower() in {"json", ""}:
candidate = payload.get("body")
if isinstance(candidate, (dict, list)):
body = candidate
return body
def _build_request_form_body(self, datasource_config: DataSourceConfig | None) -> Any:
if not datasource_config:
return None
payload = datasource_config.config or {}
form_body = payload.get("form_body")
if form_body is not None:
return form_body
if str(payload.get("body_type") or "").lower() == "form":
candidate = payload.get("body")
if isinstance(candidate, dict):
return candidate
return None
def _normalize_headers(self, headers: Any) -> dict[str, str]:
if not isinstance(headers, dict):
return {}
normalized: dict[str, str] = {}
for key, value in headers.items():
header_name = str(key).strip()
if not header_name or value is None:
continue
normalized[header_name] = str(value)
return normalized
def _build_auth_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
if not datasource_config:
return {}
auth_type = str(datasource_config.auth_type or "none").lower()
auth_config = datasource_config.auth_config or {}
if auth_type == "bearer" and auth_config.get("token"):
return {"Authorization": f"Bearer {auth_config['token']}"}
if auth_type == "api_key" and auth_config.get("api_key"):
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
if location == "query":
return {}
key_name = auth_config.get("key_name") or "X-API-Key"
return {str(key_name): str(auth_config["api_key"])}
if auth_type == "basic":
username = str(auth_config.get("username") or "")
password = str(auth_config.get("password") or "")
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {encoded}"}
return {}
def _extract_candidates(self, response: Any, response_path: str | None) -> list[Any]:
if response_path:
extracted = self._extract_from_path(response, response_path)
if isinstance(extracted, list):
return extracted
if isinstance(extracted, dict):
for key in self.RESPONSE_CANDIDATE_KEYS:
nested = extracted.get(key)
if isinstance(nested, list):
return nested
return [extracted]
if isinstance(response, dict):
for key in self.RESPONSE_CANDIDATE_KEYS:
nested = response.get(key)
if isinstance(nested, list):
return nested
return []
if isinstance(response, list):
return response
return []
def _extract_from_path(self, payload: Any, path: str) -> Any:
current = payload
for segment in (part.strip() for part in path.split(".") if part.strip()):
if isinstance(current, dict):
current = current.get(segment)
continue
if isinstance(current, list):
try:
current = current[int(segment)]
except (TypeError, ValueError, IndexError):
return None
continue
return None
return current
def _infer_source_type(self, item: dict[str, Any]) -> str:
explicit = str(item.get("source_type") or item.get("type") or "").strip().lower()
if explicit in {"iframe", "hls", "video", "external", "youtube"}:
return explicit
youtube_video_id = self._clean_text(
item.get("youtube_video_id")
or item.get("video_id")
or item.get("youtubeVideoId")
)
youtube_channel = self._clean_text(item.get("youtube_channel") or item.get("channel_handle"))
embed_url = self._clean_url(item.get("embed_url") or item.get("embed") or item.get("page_url"))
stream_url = self._clean_url(item.get("stream_url") or item.get("stream") or item.get("playback_url") or item.get("hls_url"))
homepage_url = self._clean_url(item.get("homepage_url") or item.get("source_url") or item.get("website"))
if youtube_video_id or youtube_channel:
return "youtube"
if stream_url.endswith(".m3u8"):
return "hls"
if stream_url:
return "video"
if embed_url:
parsed = urlparse(embed_url)
if "youtube.com" in (parsed.netloc or "") or "youtu.be" in (parsed.netloc or ""):
return "youtube"
return "iframe"
if homepage_url:
return "external"
return "iframe"
def _parse_enabled(self, item: dict[str, Any]) -> bool:
if "is_enabled" in item:
return self._to_bool(item.get("is_enabled"), default=True)
if "enabled" in item:
return self._to_bool(item.get("enabled"), default=True)
if "active" in item:
return self._to_bool(item.get("active"), default=True)
if "status" in item:
status = str(item.get("status") or "").strip().lower()
if status in {"disabled", "inactive", "offline"}:
return False
if status in {"enabled", "active", "online", "live"}:
return True
return True
def _to_bool(self, value: Any, *, default: bool) -> bool:
if isinstance(value, bool):
return value
if value in (None, ""):
return default
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on", "enabled", "active", "online", "live"}:
return True
if lowered in {"0", "false", "no", "off", "disabled", "inactive", "offline"}:
return False
return bool(value)
def _clean_text(self, value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _clean_url(self, value: Any) -> str:
text = self._clean_text(value)
if not text:
return ""
parsed = urlparse(text)
if parsed.scheme and parsed.scheme not in {"http", "https"}:
return ""
if parsed.scheme and not parsed.netloc:
return ""
return text
async def _fetch_iptv_org(self, channels_url: str, collector_config: dict[str, Any]) -> list[dict[str, Any]]:
streams_url = self._clean_url(collector_config.get("streams_url")) or self.DEFAULT_IPTV_ORG_STREAMS_URL
logos_url = self._clean_url(collector_config.get("logos_url")) or self.DEFAULT_IPTV_ORG_LOGOS_URL
news_categories = {
self._clean_text(value).lower()
for value in (collector_config.get("news_categories") or self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES)
if self._clean_text(value)
}
exclude_categories = {
self._clean_text(value).lower()
for value in (collector_config.get("exclude_categories") or self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES)
if self._clean_text(value)
}
try:
max_sources = int(collector_config.get("max_sources", self.DEFAULT_IPTV_ORG_MAX_SOURCES))
except (TypeError, ValueError):
max_sources = self.DEFAULT_IPTV_ORG_MAX_SOURCES
timeout = self.DEFAULT_TIMEOUT
try:
timeout = float(collector_config.get("timeout", self.DEFAULT_TIMEOUT))
except (TypeError, ValueError):
timeout = self.DEFAULT_TIMEOUT
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
channels_payload, streams_payload, logos_payload = await self._gather_iptv_org_payloads(
client,
channels_url,
streams_url,
logos_url,
)
channels = channels_payload if isinstance(channels_payload, list) else []
streams = streams_payload if isinstance(streams_payload, list) else []
logos = logos_payload if isinstance(logos_payload, list) else []
logo_by_channel = {
self._clean_text(item.get("channel")): self._clean_url(item.get("url"))
for item in logos
if isinstance(item, dict) and self._clean_text(item.get("channel")) and self._clean_url(item.get("url"))
}
streams_by_channel: dict[str, list[dict[str, Any]]] = {}
for stream in streams:
if not isinstance(stream, dict):
continue
channel_id = self._clean_text(stream.get("channel"))
if not channel_id:
continue
streams_by_channel.setdefault(channel_id, []).append(stream)
normalized: list[dict[str, Any]] = []
for channel in channels:
if not isinstance(channel, dict):
continue
categories = [
self._clean_text(value).lower()
for value in (channel.get("categories") or [])
if self._clean_text(value)
]
if news_categories and not any(category in news_categories for category in categories):
continue
if exclude_categories and any(category in exclude_categories for category in categories):
continue
if channel.get("is_nsfw") is True:
continue
if channel.get("closed"):
continue
channel_id = self._clean_text(channel.get("id"))
if not channel_id:
continue
stream = self._pick_iptv_org_stream(streams_by_channel.get(channel_id) or [])
if not stream:
continue
stream_url = self._clean_url(stream.get("url"))
if not stream_url:
continue
name = self._clean_text(channel.get("name")) or channel_id
notes_parts = [
f"Imported from IPTV-org catalog ({channel_id})",
f"Categories: {', '.join(categories)}" if categories else "",
f"Quality: {self._clean_text(stream.get('quality'))}" if self._clean_text(stream.get("quality")) else "",
]
metadata = {
"provider": self._clean_text(channel.get("network")) or "IPTV-org",
"region": self._clean_text(channel.get("country")) or "Global",
"language": "und",
"source_type": "hls" if stream_url.endswith(".m3u8") else "video",
"embed_url": "",
"stream_url": stream_url,
"homepage_url": self._clean_url(channel.get("website")),
"poster_url": logo_by_channel.get(channel_id, ""),
"youtube_video_id": "",
"youtube_channel": "",
"sort_order": 400 + len(normalized),
"notes": "; ".join(part for part in notes_parts if part),
"is_enabled": True,
"collector_adapter": "iptv_org",
"channel_id": channel_id,
"categories": categories,
"quality": self._clean_text(stream.get("quality")),
"stream_label": self._clean_text(stream.get("label") or stream.get("title")),
"stream_referrer": self._clean_text(stream.get("referrer")),
"stream_user_agent": self._clean_text(stream.get("user_agent")),
}
normalized.append(
{
"source_id": channel_id,
"name": name,
"description": metadata["notes"],
"metadata": metadata,
"reference_date": datetime.now(UTC).isoformat(),
}
)
if len(normalized) >= max_sources:
break
return normalized
async def _gather_iptv_org_payloads(
self,
client: httpx.AsyncClient,
channels_url: str,
streams_url: str,
logos_url: str,
) -> tuple[Any, Any, Any]:
headers = dict(self.DEFAULT_HEADERS)
channels_payload, streams_payload, logos_payload = await asyncio.gather(
client.get(channels_url, headers=headers),
client.get(streams_url, headers=headers),
client.get(logos_url, headers=headers),
)
channels_payload.raise_for_status()
streams_payload.raise_for_status()
logos_payload.raise_for_status()
return channels_payload.json(), streams_payload.json(), logos_payload.json()
def _pick_iptv_org_stream(self, streams: list[dict[str, Any]]) -> dict[str, Any] | None:
if not streams:
return None
def score(stream: dict[str, Any]) -> tuple[int, int]:
url = self._clean_url(stream.get("url"))
quality = self._clean_text(stream.get("quality")).lower()
quality_score = 0
if quality.endswith("p"):
try:
quality_score = int(quality[:-1])
except ValueError:
quality_score = 0
stream_score = 1000 if url.endswith(".m3u8") else 0
return stream_score, quality_score
sorted_streams = sorted(streams, key=score, reverse=True)
return sorted_streams[0]
def parse_response(self, response: Any, *, response_path: str | None = None) -> list[dict[str, Any]]:
candidates = self._extract_candidates(response, response_path)
normalized: list[dict[str, Any]] = [] normalized: list[dict[str, Any]] = []
for index, item in enumerate(candidates): for index, item in enumerate(candidates):
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}" stream_id = (
name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip() item.get("id")
or item.get("source_id")
or item.get("slug")
or item.get("channel_id")
or item.get("code")
or f"news-live-{index + 1}"
)
name = self._clean_text(
item.get("name")
or item.get("title")
or item.get("channel")
or item.get("display_name")
or f"News Live {index + 1}"
)
if not name: if not name:
continue continue
source_type = self._infer_source_type(item)
stream_url = self._clean_url(
item.get("stream_url")
or item.get("stream")
or item.get("playback_url")
or item.get("hls_url")
or item.get("m3u8_url")
)
embed_url = self._clean_url(
item.get("embed_url")
or item.get("embed")
or item.get("page_url")
or (item.get("url") if source_type == "iframe" else "")
)
homepage_url = self._clean_url(
item.get("homepage_url")
or item.get("source_url")
or item.get("website")
or item.get("url")
)
metadata = { metadata = {
"provider": item.get("provider") or item.get("publisher") or "Collector", "provider": self._clean_text(item.get("provider") or item.get("publisher") or item.get("network")) or "Collector",
"region": item.get("region") or item.get("country") or "Global", "region": self._clean_text(item.get("region") or item.get("country") or item.get("market")) or "Global",
"language": item.get("language") or "und", "language": self._clean_text(item.get("language") or item.get("lang") or item.get("locale")) or "und",
"source_type": item.get("source_type") or "iframe", "source_type": source_type,
"embed_url": item.get("embed_url") or item.get("url") or "", "embed_url": embed_url,
"stream_url": item.get("stream_url") or "", "stream_url": stream_url,
"homepage_url": item.get("homepage_url") or item.get("source_url") or "", "homepage_url": homepage_url,
"poster_url": item.get("poster_url") or "", "poster_url": self._clean_url(item.get("poster_url") or item.get("thumbnail_url") or item.get("logo_url")),
"youtube_video_id": self._clean_text(
item.get("youtube_video_id")
or item.get("video_id")
or item.get("youtubeVideoId")
),
"youtube_channel": self._clean_text(
item.get("youtube_channel")
or item.get("channel_handle")
or item.get("youtubeChannel")
),
"sort_order": item.get("sort_order", 200 + index), "sort_order": item.get("sort_order", 200 + index),
"notes": item.get("notes") or item.get("description") or "", "notes": self._clean_text(item.get("notes") or item.get("description") or item.get("summary")),
"is_enabled": item.get("is_enabled", True), "is_enabled": self._parse_enabled(item),
} }
normalized.append( normalized.append(
@@ -72,7 +563,7 @@ class NewsLiveStreamsCollector(BaseCollector):
"name": name, "name": name,
"description": metadata["notes"], "description": metadata["notes"],
"metadata": metadata, "metadata": metadata,
"reference_date": item.get("reference_date", datetime.now(UTC).isoformat()), "reference_date": item.get("reference_date") or datetime.now(UTC).isoformat(),
} }
) )

View File

@@ -17,7 +17,7 @@ TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream" TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
DEFAULT_TV_SETTINGS = { DEFAULT_TV_SETTINGS = {
"default_source_id": DEFAULT_TV_SOURCE_ID, "default_source_id": DEFAULT_TV_SOURCE_ID,
"auto_fallback": True, "auto_fallback": True,
"sources": [ "sources": [
{ {
@@ -362,7 +362,7 @@ def _build_collected_tv_source(record: CollectedData, index: int) -> dict[str, A
"sort_order": metadata.get("sort_order", 200 + index), "sort_order": metadata.get("sort_order", 200 + index),
"collector_source": record.source, "collector_source": record.source,
"notes": record.description or metadata.get("notes") or "", "notes": record.description or metadata.get("notes") or "",
"updated_at": to_iso8601_utc(record.updated_at or record.reference_date or datetime.now(UTC)), "updated_at": to_iso8601_utc(record.collected_at or record.reference_date or datetime.now(UTC)),
}, },
index=index, index=index,
) )

View File

@@ -8,7 +8,98 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.31.0] — 2026-04-21 ## [0.34.0] — 2026-04-22
### ✨ Highlights
- Earth 搜索面板正式接入支持搜索海缆、登陆点、卫星、BGP 事件与观测站,并可直接聚焦到对应对象
- `planet.sh --allow-lan` 打通 Bun + Vite 的局域网开放链路,启动成功后自动打印推荐访问地址与后端健康检查地址
### 🔧 Improvements
- 前端开发启动链统一改成 Bun 直接执行 Vite 入口,不再依赖 shell 中额外暴露的 Node 路径
- Earth 搜索结果接入登陆点详情卡片与对象聚焦,搜索后可直接进入对应详情流
- `planet.sh` 补充局域网 IPv4 自动识别与推荐地址输出,减少 WSL 局域网调试成本
### 🐛 Fixes
- 修复 `./planet.sh restart --allow-lan` 全量重启时未把 `--allow-lan` 继续传给 `start()`,导致前端退回本机监听的问题
- 修复 WSL + Bun 环境下前端偶发因 Vite 启动链不稳定而无法正确监听 `0.0.0.0:3000` 的问题
---
## [0.33.0] — 2026-04-22
### ✨ Highlights
- `news_live_streams` 采集器默认接入 `iptv-org` 频道目录,并将采集结果稳定并入 Earth TV 直播源列表
- 数据源页支持直接编辑内置数据源 override并为内置源提供一键恢复默认配置入口
### 🔧 Improvements
- `News Live Streams` 现在作为可直接触发的内置默认数据源提供,无需先手工补 override 才能采集
- TV 播放源菜单会直接区分 `[内置]``[采集]` 来源,频道来源信息也会同步展示
- 新增 [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md),正式规划 Earth 态势新闻源配置化与后续采集器化路线
### 🐛 Fixes
- 修复 `news_live_streams` 采集完成后 `/api/v1/tv/streams` 因读取不存在的 `updated_at` 字段而导致默认频道全部消失的问题
- 修复内置数据源操作列按钮显示不全,以及编辑抽屉中多个 `Collapse` 紧贴的问题
---
## [0.32.0] — 2026-04-22
### ✨ Highlights
- Earth 设置新增“地球默认大小”持久化项,重置视角、缩放百分比重置和 BGP 巡航视图现在统一复用这一份默认 zoom
- 卫星焦点层次继续收口:巡航进入 presentation 前不再过早 dim非焦点卫星改成“降亮度/尾迹/背板”而不是去饱和度
### 🔧 Improvements
- Earth 设置面板区块和左右留白进一步收紧,整体更贴近 HUD 面板的密度
- toolbar 展开边界缓存改为按需刷新,减少 document 级 mousemove 期间的重复布局读取
- Scrollbar 和 ScrollbarOverlay 收窄 observer 范围,减少大表格和动态菜单下的额外刷新成本
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md),补充默认视图大小已进入 Earth 设置持久化真源
### 🐛 Fixes
- 修复开启巡航后,尚未进入连线/presentation 时卫星已经整体变暗的问题
- 修复默认大小重置链路分散在多个入口、实际 reset/cruise/缩放提示不一致的问题
- 修复开启地形后卫星反馈层与地球背面可见性之间的一组表现问题,保留正面反馈同时恢复背面轨道遮挡
---
## [0.31.3] — 2026-04-22
### ✨ Highlights
- Earth 图层注册表和启动任务框架继续收口,启动顺序、启动模式、启动提示和任务注册现在都能从统一入口扩展
- 修复 Earth 普通旋转模式与巡航模式切换时的一组交互回归,同时让卫星/地形/昼夜模式的表现更稳定
### 🔧 Improvements
- 新增 [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js) 启动任务注册表,支持 `registerLayerStartupTask(id, taskFactory)`,并拆成海缆 / 卫星 / BGP 独立注册函数
- Earth 图层控制改成注册表驱动,统一承载 `startupPriority``startupMode``startupLabel``startupMessage` 与图层持久化元信息
- Earth 设置支持持久化图层开关、旋转模式、HUD 面板显示状态、地形透明度与日夜模式,并提供一键重置
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 记录图层注册表、启动任务、设置持久化与巡航适配边界
### 🐛 Fixes
- 修复普通旋转模式下点击海缆 / 卫星 / BGP 后卡片和选中表现会被异常清空的问题
- 修复巡航模式切回旋转再切回巡航后无法继续自动巡航的问题
- 修复开启地形后卫星选中反馈层被高海拔区域吞掉的问题,并恢复轨道只在地球前半侧可见
- 修复关闭日夜模式后地球照明仍沿真实昼夜切换、亮部过曝和偏色的问题,改成更中性的 inspection lighting
- 修复 toolbar 收起态仍挡住地球交互,以及首帧短暂展开闪现的问题
---
## [0.31.2] — 2026-04-21
### ✨ Highlights
- Earth 巡航模式重构为“通用巡航队列 + 通用连线动画 + BGP 业务适配”三层结构,后续扩到海缆、卫星或新闻巡航时不必再复制一套 `main.js` 状态机
- 修复巡航重构后的交互回归:空白点击重新稳定切到下一项,连线按“起点 → 引导线 → 终点”顺序入场
### 🔧 Improvements
- 新增 [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) 统一管理队列推进、停留时长、打断与恢复
- 新增 [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) 统一管理 SVG 连线、折线路径与描边动画
- 新增 [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 收口 BGP 巡航目标排序、卡片落点、轮询去重与连线适配
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 说明新的巡航分层与复用边界
### 🐛 Fixes
- 修复巡航模式下点击空白处无法稳定跳转到下一项、切回旋转再切回巡航后直接卡住的问题
- 修复巡航连线被实时重定位覆盖导致“直接出现”而非绘制动画的问题
- 修复连线动画节点入场节奏不对的问题,改为先出现起点,再绘制连线,最后出现终点
---
## [0.31.1] — 2026-04-21 ## [0.31.1] — 2026-04-21
@@ -27,6 +118,8 @@ This project follows the repository versioning rule:
--- ---
## [0.31.0] — 2026-04-21
### ✨ Features ### ✨ Features
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件逐帧追踪连接线位置支持外部交互立即中断序列cancel notifier 模式) - Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件逐帧追踪连接线位置支持外部交互立即中断序列cancel notifier 模式)
- 巡航目标事件点高亮显示hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆 - 巡航目标事件点高亮显示hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
@@ -40,8 +133,6 @@ This project follows the repository versioning rule:
--- ---
## [0.29.1] — 2026-04-20
## [0.30.0] — 2026-04-21 ## [0.30.0] — 2026-04-21
### ✨ Features ### ✨ Features

View File

@@ -20,6 +20,7 @@
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md) - [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md) - [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md) - [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) - [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md) - [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)

View File

@@ -0,0 +1,156 @@
# Earth News Source Configuration And Collector Plan
## Why
当前 Earth 的“态势新闻”由 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 直接在请求时抓取 RSS / Google News feed再按当前地球视角中心区域聚合返回。
这条链已经可用,但存在两个明显限制:
- 新闻源写死在代码里,不能像 TV 直播源一样从后台维护
- 新闻并未进入统一采集体系,没有采集状态、失败监控、历史数据和后续 AI 复用能力
因此这块更合理的路线不是一步到位重写,而是分阶段推进:
1. 先做“新闻源配置化”
2. 再做“新闻采集器化”
## Current State
当前实现分布在:
- 新闻接口
- [news.py](/home/ray/dev/linkong/planet/backend/app/api/v1/news.py)
- 实时聚合逻辑
- [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py)
- 前端消费
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
当前新闻源包含:
- `BBC World` RSS
- `DW Top Stories` RSS
- 按区域关键词拼出来的 `Google News RSS`
- `Global`
- `Americas`
- `Europe`
- `Middle East / Africa`
- `Asia Pacific`
当前不是采集器,也不落库,只做内存缓存。
## Phase 1: Source Configuration
### Goal
`NEWS_FEED_SOURCES` 从硬编码列表升级成可配置新闻源目录,但继续保留当前“实时聚合”的工作方式。
### Scope
- 为 Earth news 建立独立配置结构
- 支持后台维护 feed 源
- 支持启用/禁用、优先级、区域、源类型
- 保持现有 `/api/v1/news/earth-feed` 输出协议不变
### Proposed Shape
建议配置字段至少包括:
- `id`
- `name`
- `region`
- `feed_url`
- `homepage_url`
- `source_type`
- `priority`
- `is_enabled`
- 可选 `query_profile`
- 可选 `language`
- 可选 `notes`
### Suggested Storage
优先走系统设置或单独的 news source settings payload而不是先建复杂新表。
推荐原因:
- 改动小
- 易上线
- 和当前 TV settings 维护体验更接近
- 先解决“写死在代码里”的问题
### Non-goals
这一阶段不做:
- 新闻入库
- 新闻历史回看
- 新闻采集任务监控
- 新闻去重流水线
## Phase 2: News Collectorization
### Goal
把“态势新闻”升级为真正的采集器链路,使其进入采集系统和数据层。
### Scope
- 新增专用 news collector
- 按配置源定时采集 RSS / feed
- 做标题/链接级去重
- 建立统一新闻记录模型
- 为 Earth、控制台、AI 研判复用同一份新闻数据
### Benefits
- 有采集状态
- 有失败监控
- 有历史缓存
- 可以做时间轴 / 区域新闻基线
- 可以作为 AI 引用证据
### Required Design Work
需要提前明确:
- 新闻数据模型
- 去重策略
- 过期清理策略
- 区域映射策略
- 聚合排序策略
- 新闻与 Earth 当前视角/区域的关联方式
### Candidate Output Model
至少应包含:
- `source_id`
- `headline`
- `summary`
- `url`
- `publisher`
- `region`
- `published_at`
- `language`
- `tags`
- `raw_feed_source`
- `reference_date`
## Recommended Order
推荐执行顺序:
1. 先完成 Phase 1 配置化
2. 保持 Earth 继续实时聚合,但改为读取配置源
3. 等新闻源稳定后,再设计 Phase 2 的 collector / storage / dedupe
## Decision
当前结论:
- TV 直播源:优先采集器化
- 态势新闻:优先配置化,再采集器化
## Source Note
This plan is newly created for the Planet repo to separate the short-term "configurable source directory" work from the longer-term "collectorized news pipeline" work.

View File

@@ -67,6 +67,7 @@ React 路由入口:
- 旋转/缩放/布局 - 旋转/缩放/布局
- HUD 面板拖拽 - HUD 面板拖拽
- 图层开关状态机 - 图层开关状态机
- Earth 设置读取、持久化与重置
这份文件是 Earth 前端当前最核心的 UI 控制入口。 这份文件是 Earth 前端当前最核心的 UI 控制入口。
@@ -96,8 +97,12 @@ React 路由入口:
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js) - [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js) - [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) - [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js) - [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) - [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
- [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js)
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
职责: 职责:
@@ -106,6 +111,27 @@ React 路由入口:
- 面板内容 - 面板内容
- hover/lock/selection 语义 - hover/lock/selection 语义
其中 Earth 启动加载链现在也拆成了两层:
- `controls.js`
- 提供图层注册表与启动元信息
- `layer-startup-tasks.js`
- 提供图层启动任务注册表
- 通过 `registerLayerStartupTask(id, taskFactory)` 扩展启动任务
- `main.js`
- 只负责读取排序后的启动图层,再按映射执行队列
其中巡航模式现在已经拆成两层:
- `cruise-sequencer.js`
- 负责目标队列顺序、停留时长、切换节奏、打断与恢复
- `callout-connector.js`
- 负责卡片连线 SVG、路径计算与绘制动画
- `bgp-cruise-adapter.js`
- 负责 BGP 巡航展示适配目标排序、卡片落点、连线路径、focus/overlay/info-card 时序
当前 BGP 巡航只是这套能力的一个调用方不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。
## 当前样式分层 ## 当前样式分层
Earth 的 CSS 不是一份大样式表,而是分层管理: Earth 的 CSS 不是一份大样式表,而是分层管理:
@@ -155,6 +181,57 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。 因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。
另外Earth 图层控制现在已经收成“注册表驱动”:
- 图层元数据
- `id`
- `icon`
- `label`
- `meta`
- `buttonId`
- `persist`
- `startupPriority`
- `startupMode`
- `startupLabel`
- `startupMessage`
- 图层行为
- `getVisible()`
- `setVisible(next, options)`
当前入口仍在 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)。
这意味着后续新增图层时,优先应补一条图层注册定义,而不是同时去改:
- 图层面板 HTML
- 持久化快照
- 初始化恢复
- click 绑定
这四处现在都应该由注册表派生。
其中:
- `startupPriority`
- 描述图层参与启动加载时的顺序
- `startupMode`
- `visible`
- 仅当前图层处于启用/可见状态时,才加入启动加载队列
- `preload`
- 即使当前图层未显示,也会参与启动预加载
当前 `main.js` 会通过注册表读取排序后的启动图层列表,再动态拼装启动加载队列,而不是手写一串固定步骤。像 BGP 这类需要尽早准备数据、但不一定默认显示的图层,应该优先走 `startupMode: "preload"`,而不是在启动流程里写隐式特判。
此外,启动阶段给用户看的提示文案也应尽量从注册表派生:
- `startupLabel`
- 用于描述当前启动任务的业务名称
- `startupMessage`
- 用于描述启动中的提示文案
- 可以是字符串
- 也可以是对象,用于像海缆这种“准备阶段 / 主加载阶段”两段式文案
这样后续新增会参与启动加载的图层时,顺序、模式和提示文案都在同一处定义,不需要再去 `main.js` 里补第二套常量。
### `data-status-target` ### `data-status-target`
图层按钮可以通过: 图层按钮可以通过:
@@ -168,6 +245,26 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
以后别的异步图层也可以沿用这套约定。 以后别的异步图层也可以沿用这套约定。
## 当前设置持久化
Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一负责:
- 捕获默认值
-`localStorage` 读取上次设置
- 初始化应用当前设置
- 用户变更后即时持久化
- 一键重置回默认值
当前持久化的范围是:
- 旋转模式
- 地球默认大小(作为重置视角、缩放重置和巡航视图的默认 zoom 真源)
- HUD 面板显示/隐藏
- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP`
- 地形透明度
也就是说Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`
## 当前地形链路 ## 当前地形链路
真实地形首次启用会慢,原因不只是一个: 真实地形首次启用会慢,原因不只是一个:
@@ -233,6 +330,27 @@ Earth 已经经历过多轮 HUD、toolbar、media panel 重构,所以最容易
每次大功能完成后,都要做一次 cleanup pass。 每次大功能完成后,都要做一次 cleanup pass。
### 4. 巡航与业务事件不要再深度耦合
当前正确边界应该是:
- 通用巡航层只知道:
- 当前目标
- 队列顺序
- 相机 focus
- 停留 / 隐藏 / 切换
- 业务模块只负责:
- 提供目标队列
- 提供 focus 坐标
- 提供卡片内容
- 提供高亮/图层副作用
如果以后再给海缆、卫星或新闻做巡航,不应复制一套新的 `main.js` 状态变量,而应复用:
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 这种业务适配层模式
## 当前推荐改动方式 ## 当前推荐改动方式
如果后续继续改 Earth建议按这个顺序 如果后续继续改 Earth建议按这个顺序

View File

@@ -95,3 +95,93 @@
- 手工配置源 - 手工配置源
- `news_live_streams` 采集器采集源 - `news_live_streams` 采集器采集源
- 当前默认兜底源为 `CCTV-4 中文国际` - 当前默认兜底源为 `CCTV-4 中文国际`
- `news_live_streams` 在未配置 override 时,默认使用 `iptv-org`
- `channels.json`
- `streams.json`
- `logos.json`
并自动筛出新闻类频道目录
## 采集器配置方式
`news_live_streams` 不需要单独新页面,直接复用现有数据源配置:
- `endpoint`
- 频道目录 JSON API 地址
- `auth_type`
- `none` / `bearer` / `api_key` / `basic`
- `headers`
- 额外请求头
- `config`
- 采集器请求与解析行为
### 支持的 `config` 字段
```json
{
"timeout": 30,
"method": "GET",
"params": {
"region": "global"
},
"body_type": "json",
"body": {
"include_disabled": false
},
"response_path": "payload.channels"
}
```
- `timeout`
- 请求超时秒数
- `method`
- `GET``POST`
- `params`
- 查询参数对象
- `body_type`
- `json``form`
- `body`
- 配合 `POST` 使用的请求体
- `json_body`
- 显式 JSON 请求体,优先级高于 `body`
- `form_body`
- 显式表单请求体,优先级高于 `body`
- `response_path`
- 返回 JSON 中频道数组所在路径,支持点路径,例如:
- `payload.channels`
- `data.items`
- `result.streams`
### 认证补充
- `bearer`
- 使用 `Authorization: Bearer <token>`
- `api_key`
- 默认作为请求头发送
- 如果 `auth_config.in = "query"`,则作为 query param 发送
- `basic`
- 使用 HTTP Basic Authorization
## 兼容的响应结构
采集器会优先读取:
- 顶层数组
- 或这些常见字段下的数组:
- `sources`
- `streams`
- `channels`
- `items`
- `results`
- `data`
同时会兼容这些字段别名:
- `id` / `source_id` / `slug` / `channel_id` / `code`
- `name` / `title` / `channel` / `display_name`
- `provider` / `publisher` / `network`
- `stream_url` / `stream` / `playback_url` / `hls_url` / `m3u8_url`
- `embed_url` / `embed` / `page_url`
- `homepage_url` / `source_url` / `website`
- `language` / `lang` / `locale`
- `youtube_video_id` / `video_id`
- `youtube_channel` / `channel_handle`

View File

@@ -16,12 +16,17 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.31.1` - `dev` 当前开发分支历史推导到:`0.34.0`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override并修复 TV 合并采集源后默认频道消失的问题 |
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 | | `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 | | `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 | | `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "0.31.1", "version": "0.34.0",
"private": true, "private": true,
"packageManager": "bun@1", "packageManager": "bun@1",
"dependencies": { "dependencies": {
@@ -25,8 +25,8 @@
"vite": "^5.0.10" "vite": "^5.0.10"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "bun ./node_modules/vite/bin/vite.js",
"build": "tsc && vite build", "build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
"preview": "vite preview" "preview": "bun ./node_modules/vite/bin/vite.js preview"
} }
} }

View File

@@ -453,6 +453,246 @@
user-select: none; user-select: none;
} }
.earth-search-modal {
position: fixed;
inset: 0;
z-index: 255;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.earth-search-modal.is-open {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.earth-search-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 8, 20, 0.38);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
opacity: 0;
transition: opacity 0.2s ease;
}
.earth-search-modal.is-open .earth-search-backdrop {
opacity: 1;
}
.earth-search-sheet {
position: fixed;
top: max(calc(28px * var(--hud-scale)), 8vh);
left: 50%;
width: min(calc(680px * var(--hud-scale)), calc(100vw - (32px * var(--hud-scale))));
max-height: min(calc(720px * var(--hud-scale)), calc(100vh - (56px * var(--hud-scale))));
transform: translateX(-50%) scale(0.98);
transform-origin: top center;
padding: calc(var(--hud-panel-padding) * var(--hud-scale));
display: flex;
flex-direction: column;
gap: calc(var(--hud-gap-md) * var(--hud-scale));
overflow: hidden;
opacity: 0;
filter: blur(10px);
transition:
transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.22s ease,
filter 0.22s ease;
}
.earth-search-modal.is-open .earth-search-sheet {
transform: translateX(-50%) scale(1);
opacity: 1;
filter: blur(0);
}
.earth-search-header,
.earth-search-content {
position: relative;
z-index: 1;
}
.earth-search-kicker {
color: var(--hud-text-soft);
font-size: calc(0.72rem * var(--hud-scale));
letter-spacing: 0.16em;
text-transform: uppercase;
}
.earth-search-content {
display: flex;
flex-direction: column;
gap: calc(var(--hud-gap-sm) * var(--hud-scale));
min-height: 0;
}
.earth-search-input-shell {
display: flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
min-height: calc(48px * var(--hud-scale));
padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale));
border: 1px solid rgba(214, 230, 247, 0.12);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
rgba(255, 255, 255, 0.03);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.earth-search-input-shell:focus-within {
border-color: rgba(215, 230, 249, 0.22);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 1px rgba(126, 174, 236, 0.1);
}
.earth-search-input-icon {
color: var(--hud-text-muted);
font-size: calc(20px * var(--hud-scale));
}
.earth-search-input {
flex: 1 1 auto;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--hud-title);
font: inherit;
font-size: calc(0.9rem * var(--hud-scale));
letter-spacing: 0.01em;
}
.earth-search-input::placeholder {
color: rgba(190, 208, 227, 0.46);
}
.earth-search-clear[hidden] {
display: none;
}
.earth-search-meta {
min-height: calc(18px * var(--hud-scale));
color: var(--hud-text-muted);
font-size: calc(0.7rem * var(--hud-scale));
letter-spacing: 0.02em;
}
.earth-search-results {
display: flex;
flex-direction: column;
gap: calc(8px * var(--hud-scale));
min-height: 0;
overflow-y: auto;
padding-right: calc(4px * var(--hud-scale));
scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
}
.earth-search-results::-webkit-scrollbar {
width: 6px;
}
.earth-search-results::-webkit-scrollbar-track {
background: transparent;
}
.earth-search-results::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
border-radius: 999px;
}
.earth-search-empty {
color: var(--hud-text-muted);
font-size: calc(0.76rem * var(--hud-scale));
line-height: 1.55;
padding: calc(8px * var(--hud-scale)) calc(2px * var(--hud-scale));
}
.earth-search-result {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: calc(12px * var(--hud-scale));
width: 100%;
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
border: 1px solid rgba(212, 227, 244, 0.09);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
rgba(255, 255, 255, 0.025);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: inherit;
text-align: left;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.earth-search-result:hover,
.earth-search-result.is-active {
border-color: rgba(224, 236, 249, 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent),
rgba(255, 255, 255, 0.04);
transform: translateY(-1px);
}
.earth-search-result-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(32px * var(--hud-scale));
height: calc(32px * var(--hud-scale));
border-radius: calc(10px * var(--hud-scale));
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(214, 230, 247, 0.1);
color: var(--hud-accent-strong);
}
.earth-search-result-icon .material-symbols-rounded {
font-size: calc(18px * var(--hud-scale));
}
.earth-search-result-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.earth-search-result-title {
color: var(--hud-title);
font-size: calc(0.86rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.earth-search-result-subtitle {
color: var(--hud-text-soft);
font-size: calc(0.72rem * var(--hud-scale));
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.earth-search-result-type {
color: var(--hud-text-muted);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.earth-settings-modal { .earth-settings-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -538,7 +778,7 @@
.earth-settings-kicker { .earth-settings-kicker {
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: var(--hud-panel-header-title-size); font-size: calc(0.72rem * var(--hud-scale));
letter-spacing: 0.16em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -548,9 +788,26 @@
flex-shrink: 0; flex-shrink: 0;
} }
.earth-settings-reset {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding-inline: calc(10px * var(--hud-scale));
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.06em;
text-transform: uppercase;
}
.earth-settings-reset .material-symbols-rounded {
font-size: calc(0.86rem * var(--hud-scale));
}
.earth-settings-content { .earth-settings-content {
overflow-y: auto; overflow-y: auto;
padding-right: 4px; padding-inline: 8px;
padding-right: 10px;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) transparent; scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
} }
@@ -569,13 +826,13 @@
} }
.earth-settings-section { .earth-settings-section {
padding: 12px 0 20px; padding: 6px 0 10px;
} }
.earth-settings-section-title { .earth-settings-section-title {
margin-bottom: 12px; margin-bottom: 10px;
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: var(--hud-kicker-size); font-size: calc(0.62rem * var(--hud-scale));
letter-spacing: 0.16em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -583,16 +840,16 @@
.earth-settings-list { .earth-settings-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 8px;
} }
.earth-settings-item { .earth-settings-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 12px;
padding: 15px 16px; padding: 10px 13px;
border-radius: 16px; border-radius: 14px;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025); rgba(255, 255, 255, 0.025);
@@ -613,7 +870,7 @@
.earth-settings-item--stacked { .earth-settings-item--stacked {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 10px;
cursor: default; cursor: default;
} }
@@ -628,7 +885,7 @@
.earth-settings-slider-row { .earth-settings-slider-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
} }
.earth-settings-segmented { .earth-settings-segmented {
@@ -648,10 +905,10 @@
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--hud-text-soft); color: var(--hud-text-soft);
padding: 8px 14px; padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale));
border-radius: 999px; border-radius: 999px;
font: inherit; font: inherit;
font-size: 0.82rem; font-size: calc(0.7rem * var(--hud-scale));
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
@@ -680,18 +937,23 @@
.earth-settings-slider { .earth-settings-slider {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
height: 6px; height: calc(4px * var(--hud-scale));
appearance: none; appearance: none;
background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5)); background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5));
border-radius: 999px; border-radius: 999px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
transition: background 0.18s ease;
}
.earth-settings-slider:hover {
background: linear-gradient(90deg, rgba(152, 184, 224, 0.44), rgba(114, 155, 202, 0.64));
} }
.earth-settings-slider::-webkit-slider-thumb { .earth-settings-slider::-webkit-slider-thumb {
appearance: none; appearance: none;
width: 18px; width: calc(14px * var(--hud-scale));
height: 18px; height: calc(14px * var(--hud-scale));
border-radius: 50%; border-radius: 50%;
background: background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%), radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%),
@@ -699,26 +961,62 @@
border: 1px solid rgba(222, 236, 252, 0.4); border: 1px solid rgba(222, 236, 252, 0.4);
box-shadow: box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06), 0 0 0 1px rgba(255, 255, 255, 0.06),
0 6px 16px rgba(0, 0, 0, 0.24); 0 4px 10px rgba(0, 0, 0, 0.24);
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.earth-settings-slider:hover::-webkit-slider-thumb {
transform: scale(1.22);
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.32) 55%, transparent 70%),
linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98));
border-color: rgba(232, 244, 255, 0.72);
box-shadow:
0 0 0 3px rgba(145, 186, 255, 0.22),
0 6px 14px rgba(0, 0, 0, 0.3);
}
.earth-settings-slider:active::-webkit-slider-thumb {
transform: scale(1.08);
box-shadow:
0 0 0 4px rgba(145, 186, 255, 0.32),
0 4px 10px rgba(0, 0, 0, 0.28);
} }
.earth-settings-slider::-moz-range-thumb { .earth-settings-slider::-moz-range-thumb {
width: 18px; width: calc(14px * var(--hud-scale));
height: 18px; height: calc(14px * var(--hud-scale));
border-radius: 50%; border-radius: 50%;
background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92)); background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
border: 1px solid rgba(222, 236, 252, 0.4); border: 1px solid rgba(222, 236, 252, 0.4);
box-shadow: box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06), 0 0 0 1px rgba(255, 255, 255, 0.06),
0 6px 16px rgba(0, 0, 0, 0.24); 0 4px 10px rgba(0, 0, 0, 0.24);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.earth-settings-slider:hover::-moz-range-thumb {
transform: scale(1.22);
background: linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98));
border-color: rgba(232, 244, 255, 0.72);
box-shadow:
0 0 0 3px rgba(145, 186, 255, 0.22),
0 6px 14px rgba(0, 0, 0, 0.3);
}
.earth-settings-slider:active::-moz-range-thumb {
transform: scale(1.08);
box-shadow:
0 0 0 4px rgba(145, 186, 255, 0.32),
0 4px 10px rgba(0, 0, 0, 0.28);
} }
.earth-settings-slider-value { .earth-settings-slider-value {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 46px; min-width: calc(34px * var(--hud-scale));
text-align: right; text-align: right;
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: 0.78rem; font-size: calc(0.66rem * var(--hud-scale));
letter-spacing: 0.04em; letter-spacing: 0.04em;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -726,18 +1024,18 @@
.earth-settings-copy { .earth-settings-copy {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 3px;
} }
.earth-settings-item-title { .earth-settings-item-title {
color: var(--hud-text); color: var(--hud-text);
font-size: calc(0.98rem * var(--hud-scale)); font-size: calc(0.76rem * var(--hud-scale));
font-weight: 600; font-weight: 600;
} }
.earth-settings-item-subtitle { .earth-settings-item-subtitle {
color: var(--hud-text-muted); color: var(--hud-text-muted);
font-size: 0.82rem; font-size: calc(0.67rem * var(--hud-scale));
line-height: 1.4; line-height: 1.4;
} }
@@ -750,11 +1048,11 @@
} }
.earth-settings-link-meta .material-symbols-rounded:first-child { .earth-settings-link-meta .material-symbols-rounded:first-child {
font-size: 1.1rem; font-size: calc(0.9rem * var(--hud-scale));
} }
.earth-settings-link-meta .material-symbols-rounded:last-child { .earth-settings-link-meta .material-symbols-rounded:last-child {
font-size: 1rem; font-size: calc(0.82rem * var(--hud-scale));
} }
.earth-settings-switch { .earth-settings-switch {
@@ -770,8 +1068,8 @@
} }
.earth-settings-switch-track { .earth-settings-switch-track {
width: 48px; width: calc(38px * var(--hud-scale));
height: 30px; height: calc(22px * var(--hud-scale));
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(215, 229, 242, 0.12); border: 1px solid rgba(215, 229, 242, 0.12);
@@ -782,10 +1080,10 @@
.earth-settings-switch-track::after { .earth-settings-switch-track::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 3px; top: calc(3px * var(--hud-scale));
left: 3px; left: calc(3px * var(--hud-scale));
width: 22px; width: calc(16px * var(--hud-scale));
height: 22px; height: calc(16px * var(--hud-scale));
border-radius: 50%; border-radius: 50%;
background: #edf4fc; background: #edf4fc;
box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26); box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26);
@@ -798,7 +1096,7 @@
} }
.earth-settings-switch input:checked + .earth-settings-switch-track::after { .earth-settings-switch input:checked + .earth-settings-switch-track::after {
transform: translateX(18px); transform: translateX(calc(16px * var(--hud-scale)));
} }
@media (max-width: 960px) { @media (max-width: 960px) {

View File

@@ -206,11 +206,19 @@
} }
.info-card-cruise-link.is-animating circle { .info-card-cruise-link.is-animating circle {
animation: cruiseConnectorNodeIn 0.22s ease forwards;
animation-delay: 0.22s;
opacity: 0; opacity: 0;
} }
.info-card-cruise-link.is-animating circle:first-of-type {
animation: cruiseConnectorNodeIn 0.14s ease forwards;
animation-delay: 0.02s;
}
.info-card-cruise-link.is-animating circle:last-of-type {
animation: cruiseConnectorNodeIn 0.16s ease forwards;
animation-delay: 0.34s;
}
@keyframes cruiseConnectorDraw { @keyframes cruiseConnectorDraw {
from { from {
stroke-dashoffset: var(--connector-length, 0px); stroke-dashoffset: var(--connector-length, 0px);

View File

@@ -9,6 +9,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 200; z-index: 200;
pointer-events: none;
} }
.earth-toolbar-group, .earth-toolbar-group,
@@ -40,6 +41,7 @@
border: none; border: none;
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
pointer-events: none;
} }
.earth-toolbar-cluster { .earth-toolbar-cluster {
@@ -103,6 +105,14 @@
pointer-events: auto; pointer-events: auto;
} }
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
pointer-events: none;
}
.earth-toolbar-hub > * {
pointer-events: auto;
}
.earth-toolbar-orb > .liquid-glass-surface { .earth-toolbar-orb > .liquid-glass-surface {
animation: floatDock 4.6s ease-in-out infinite; animation: floatDock 4.6s ease-in-out infinite;
animation-delay: var(--orb-delay, 0s); animation-delay: var(--orb-delay, 0s);
@@ -120,7 +130,7 @@
border: none; border: none;
background: transparent; background: transparent;
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: 14px; font-size: calc(14px * var(--toolbar-scale));
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -361,7 +371,7 @@
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
transform: translate(-50%, 8px); transform: translate(-50%, calc(8px * var(--toolbar-scale)));
} }
.earth-toolbar-popover::before { .earth-toolbar-popover::before {
@@ -381,7 +391,7 @@
top: auto; top: auto;
right: auto; right: auto;
bottom: calc(100% + (12px * var(--toolbar-scale))); bottom: calc(100% + (12px * var(--toolbar-scale)));
transform: translate(-50%, 10px); transform: translate(-50%, calc(10px * var(--toolbar-scale)));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -398,16 +408,16 @@
.earth-zoom-toolbar .earth-zoom-btn, .earth-zoom-toolbar .earth-zoom-btn,
.earth-zoom-toolbar .earth-zoom-value { .earth-zoom-toolbar .earth-zoom-value {
width: 42px; width: calc(42px * var(--toolbar-scale));
min-width: 42px; min-width: calc(42px * var(--toolbar-scale));
border-radius: 50%; border-radius: 50%;
color: var(--hud-text-soft); color: var(--hud-text-soft);
animation: none; animation: none;
} }
.earth-zoom-toolbar .earth-zoom-btn { .earth-zoom-toolbar .earth-zoom-btn {
height: 42px; height: calc(42px * var(--toolbar-scale));
font-size: 20px; font-size: calc(20px * var(--toolbar-scale));
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
} }
@@ -416,9 +426,9 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 42px; height: calc(42px * var(--toolbar-scale));
padding: 0; padding: 0;
font-size: 0.68rem; font-size: calc(11px * var(--toolbar-scale));
letter-spacing: normal; letter-spacing: normal;
} }
@@ -429,15 +439,15 @@
.earth-toolbar-btn .earth-toolbar-tooltip { .earth-toolbar-btn .earth-toolbar-tooltip {
position: absolute; position: absolute;
bottom: 56px; bottom: calc(56px * var(--toolbar-scale));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: background:
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95)); linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
color: var(--hud-text); color: var(--hud-text);
padding: 6px 12px; padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
border-radius: 6px; border-radius: calc(6px * var(--toolbar-scale));
font-size: 12px; font-size: calc(12px * var(--toolbar-scale));
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
@@ -453,7 +463,7 @@
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip { .earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
bottom: 58px; bottom: calc(58px * var(--toolbar-scale));
} }
.earth-toolbar-btn .earth-toolbar-tooltip::after { .earth-toolbar-btn .earth-toolbar-tooltip::after {
@@ -462,6 +472,6 @@
top: 100%; top: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
border: 6px solid transparent; border: calc(6px * var(--toolbar-scale)) solid transparent;
border-top-color: rgba(18, 31, 52, 0.96); border-top-color: rgba(18, 31, 52, 0.96);
} }

View File

@@ -154,13 +154,13 @@
<div id="right-toolbar-group" class="earth-toolbar-group"> <div id="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar"> <div id="control-toolbar" class="earth-toolbar">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded"> <div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;"> <div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)"> <button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
<span class="icon" aria-hidden="true"> <span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">search</span> <span class="material-symbols-rounded">search</span>
</span> </span>
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span> <span class="tooltip earth-toolbar-tooltip">搜索</span>
</button> </button>
</div> </div>
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;"> <div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
@@ -412,6 +412,41 @@
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div> <div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
<div id="tooltip" class="earth-tooltip"></div> <div id="tooltip" class="earth-tooltip"></div>
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
<div id="search-backdrop" class="earth-search-backdrop"></div>
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
<div class="earth-search-header hud-panel__header">
<div class="hud-panel__title-group">
<div class="earth-search-kicker">搜索</div>
</div>
<div class="hud-panel__actions">
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<div class="earth-search-content hud-panel__body">
<div class="earth-search-input-shell">
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
<input
id="earth-search-input"
class="earth-search-input"
type="text"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="搜索海缆、登陆点、卫星、BGP 事件..."
>
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
</div>
</div>
</div>
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true"> <div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
<div id="settings-backdrop" class="earth-settings-backdrop"></div> <div id="settings-backdrop" class="earth-settings-backdrop"></div>
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置"> <div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
@@ -419,6 +454,10 @@
<div class="hud-panel__title-group"> <div class="hud-panel__title-group">
<div class="earth-settings-kicker">设置</div> <div class="earth-settings-kicker">设置</div>
</div> </div>
<button id="settings-reset" class="earth-settings-reset hud-panel__action" type="button" aria-label="重置设置">
<span class="material-symbols-rounded">restart_alt</span>
<span>重置</span>
</button>
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置"> <button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
<span class="material-symbols-rounded">close</span> <span class="material-symbols-rounded">close</span>
</button> </button>
@@ -456,6 +495,16 @@
<section class="earth-settings-section"> <section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div> <div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-daynight" type="checkbox" checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-view-layers"> <label class="earth-settings-item" for="toggle-view-layers">
<div class="earth-settings-copy"> <div class="earth-settings-copy">
<span class="earth-settings-item-title">图层控制</span> <span class="earth-settings-item-title">图层控制</span>
@@ -498,6 +547,30 @@
</label> </label>
</div> </div>
</section> </section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">地球默认大小</span>
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
</div>
<div class="earth-settings-slider-row">
<input
id="default-earth-size-slider"
class="earth-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
aria-label="调整地球默认大小"
>
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
</div>
</div>
</div>
</section>
<section class="earth-settings-section"> <section class="earth-settings-section">
<div class="earth-settings-section-title">地形</div> <div class="earth-settings-section-title">地形</div>
<div class="earth-settings-list"> <div class="earth-settings-list">

View File

@@ -0,0 +1,300 @@
import * as THREE from "three";
import { CRUISE_CONFIG, PATHS } from "./constants.js";
import { createElbowConnectorPoints } from "./callout-connector.js";
const scratchBGPWorldPosition = new THREE.Vector3();
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
const CRUISE_CARD_ANCHOR_OFFSET_PX = 18;
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
const CRUISE_CONNECTOR_DRAW_MS = 420;
const CRUISE_PRESENTATION_HIDE_MS = 220;
function getMarkerTimestamp(marker) {
const rawValue = marker?.userData?.created_at_raw;
const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
return Number.isFinite(parsedValue) ? parsedValue : 0;
}
export function createBGPCruiseAdapter({
camera,
getMarkers,
connector,
focusView,
setMarkerLocked,
clearMarkerState,
showMarkerOverlay,
applySatelliteHighlights,
showMarkerInfo,
hideInfo,
isInfoVisible,
getLockedObject,
refreshMarkers,
}) {
let currentMarkerId = null;
let cardPlacement = null;
let knownEventIds = new Set();
function getCurrentMarker() {
if (!currentMarkerId) return null;
return getMarkers().find((marker) => marker?.userData?.id === currentMarkerId) || null;
}
function getSortedMarkers() {
return getMarkers()
.slice()
.sort((a, b) => getMarkerTimestamp(b) - getMarkerTimestamp(a));
}
function getMarkerScreenCoords(marker) {
if (!marker || !camera) return null;
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
const projected = scratchBGPWorldPosition.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
};
}
function getCardScreenCoords(marker) {
const markerCoords = getMarkerScreenCoords(marker);
if (!markerCoords) return null;
const hudScale =
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
) || 1;
const estimatedCardHeight = Math.min(
CRUISE_CARD_ESTIMATED_HEIGHT_PX * hudScale,
window.innerHeight * 0.7,
);
const estimatedCardWidth = Math.min(
CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale,
window.innerWidth - CRUISE_CARD_VIEWPORT_PADDING_PX,
);
const x =
window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
const y =
window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
const margin = CRUISE_CARD_SCREEN_MARGIN_PX;
const clampedX = Math.min(
Math.max(margin, x),
Math.max(margin, window.innerWidth - estimatedCardWidth - margin),
);
const clampedY = Math.min(
Math.max(margin, y),
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
);
const anchorY = clampedY + Math.max(
CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale,
estimatedCardHeight * 0.18,
);
return {
x: clampedX,
y: clampedY,
width: estimatedCardWidth,
height: estimatedCardHeight,
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
anchorY,
};
}
function getConnectorPath(marker) {
const markerCoords = getMarkerScreenCoords(marker);
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
if (!markerCoords || !targetCardCoords) return null;
return createElbowConnectorPoints(
markerCoords,
{
x: targetCardCoords.anchorX,
y: targetCardCoords.anchorY,
},
{
startFrom: "source",
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx,
targetGapPx: CRUISE_CONFIG.linkPanelGapPx,
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx,
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx,
},
);
}
function renderConnector(marker, { animate = false } = {}) {
const path = getConnectorPath(marker);
if (!path) return false;
return connector.render(path, { animate });
}
function extractFeatureIds(features = []) {
return features
.map((feature) => {
const properties = feature?.properties || {};
const coords = feature?.geometry?.coordinates || [];
return (
properties.id ||
properties.incident_key ||
`${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}`
);
})
.filter(Boolean);
}
return {
getSortedMarkers,
getCurrentMarker,
isPresentationVisible() {
return cardPlacement != null;
},
clearCurrentHighlight() {
const marker = getCurrentMarker();
if (marker && getLockedObject() !== marker) {
clearMarkerState(marker);
}
currentMarkerId = null;
},
async focusMarker(marker, { interrupt = false } = {}) {
if (!marker) return;
currentMarkerId = marker.userData?.id || null;
cardPlacement = getCardScreenCoords(marker);
setMarkerLocked(marker);
showMarkerOverlay(marker);
await focusView({
lat: marker.userData?.latitude ?? 0,
lon: marker.userData?.longitude ?? 0,
rotLon: (marker.userData?.longitude ?? 0) - 270,
duration: interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
},
async presentMarker(marker, { context }) {
if (!marker) return false;
const startedAt = performance.now();
let connectorReady = false;
while (context.isCurrent()) {
connectorReady = renderConnector(marker, { animate: !connectorReady });
if (connectorReady) break;
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
break;
}
await context.nextFrame();
}
if (!connectorReady || !context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
applySatelliteHighlights(marker);
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
if (!connectorDelayCompleted || !context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
await context.nextFrame();
if (!isInfoVisible()) {
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
await context.nextFrame();
}
if (!isInfoVisible() || !context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
return true;
},
async hidePresentation({ context }) {
if (!getLockedObject()) {
hideInfo();
}
connector.hide();
const hideDelayCompleted = await context.wait(CRUISE_PRESENTATION_HIDE_MS, {
secondary: true,
});
if (!hideDelayCompleted) return;
cardPlacement = null;
},
repositionConnector(marker) {
if (!cardPlacement || !marker || !connector.isVisible() || connector.isAnimating()) {
return;
}
renderConnector(marker, { animate: false });
},
resetPresentation() {
cardPlacement = null;
connector.hide();
},
syncKnownEventIds() {
knownEventIds = new Set(
getMarkers()
.map((marker) => marker?.userData?.id)
.filter(Boolean),
);
return knownEventIds;
},
async pollForNewMarkerIds() {
const [incidentResponse, anomalyResponse] = await Promise.all([
fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
]);
if (!incidentResponse.ok || !anomalyResponse.ok) {
return [];
}
const [incidentPayload, anomalyPayload] = await Promise.all([
incidentResponse.json(),
anomalyResponse.json(),
]);
const incidentFeatures = Array.isArray(incidentPayload?.features)
? incidentPayload.features
: [];
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
? anomalyPayload.features
: [];
const selectedFeatures =
incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures;
const nextIds = extractFeatureIds(selectedFeatures);
const newIds = nextIds.filter((id) => !knownEventIds.has(id));
if (newIds.length === 0) return [];
await refreshMarkers();
this.syncKnownEventIds();
return newIds;
},
};
}

View File

@@ -0,0 +1,185 @@
const SVG_NS = "http://www.w3.org/2000/svg";
const DEFAULT_CLASS_NAME = "info-card-cruise-link";
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw";
function createSvgElement(tagName) {
return document.createElementNS(SVG_NS, tagName);
}
export function createElbowConnectorPoints(source, target, options = {}) {
if (!source || !target) return null;
const {
startFrom = "source",
sourceGapPx = 12,
targetGapPx = 8,
elbowOffsetPx = 18,
elbowDropPx = 14,
} = options;
const sourcePoint = { x: Number(source.x), y: Number(source.y) };
const targetPoint = { x: Number(target.x), y: Number(target.y) };
if (
!Number.isFinite(sourcePoint.x) ||
!Number.isFinite(sourcePoint.y) ||
!Number.isFinite(targetPoint.x) ||
!Number.isFinite(targetPoint.y)
) {
return null;
}
const horizontalDirection = sourcePoint.x <= targetPoint.x ? 1 : -1;
const startX = sourcePoint.x + horizontalDirection * sourceGapPx;
const startY = sourcePoint.y;
const endX = targetPoint.x - horizontalDirection * targetGapPx;
const endY = targetPoint.y;
const elbowX = endX - horizontalDirection * elbowOffsetPx;
const elbowY = Math.min(startY, endY) + elbowDropPx;
if (Math.abs(endX - startX) < 8 && Math.abs(endY - startY) < 8) {
return null;
}
const orderedPoints = [
{ x: startX, y: startY },
{ x: elbowX, y: elbowY },
{ x: endX, y: endY },
];
return {
points: startFrom === "target" ? orderedPoints.slice().reverse() : orderedPoints,
start: startFrom === "target" ? orderedPoints[2] : orderedPoints[0],
end: startFrom === "target" ? orderedPoints[0] : orderedPoints[2],
};
}
export class CalloutConnector {
constructor({
container = null,
containerId = "container",
className = DEFAULT_CLASS_NAME,
drawAnimationName = DEFAULT_DRAW_ANIMATION_NAME,
} = {}) {
this.container = container;
this.containerId = containerId;
this.className = className;
this.drawAnimationName = drawAnimationName;
this.connectorEl = null;
this.polylineEl = null;
this.startpointEl = null;
this.endpointEl = null;
}
resolveContainer() {
if (this.container instanceof HTMLElement) return this.container;
this.container = document.getElementById(this.containerId);
return this.container instanceof HTMLElement ? this.container : null;
}
ensure() {
if (this.connectorEl instanceof SVGSVGElement) {
return this.connectorEl;
}
const container = this.resolveContainer();
if (!container) return null;
const connector = createSvgElement("svg");
connector.setAttribute("class", this.className);
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
connector.setAttribute("preserveAspectRatio", "none");
const polyline = createSvgElement("polyline");
const startpoint = createSvgElement("circle");
const endpoint = createSvgElement("circle");
startpoint.setAttribute("r", "4");
endpoint.setAttribute("r", "4");
connector.append(startpoint, polyline, endpoint);
container.appendChild(connector);
connector.addEventListener("animationend", (event) => {
if (
event.animationName === this.drawAnimationName &&
this.connectorEl?.classList.contains("is-visible")
) {
if (this.polylineEl) {
this.polylineEl.style.strokeDashoffset = "0";
}
this.connectorEl?.classList.remove("is-animating");
}
});
this.connectorEl = connector;
this.polylineEl = polyline;
this.startpointEl = startpoint;
this.endpointEl = endpoint;
return connector;
}
isVisible() {
return this.connectorEl?.classList.contains("is-visible") === true;
}
isAnimating() {
return this.connectorEl?.classList.contains("is-animating") === true;
}
hide() {
const connector = this.ensure();
if (!connector) return;
connector.classList.remove("is-visible", "is-animating");
}
render(path, { animate = false } = {}) {
const connector = this.ensure();
if (
!connector ||
!this.polylineEl ||
!this.startpointEl ||
!this.endpointEl ||
!Array.isArray(path?.points) ||
path.points.length < 2
) {
return false;
}
const viewWidth = window.innerWidth;
const viewHeight = window.innerHeight;
connector.setAttribute("viewBox", `0 0 ${viewWidth} ${viewHeight}`);
const pointsText = path.points
.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`)
.join(" ");
this.polylineEl.setAttribute("points", pointsText);
this.startpointEl.setAttribute("cx", path.start.x.toFixed(2));
this.startpointEl.setAttribute("cy", path.start.y.toFixed(2));
this.endpointEl.setAttribute("cx", path.end.x.toFixed(2));
this.endpointEl.setAttribute("cy", path.end.y.toFixed(2));
const totalLength =
typeof this.polylineEl.getTotalLength === "function"
? this.polylineEl.getTotalLength()
: 0;
this.polylineEl.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
this.polylineEl.style.strokeDashoffset =
totalLength > 0 ? `${animate ? totalLength : 0}` : "";
connector.style.setProperty(
"--connector-length",
totalLength > 0 ? `${totalLength}` : "0px",
);
connector.classList.add("is-visible");
if (animate && totalLength > 0) {
connector.classList.remove("is-animating");
void connector.getBoundingClientRect();
this.polylineEl.style.strokeDashoffset = `${totalLength}`;
connector.classList.add("is-animating");
} else {
connector.classList.remove("is-animating");
}
return totalLength > 0;
}
}

View File

@@ -1,7 +1,11 @@
import * as THREE from "three"; import * as THREE from "three";
import * as Astronomy from "astronomy-engine"; import * as Astronomy from "astronomy-engine";
import { CELESTIAL_CONFIG, EARTH_CONFIG } from "./constants.js"; import {
CELESTIAL_CONFIG,
EARTH_CONFIG,
SCENE_LIGHT_CONFIG,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { latLonToVector3 } from "./utils.js";
const textureLoader = new THREE.TextureLoader(); const textureLoader = new THREE.TextureLoader();
@@ -21,7 +25,11 @@ let moonDirection = defaultMoonDirection.clone();
let lastUpdatedAt = 0; let lastUpdatedAt = 0;
let linkedSunLight = null; let linkedSunLight = null;
let linkedBackLight = null; let linkedBackLight = null;
let linkedAmbientLight = null;
let linkedPointLight = null;
let linkedCamera = null;
let linkedEarth = null; let linkedEarth = null;
let dayNightLightingEnabled = true;
let brightStarSprites = []; let brightStarSprites = [];
let celestialRotationQuaternion = new THREE.Quaternion(); let celestialRotationQuaternion = new THREE.Quaternion();
let celestialViewQuaternion = new THREE.Quaternion(); let celestialViewQuaternion = new THREE.Quaternion();
@@ -333,10 +341,71 @@ function updateSpritePositions() {
} }
function updateLighting() { function updateLighting() {
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
const inspection = CELESTIAL_CONFIG.inspectionLighting;
const cameraDirection = linkedCamera
? linkedCamera.position.clone().normalize()
: defaultSunDirection.clone();
const worldUp = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
if (right.lengthSq() < 1e-6) {
right.set(1, 0, 0);
} else {
right.normalize();
}
const adjustedUp = new THREE.Vector3()
.crossVectors(cameraDirection, right)
.normalize();
const resolveInspectionDirection = (offset) =>
cameraDirection
.clone()
.multiplyScalar(offset.z)
.add(right.clone().multiplyScalar(offset.x))
.add(adjustedUp.clone().multiplyScalar(offset.y))
.normalize();
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(inspection.ambientColor);
linkedAmbientLight.intensity = inspection.ambientIntensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(inspection.keyLightColor);
linkedSunLight.intensity = inspection.keyLightIntensity;
linkedSunLight.position
.copy(resolveInspectionDirection(inspection.keyLightOffset))
.multiplyScalar(inspection.keyLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(inspection.backLightColor);
linkedBackLight.intensity = inspection.backLightIntensity;
linkedBackLight.position
.copy(resolveInspectionDirection(inspection.backLightOffset))
.multiplyScalar(inspection.backLightDistance);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(inspection.pointLightColor);
linkedPointLight.intensity = inspection.pointLightIntensity;
linkedPointLight.position
.copy(resolveInspectionDirection(inspection.pointLightOffset))
.multiplyScalar(inspection.pointLightDistance);
}
return;
}
const physicalSunDirection = getPhysicalSunDirection( const physicalSunDirection = getPhysicalSunDirection(
new Date(lastUpdatedAt || Date.now()), new Date(lastUpdatedAt || Date.now()),
); );
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
}
if (linkedSunLight) { if (linkedSunLight) {
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor); linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity; linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
@@ -352,6 +421,16 @@ function updateLighting() {
.copy(physicalSunDirection) .copy(physicalSunDirection)
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7); .multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
} }
if (linkedPointLight) {
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
linkedPointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
}
} }
function computeCelestialState(date = new Date()) { function computeCelestialState(date = new Date()) {
@@ -364,14 +443,24 @@ function computeCelestialState(date = new Date()) {
export function initCelestialLayer( export function initCelestialLayer(
scene, scene,
{ camera = null, sunLight = null, backLight = null, earth = null } = {}, {
camera = null,
sunLight = null,
backLight = null,
ambientLight = null,
pointLight = null,
earth = null,
} = {},
) { ) {
if (!scene || !CELESTIAL_CONFIG.enabled) return null; if (!scene || !CELESTIAL_CONFIG.enabled) return null;
disposeCelestialLayer(); disposeCelestialLayer();
linkedCamera = camera;
linkedSunLight = sunLight; linkedSunLight = sunLight;
linkedBackLight = backLight; linkedBackLight = backLight;
linkedAmbientLight = ambientLight;
linkedPointLight = pointLight;
linkedEarth = earth; linkedEarth = earth;
celestialRoot = new THREE.Group(); celestialRoot = new THREE.Group();
@@ -457,6 +546,10 @@ export function initCelestialLayer(
export function updateCelestialLayer(date = new Date(), camera = null) { export function updateCelestialLayer(date = new Date(), camera = null) {
if (!celestialRoot) return; if (!celestialRoot) return;
if (camera) {
linkedCamera = camera;
}
refreshCelestialView(); refreshCelestialView();
const now = date.getTime(); const now = date.getTime();
@@ -503,6 +596,11 @@ export function setCelestialFollow(nextFollow = {}) {
return getCelestialDebugState(); return getCelestialDebugState();
} }
export function setCelestialDayNightEnabled(enabled) {
dayNightLightingEnabled = enabled;
updateLighting();
}
export function disposeCelestialLayer() { export function disposeCelestialLayer() {
if (celestialRoot?.parent) { if (celestialRoot?.parent) {
celestialRoot.parent.remove(celestialRoot); celestialRoot.parent.remove(celestialRoot);
@@ -531,7 +629,11 @@ export function disposeCelestialLayer() {
lastUpdatedAt = 0; lastUpdatedAt = 0;
linkedSunLight = null; linkedSunLight = null;
linkedBackLight = null; linkedBackLight = null;
linkedAmbientLight = null;
linkedPointLight = null;
linkedCamera = null;
linkedEarth = null; linkedEarth = null;
dayNightLightingEnabled = true;
sunDirection.copy(defaultSunDirection); sunDirection.copy(defaultSunDirection);
moonDirection.copy(defaultMoonDirection); moonDirection.copy(defaultMoonDirection);
runtimeOrientationEuler = { runtimeOrientationEuler = {

View File

@@ -3,6 +3,7 @@
// Scene configuration // Scene configuration
export const CONFIG = { export const CONFIG = {
defaultCameraZ: 300, defaultCameraZ: 300,
defaultViewZoom: 1.0,
minZoom: 0.5, minZoom: 0.5,
maxZoom: 5.0, maxZoom: 5.0,
earthRadius: 100, earthRadius: 100,
@@ -92,6 +93,45 @@ export const CELESTIAL_CONFIG = {
sunLightColor: 0xfff4df, sunLightColor: 0xfff4df,
backLightIntensity: 0.3, backLightIntensity: 0.3,
backLightColor: 0x2b4c78, backLightColor: 0x2b4c78,
inspectionLighting: {
enabled: true,
ambientIntensity: 0.64,
ambientColor: 0x707070,
keyLightIntensity: 0.92,
keyLightColor: 0xfcfcfb,
keyLightDistance: 380,
keyLightOffset: { x: 0.42, y: 0.34, z: 0.84 },
backLightIntensity: 0.26,
backLightColor: 0x8f96a0,
backLightDistance: 260,
backLightOffset: { x: -0.52, y: -0.1, z: -0.62 },
pointLightIntensity: 0.36,
pointLightColor: 0xfafcff,
pointLightDistance: 320,
pointLightOffset: { x: 0.18, y: 0.52, z: 0.62 },
},
};
export const SCENE_LIGHT_CONFIG = {
ambient: {
color: 0x404060,
intensity: 1,
},
sun: {
color: 0xffffff,
intensity: 1.2,
position: { x: 5, y: 3, z: 5 },
},
back: {
color: 0x446688,
intensity: 0.3,
position: { x: -5, y: 0, z: -5 },
},
point: {
color: 0xffffff,
intensity: 0.4,
position: { x: 10, y: 10, z: 10 },
},
}; };
export const TERRAIN_CONFIG = { export const TERRAIN_CONFIG = {
@@ -192,6 +232,9 @@ export const SATELLITE_CONFIG = {
initialLoadCount: 2400, initialLoadCount: 2400,
hydrateFullAfterInitialLoad: true, hydrateFullAfterInitialLoad: true,
trailLength: 10, trailLength: 10,
displayAltitudeOffset: 8,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotSize: 4, dotSize: 4,
ringSize: 0.07, ringSize: 0.07,
apiPath: '/api/v1/visualization/geo/satellites', apiPath: '/api/v1/visualization/geo/satellites',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,229 @@
function nextAnimationFrame() {
return new Promise((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
export class CruiseSequencer {
constructor({
isActive,
getItems,
getItemId,
focusItem,
presentItem,
hideItem,
clearCurrent,
onStop,
dwellMs = 2400,
transitionGapMs = 24,
}) {
this.isActive = isActive;
this.getItems = getItems;
this.getItemId = getItemId;
this.focusItem = focusItem;
this.presentItem = presentItem;
this.hideItem = hideItem;
this.clearCurrent = clearCurrent;
this.onStop = onStop;
this.dwellMs = dwellMs;
this.transitionGapMs = transitionGapMs;
this.currentItemId = null;
this.currentIndex = -1;
this.queuedItemIds = [];
this.sequenceToken = 0;
this.advanceQueued = false;
this.advanceInterrupt = false;
this.advanceInFlight = false;
this.advanceLoopToken = 0;
this.primaryTimerId = null;
this.secondaryTimerId = null;
this.presentationVisible = false;
}
getCurrentItem() {
if (!this.currentItemId) return null;
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
}
getCurrentItemId() {
return this.currentItemId;
}
isPresentationPinned() {
return this.presentationVisible;
}
isBusy() {
return this.advanceInFlight || this.presentationVisible;
}
enqueue(itemIds = []) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return;
this.queuedItemIds = Array.from(
new Set([...itemIds.filter(Boolean), ...this.queuedItemIds]),
);
}
setPresentationVisible(visible) {
this.presentationVisible = Boolean(visible);
}
clearTimers() {
if (this.primaryTimerId) {
clearTimeout(this.primaryTimerId);
this.primaryTimerId = null;
}
if (this.secondaryTimerId) {
clearTimeout(this.secondaryTimerId);
this.secondaryTimerId = null;
}
}
interruptPresentation({ preservePresentation = false, resetLoop = false } = {}) {
this.sequenceToken += 1;
this.clearTimers();
this.advanceQueued = false;
this.advanceInterrupt = false;
if (resetLoop) {
this.advanceLoopToken += 1;
this.advanceInFlight = false;
}
if (!preservePresentation) {
this.presentationVisible = false;
this.clearCurrent?.();
}
}
stop({ preservePresentation = false } = {}) {
this.interruptPresentation({ preservePresentation });
this.currentItemId = preservePresentation ? this.currentItemId : null;
this.currentIndex = preservePresentation ? this.currentIndex : -1;
this.queuedItemIds = [];
this.onStop?.({ preservePresentation });
}
createContext(token) {
return {
token,
isCurrent: () => token === this.sequenceToken && this.isActive(),
wait: (durationMs, { secondary = false } = {}) =>
new Promise((resolve) => {
const timerId = window.setTimeout(() => {
if (secondary) {
if (this.secondaryTimerId === timerId) this.secondaryTimerId = null;
} else if (this.primaryTimerId === timerId) {
this.primaryTimerId = null;
}
resolve(token === this.sequenceToken && this.isActive());
}, durationMs);
if (secondary) {
this.secondaryTimerId = timerId;
} else {
this.primaryTimerId = timerId;
}
}),
nextFrame: nextAnimationFrame,
setPresentationVisible: (visible) => {
if (token !== this.sequenceToken) return;
this.presentationVisible = Boolean(visible);
},
};
}
resolveNextItem(items) {
let targetItem = null;
while (this.queuedItemIds.length > 0 && !targetItem) {
const queuedId = this.queuedItemIds.shift();
targetItem = items.find((item) => this.getItemId(item) === queuedId) || null;
}
if (targetItem) return targetItem;
const nextIndex = this.currentIndex >= 0 ? (this.currentIndex + 1) % items.length : 0;
return items[nextIndex] || items[0] || null;
}
async performAdvance({ interrupt = false } = {}) {
if (!this.isActive()) return;
const items = this.getItems();
if (!Array.isArray(items) || items.length === 0) return;
const targetItem = this.resolveNextItem(items);
if (!targetItem) return;
const token = ++this.sequenceToken;
const context = this.createContext(token);
this.clearTimers();
this.presentationVisible = false;
this.clearCurrent?.();
this.currentItemId = this.getItemId(targetItem);
this.currentIndex = items.findIndex(
(item) => this.getItemId(item) === this.currentItemId,
);
await this.focusItem?.(targetItem, { interrupt, context });
if (!context.isCurrent()) {
this.presentationVisible = false;
return;
}
const presented = await this.presentItem?.(targetItem, { interrupt, context });
if (!presented || !context.isCurrent()) {
this.presentationVisible = false;
return;
}
this.presentationVisible = true;
const dwellCompleted = await context.wait(this.dwellMs);
if (!dwellCompleted || !context.isCurrent()) {
this.presentationVisible = false;
return;
}
await this.hideItem?.(targetItem, { context });
if (!context.isCurrent()) {
this.presentationVisible = false;
return;
}
this.presentationVisible = false;
const gapCompleted = await context.wait(this.transitionGapMs, { secondary: true });
if (!gapCompleted || !context.isCurrent()) {
return;
}
void this.advance();
}
async advance({ interrupt = false } = {}) {
if (!this.isActive()) return;
this.advanceQueued = true;
this.advanceInterrupt = this.advanceInterrupt || interrupt;
if (this.advanceInFlight) return;
const activeLoopToken = ++this.advanceLoopToken;
this.advanceInFlight = true;
try {
while (
this.advanceQueued &&
this.isActive() &&
this.advanceLoopToken === activeLoopToken
) {
const nextInterrupt = this.advanceInterrupt;
this.advanceQueued = false;
this.advanceInterrupt = false;
await this.performAdvance({ interrupt: nextInterrupt });
}
} finally {
if (this.advanceLoopToken === activeLoopToken) {
this.advanceInFlight = false;
}
}
}
}

View File

@@ -11,6 +11,7 @@ export let terrain = null;
const textureLoader = new THREE.TextureLoader(); const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null; let _earthMaterial = null;
let _earthShader = null; let _earthShader = null;
let _dayNightEnabled = true;
const _earthSunDirection = new THREE.Vector3( const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x, EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y, EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
@@ -33,6 +34,7 @@ function applyEarthDayNightShader(material) {
shader.uniforms.uTwilightColor = { value: twilightColor }; shader.uniforms.uTwilightColor = { value: twilightColor };
shader.uniforms.uNightTintColor = { value: nightTintColor }; shader.uniforms.uNightTintColor = { value: nightTintColor };
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity }; shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 };
shader.vertexShader = shader.vertexShader.replace( shader.vertexShader = shader.vertexShader.replace(
"#include <common>", "#include <common>",
@@ -55,7 +57,8 @@ uniform float uTwilightWidth;
uniform float uTwilightIntensity; uniform float uTwilightIntensity;
uniform vec3 uTwilightColor; uniform vec3 uTwilightColor;
uniform vec3 uNightTintColor; uniform vec3 uNightTintColor;
uniform float uNightTintIntensity;`, uniform float uNightTintIntensity;
uniform float uDayNightEnabled;`,
).replace( ).replace(
"#include <output_fragment>", "#include <output_fragment>",
` `
@@ -65,16 +68,27 @@ uniform float uNightTintIntensity;`,
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing); float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing)); float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
outgoingLight *= mix(uNightFloor, uDayBoost, daylight); // Camera-facing diffuse: vNormal and vViewPosition are both in view space.
outgoingLight += uTwilightColor * twilight * uTwilightIntensity; // N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity; float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition)));
float cameraBoost = mix(0.62, 1.08, nDotV);
float dn = uDayNightEnabled;
vec3 dnLight = outgoingLight;
dnLight *= mix(uNightFloor, uDayBoost, daylight);
dnLight += uTwilightColor * twilight * uTwilightIntensity;
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
// dn=1: full day/night solar lighting
outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn);
#include <output_fragment> #include <output_fragment>
`, `,
); );
}; };
material.customProgramCacheKey = () => "earth-day-night-v1"; material.customProgramCacheKey = () => "earth-day-night-v5";
material.needsUpdate = true; material.needsUpdate = true;
} }
@@ -348,6 +362,28 @@ export function setEarthSunDirection(direction) {
} }
} }
export function setDayNightEnabled(enabled) {
_dayNightEnabled = enabled;
if (_earthShader?.uniforms?.uDayNightEnabled) {
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
if (_earthMaterial) {
if (enabled) {
// Restore normal Phong lighting + custom day/night shader
_earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive);
_earthMaterial.emissiveMap = null;
} else {
// Full bright: zero diffuse so directional light has no effect;
// use original color as emissive map to show texture uniformly.
_earthMaterial.color.setRGB(0, 0, 0);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissiveMap = _earthMaterial.map;
}
_earthMaterial.needsUpdate = true;
}
}
export function loadEarthTexture() { export function loadEarthTexture() {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; } if (!_earthMaterial) { resolve(); return; }
@@ -368,6 +404,10 @@ export function loadEarthTexture() {
texture.minFilter = THREE.LinearMipmapLinearFilter; texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter;
_earthMaterial.map = texture; _earthMaterial.map = texture;
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
if (!_dayNightEnabled) {
_earthMaterial.emissiveMap = texture;
}
_earthMaterial.needsUpdate = true; _earthMaterial.needsUpdate = true;
resolve(); resolve();
}, },

View File

@@ -18,6 +18,18 @@ const CARD_CONFIG = {
{ key: 'rfs', label: '投入使用' } { key: 'rfs', label: '投入使用' }
] ]
}, },
landing_point: {
icon: '📍',
title: '登陆点详情',
className: 'cable',
fields: [
{ key: 'name', label: '名称' },
{ key: 'country', label: '国家' },
{ key: 'status', label: '状态' },
{ key: 'cable_count', label: '关联海缆数' },
{ key: 'cables', label: '关联海缆' }
]
},
satellite: { satellite: {
icon: '🛰️', icon: '🛰️',
title: '卫星详情', title: '卫星详情',

View File

@@ -0,0 +1,174 @@
import {
loadGeoJSONFromPath,
loadLandingPoints,
getCableLegendItems,
toggleCables,
} from "./cables.js";
import {
clearSatelliteData,
getSatelliteLegendItems,
loadSatellites,
toggleSatellites,
} from "./satellites.js";
import {
loadBGPAnomalies,
toggleBGP,
} from "./bgp.js";
/**
* Layer startup task registry.
*
* This module is the startup-task counterpart to the layer registry in controls.js:
* - controls.js owns layer metadata such as startupPriority/startupMode/startupMessage
* - this file owns the executable startup task factory for each layer id
*
* A startup task is registered via registerLayerStartupTask(id, taskFactory).
* The taskFactory receives a startup context from main.js and must return an async
* function with the signature async (layerDefinition) => void.
*
* Put a task here only when a layer needs dedicated startup loading work:
* - preloading data at boot
* - staged loading with progress/loading messages
* - post-load UI refresh or warmup
*
* Do not put plain visibility toggles or persistent UI state here; those still belong
* to the layer registry/state flow in controls.js.
*/
const startupTaskRegistry = new Map();
export function resolveStartupMessage(layer, key, fallback) {
const message = layer?.startupMessage;
if (message && typeof message === "object" && key in message) {
return message[key];
}
if (typeof message === "string" && message.trim()) {
return message;
}
return fallback;
}
export function createLayerStartupTaskMap(context) {
return Object.fromEntries(
Array.from(startupTaskRegistry.entries()).map(([id, taskFactory]) => [
id,
taskFactory(context),
]),
);
}
export function registerLayerStartupTask(id, taskFactory) {
if (typeof id !== "string" || !id.trim()) {
throw new Error("registerLayerStartupTask 需要有效的图层 id");
}
if (typeof taskFactory !== "function") {
throw new Error("registerLayerStartupTask 需要可调用的任务工厂");
}
startupTaskRegistry.set(id, taskFactory);
}
function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCableStartupTask();
registerSatelliteStartupTask();
registerBGPStartupTask();
}
function registerCableStartupTask() {
registerLayerStartupTask("cables", (context) => async (layer) => {
if (!context.isCablesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "prepare", "正在加载登陆点..."),
);
await context.yieldFrame(12);
try {
await loadLandingPoints(context.scene, context.earth, { silent: true });
} catch (error) {
context.reportError("登陆点", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海缆..."),
);
await context.yieldFrame(12);
try {
await loadGeoJSONFromPath(context.scene, context.earth, { silent: true });
if (!context.isCancelled() && context.isCablesEnabled()) {
toggleCables(true);
context.updateCableToggleUi(true);
context.setLegendItems("cables", getCableLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "海缆", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerSatelliteStartupTask() {
registerLayerStartupTask("satellites", (context) => async (layer) => {
if (!context.isSatellitesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载卫星..."),
);
await context.yieldFrame(12);
try {
clearSatelliteData();
const loadResult = await loadSatellites({
limit: context.getInitialSatelliteLoadLimit(),
});
if (!context.isCancelled() && context.isSatellitesEnabled()) {
context.updateSatelliteToggleUi(true, loadResult.count);
context.setLegendItems("satellites", getSatelliteLegendItems());
context.refreshLegend();
context.scheduleSatellitePositionWarmup(() => {
if (!context.isCancelled() && context.isSatellitesEnabled()) {
toggleSatellites(true);
}
});
if (context.shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = context.nextSatelliteHydrationToken();
context.hydrateAllSatellitesInBackground(
() =>
hydrationToken === context.getSatelliteHydrationToken() &&
!context.isCancelled() &&
context.isSatellitesEnabled(),
);
}
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "卫星", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerBGPStartupTask() {
registerLayerStartupTask("bgp", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载BGP态势..."),
);
await context.yieldFrame(12);
try {
const bgpResult = await loadBGPAnomalies(context.scene, context.earth);
if (!context.isCancelled()) {
toggleBGP(context.getShowBGP());
context.updateBGPHud(bgpResult);
context.syncBGPKnownEventIds();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "BGP态势", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
registerBuiltinLayerStartupTasks();

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,10 @@ let satelliteSatrecCache = new Map();
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32; const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250; const POSITION_UPDATE_INTERVAL_MS = 250;
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
const DIMMED_SATELLITE_BACKDROP_OPACITY = 0.1;
const scratchWorldSatellitePosition = new THREE.Vector3(); const scratchWorldSatellitePosition = new THREE.Vector3();
const scratchToCamera = new THREE.Vector3(); const scratchToCamera = new THREE.Vector3();
@@ -490,7 +494,8 @@ function computeSatellitePosition(satellite, time) {
} }
const r = Math.sqrt(x * x + y * y + z * z); const r = Math.sqrt(x * x + y * y + z * z);
const displayRadius = CONFIG.earthRadius * 1.05; const displayRadius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const scale = displayRadius / r; const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale); return new THREE.Vector3(x * scale, y * scale, z * scale);
@@ -637,7 +642,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
} }
function generateFallbackPosition(satellite, index, total) { function generateFallbackPosition(satellite, index, total) {
const radius = CONFIG.earthRadius + 5; const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index; const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53; const inclination = satellite.properties?.inclination || 53;
@@ -745,16 +750,16 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
const rule = getSatelliteLegendRule(props); const rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule); const { r, g, b } = getSatelliteRuleColor(rule);
if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) { const isNonFocusDimmed =
const lum = r * 0.299 + g * 0.587 + b * 0.114; highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i);
colors[i * 3] = lum * 0.75 + r * 0.25; const pointBrightness = isNonFocusDimmed ? DIMMED_SATELLITE_BRIGHTNESS : 1;
colors[i * 3 + 1] = lum * 0.75 + g * 0.25; const trailBrightness = isNonFocusDimmed
colors[i * 3 + 2] = lum * 0.75 + b * 0.25; ? DIMMED_SATELLITE_TRAIL_BRIGHTNESS
} else { : 1;
colors[i * 3] = r;
colors[i * 3 + 1] = g; colors[i * 3] = r * pointBrightness;
colors[i * 3 + 2] = b; colors[i * 3 + 1] = g * pointBrightness;
} colors[i * 3 + 2] = b * pointBrightness;
const satPosition = satellitePositions[i]; const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) { for (let j = 0; j < TRAIL_LENGTH; j++) {
@@ -770,9 +775,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
trailPositions[trailIdx + 1] = trailPoint.y; trailPositions[trailIdx + 1] = trailPoint.y;
trailPositions[trailIdx + 2] = trailPoint.z; trailPositions[trailIdx + 2] = trailPoint.z;
const alpha = (j + 1) / satPosition.trailCount; const alpha = (j + 1) / satPosition.trailCount;
trailColors[trailIdx] = r * alpha; trailColors[trailIdx] = r * alpha * trailBrightness;
trailColors[trailIdx + 1] = g * alpha; trailColors[trailIdx + 1] = g * alpha * trailBrightness;
trailColors[trailIdx + 2] = b * alpha; trailColors[trailIdx + 2] = b * alpha * trailBrightness;
continue; continue;
} }
} }
@@ -843,6 +848,10 @@ export function toggleTrails(visible) {
} }
} }
export function getShowTrails() {
return showTrails;
}
export function getShowSatellites() { export function getShowSatellites() {
return showSatellites; return showSatellites;
} }
@@ -902,7 +911,10 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
.subVectors(scratchWorldSatellitePosition, earthObjRef.position) .subVectors(scratchWorldSatellitePosition, earthObjRef.position)
.normalize(); .normalize();
return scratchToCamera.dot(scratchToSatellite) > 0; return (
scratchToCamera.dot(scratchToSatellite) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
} }
function createBrighterDotCanvas() { function createBrighterDotCanvas() {
@@ -948,6 +960,7 @@ function createRingSprite(position, isLocked = false) {
const sprite = new THREE.Sprite(spriteMaterial); const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position); sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1); sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite); earthObjRef.add(sprite);
return sprite; return sprite;
} }
@@ -967,6 +980,7 @@ function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
const sprite = new THREE.Sprite(spriteMaterial); const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position); sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1); sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite); earthObjRef.add(sprite);
return sprite; return sprite;
} }
@@ -989,6 +1003,7 @@ export function showHoverRing(position, isLocked = false) {
lockedDotSprite = new THREE.Sprite(dotMaterial); lockedDotSprite = new THREE.Sprite(dotMaterial);
lockedDotSprite.position.copy(position); lockedDotSprite.position.copy(position);
lockedDotSprite.scale.set(4, 4, 1); lockedDotSprite.scale.set(4, 4, 1);
lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
earthObjRef.add(lockedDotSprite); earthObjRef.add(lockedDotSprite);
return lockedRingSprite; return lockedRingSprite;
} }
@@ -1086,10 +1101,10 @@ export function setSatelliteRingState(index, state, position) {
function applyDimMaterialState(isDimmed) { function applyDimMaterialState(isDimmed) {
if (satellitePoints) { if (satellitePoints) {
satellitePoints.material.opacity = isDimmed ? 0.32 : 0.9; satellitePoints.material.opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
} }
if (satelliteBackdropPoints) { if (satelliteBackdropPoints) {
satelliteBackdropPoints.material.opacity = isDimmed ? 0.12 : 0.42; satelliteBackdropPoints.material.opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
} }
} }
@@ -1199,7 +1214,8 @@ function calculatePredictedOrbit(
if (points.length < samples * 0.5) { if (points.length < samples * 0.5) {
points.length = 0; points.length = 0;
const radius = CONFIG.earthRadius + 5; const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const inclination = satellite.properties?.inclination || 53; const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0; const raan = satellite.properties?.raan || 0;
@@ -1250,9 +1266,12 @@ export function showPredictedOrbit(satellite) {
transparent: true, transparent: true,
opacity: 0.8, opacity: 0.8,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
depthTest: true,
depthWrite: false,
}); });
predictedOrbitLine = new THREE.Line(geometry, material); predictedOrbitLine = new THREE.Line(geometry, material);
predictedOrbitLine.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(predictedOrbitLine); earthObjRef.add(predictedOrbitLine);
} }

View File

@@ -0,0 +1,227 @@
let initialized = false;
let resolveResultsFn = null;
let onSelectResultFn = null;
let currentResults = [];
let activeIndex = -1;
let searchTimerId = null;
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function getElements() {
return {
modal: document.getElementById("search-modal"),
backdrop: document.getElementById("search-backdrop"),
input: document.getElementById("earth-search-input"),
clear: document.getElementById("earth-search-clear"),
meta: document.getElementById("earth-search-meta"),
results: document.getElementById("earth-search-results"),
empty: document.getElementById("earth-search-empty"),
close: document.getElementById("search-close"),
};
}
function setMeta(text) {
const { meta } = getElements();
if (meta) meta.textContent = text;
}
function updateEmptyState(query) {
const { empty } = getElements();
if (!empty) return;
if (!query) {
empty.textContent = "支持搜索海缆、登陆点、卫星、BGP 事件与观测站。";
return;
}
empty.textContent = "未找到匹配对象可尝试名称、地点、NORAD、ASN、前缀等关键词。";
}
function renderResults(query) {
const { results, empty } = getElements();
if (!results || !empty) return;
results.innerHTML = "";
const hasResults = currentResults.length > 0;
empty.hidden = hasResults;
updateEmptyState(query);
if (!hasResults) return;
currentResults.forEach((result, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "earth-search-result";
button.setAttribute("role", "option");
button.dataset.index = String(index);
button.innerHTML = `
<span class="earth-search-result-icon" aria-hidden="true">
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
</span>
<span class="earth-search-result-copy">
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
</span>
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
`;
button.addEventListener("click", async () => {
await selectResult(index);
});
results.appendChild(button);
});
syncActiveResult();
}
function syncActiveResult() {
const { results } = getElements();
if (!results) return;
Array.from(results.children).forEach((node, index) => {
node.classList.toggle("is-active", index === activeIndex);
});
}
function moveActiveResult(delta) {
if (currentResults.length === 0) return;
activeIndex =
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
currentResults.length;
syncActiveResult();
const { results } = getElements();
const activeNode = results?.children?.[activeIndex];
activeNode?.scrollIntoView({ block: "nearest" });
}
async function selectResult(index) {
const result = currentResults[index];
if (!result || typeof onSelectResultFn !== "function") return;
closeSearchPanel();
try {
await onSelectResultFn(result);
} catch (error) {
console.error("Search selection failed:", error);
}
}
async function runSearch() {
const { input, clear } = getElements();
if (!input) return;
const query = input.value.trim();
if (clear) {
clear.hidden = query.length === 0;
}
if (!query) {
currentResults = [];
activeIndex = -1;
setMeta("输入关键词以搜索当前地球对象");
renderResults("");
return;
}
setMeta("正在检索…");
try {
const nextResults = await resolveResultsFn?.(query);
currentResults = Array.isArray(nextResults) ? nextResults : [];
activeIndex = currentResults.length > 0 ? 0 : -1;
setMeta(`找到 ${currentResults.length} 个结果`);
renderResults(query);
} catch (error) {
console.error("Search failed:", error);
currentResults = [];
activeIndex = -1;
setMeta("搜索失败");
renderResults(query);
}
}
function scheduleSearch() {
if (searchTimerId) {
clearTimeout(searchTimerId);
}
searchTimerId = window.setTimeout(() => {
searchTimerId = null;
runSearch();
}, 120);
}
function handleKeydown(event) {
const { modal, input } = getElements();
if (!modal?.classList.contains("is-open")) return;
if (event.key === "Escape") {
event.preventDefault();
closeSearchPanel();
return;
}
if (event.target !== input) return;
if (event.key === "ArrowDown") {
event.preventDefault();
moveActiveResult(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveActiveResult(-1);
} else if (event.key === "Enter" && activeIndex >= 0) {
event.preventDefault();
selectResult(activeIndex).catch((error) => {
console.warn("Selecting search result failed:", error);
});
}
}
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
resolveResultsFn = resolveResults;
onSelectResultFn = onSelectResult;
if (initialized) return;
initialized = true;
const { input, clear, close, backdrop } = getElements();
input?.addEventListener("input", scheduleSearch);
input?.addEventListener("keydown", handleKeydown);
clear?.addEventListener("click", () => {
if (!input) return;
input.value = "";
input.focus();
runSearch().catch((error) => {
console.warn("Clearing search failed:", error);
});
});
close?.addEventListener("click", () => {
closeSearchPanel();
});
backdrop?.addEventListener("click", () => {
closeSearchPanel();
});
document.addEventListener("keydown", handleKeydown);
}
export function openSearchPanel() {
const { modal, input } = getElements();
if (!modal) return;
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
window.setTimeout(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
});
}, 16);
}
export function closeSearchPanel() {
const { modal } = getElements();
if (!modal) return;
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
}

View File

@@ -18,6 +18,7 @@ let terrainLoadPromise = null;
let terrainReady = false; let terrainReady = false;
let terrainFailed = false; let terrainFailed = false;
let terrainTileCache = new Map(); let terrainTileCache = new Map();
let resolvedTileCache = new Map();
let terrainVertexSamples = null; let terrainVertexSamples = null;
let terrainOpacity = TERRAIN_CONFIG.opacity; let terrainOpacity = TERRAIN_CONFIG.opacity;
@@ -67,7 +68,9 @@ async function decodeTerrainTile(z, x, y) {
TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize,
TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize,
); );
return { data, width, height }; const tileData = { data, width, height };
resolvedTileCache.set(cacheKey, tileData);
return tileData;
})(); })();
terrainTileCache.set(cacheKey, tilePromise); terrainTileCache.set(cacheKey, tilePromise);
@@ -236,6 +239,7 @@ export function registerTerrainMesh(mesh) {
terrainFailed = false; terrainFailed = false;
terrainLoadPromise = null; terrainLoadPromise = null;
terrainTileCache = new Map(); terrainTileCache = new Map();
resolvedTileCache = new Map();
terrainOpacity = TERRAIN_CONFIG.opacity; terrainOpacity = TERRAIN_CONFIG.opacity;
if (terrainMesh?.material) { if (terrainMesh?.material) {
terrainMesh.material.opacity = terrainOpacity; terrainMesh.material.opacity = terrainOpacity;
@@ -287,9 +291,19 @@ export function clearTerrainData() {
terrainFailed = false; terrainFailed = false;
terrainVertexSamples = null; terrainVertexSamples = null;
terrainTileCache = new Map(); terrainTileCache = new Map();
resolvedTileCache = new Map();
terrainOpacity = TERRAIN_CONFIG.opacity; terrainOpacity = TERRAIN_CONFIG.opacity;
} }
export function sampleElevationAt(lat, lon) {
if (!terrainReady) return null;
const z = TERRAIN_CONFIG.baseZoom;
const { tileX, tileY, pixelX, pixelY } = latLonToTileSample(lat, lon, z, TERRAIN_CONFIG.tileSize);
const tile = resolvedTileCache.get(`${z}/${tileX}/${tileY}`);
if (!tile) return null;
return Math.max(0, decodeTerrariumHeight(tile, pixelX, pixelY));
}
export function setTerrainOpacity(nextOpacity) { export function setTerrainOpacity(nextOpacity) {
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1); terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
if (terrainMesh?.material) { if (terrainMesh?.material) {

View File

@@ -877,11 +877,12 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
sources.forEach((source) => { sources.forEach((source) => {
const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]";
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : ""; const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const failMark = failedSourceIds.has(source.id) ? " ⚠" : ""; const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option"); const option = document.createElement("option");
option.value = source.id; option.value = source.id;
option.textContent = `${source.name}${defaultMark}${failMark}`; option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option); fragment.appendChild(option);
}); });
@@ -913,8 +914,12 @@ function renderSource(source) {
const latestLabel = latestUpdatedAt const latestLabel = latestUpdatedAt
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}` ? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
: "尚未同步"; : "尚未同步";
const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : ""; const sourceOriginLabel = source?.collector_source
catalog.textContent = ` ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`; ? `采集源 ${source.collector_source}`
: source
? "内置源"
: "";
catalog.textContent = `${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`;
} }
if (notes) { if (notes) {
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。"; notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";

View File

@@ -155,7 +155,15 @@ export function updateZoomDisplay(zoomLevel, distance) {
const slider = getElement("zoom-slider"); const slider = getElement("zoom-slider");
const cameraDistanceEl = getElement("camera-distance"); const cameraDistanceEl = getElement("camera-distance");
if (zoomValueEl) zoomValueEl.textContent = percent + "%"; if (zoomValueEl) {
const tooltip = zoomValueEl.querySelector(".tooltip");
const label = `${percent}%`;
if (zoomValueEl.firstChild?.nodeType === Node.TEXT_NODE) {
zoomValueEl.firstChild.nodeValue = label;
} else {
zoomValueEl.insertBefore(document.createTextNode(label), tooltip || null);
}
}
if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%"; if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
if (slider) slider.value = zoomLevel; if (slider) slider.value = zoomLevel;
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km"; if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";

View File

@@ -132,13 +132,10 @@ function Scrollbar({
resizeObserver.observe(viewport) resizeObserver.observe(viewport)
resizeObserver.observe(trackX) resizeObserver.observe(trackX)
resizeObserver.observe(trackY) resizeObserver.observe(trackY)
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
mutationObserver.observe(viewport, { mutationObserver.observe(viewport, {
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true,
characterData: true,
}) })
viewport.addEventListener('scroll', scheduleUpdate, { passive: true }) viewport.addEventListener('scroll', scheduleUpdate, { passive: true })

View File

@@ -156,7 +156,6 @@ function ScrollbarOverlay({
scheduleUpdate() scheduleUpdate()
}) })
resizeObserver.observe(target) resizeObserver.observe(target)
Array.from(target.children).forEach((child) => resizeObserver?.observe(child))
scheduleUpdate() scheduleUpdate()
} }
@@ -169,7 +168,6 @@ function ScrollbarOverlay({
mutationObserver.observe(container, { mutationObserver.observe(container, {
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true,
}) })
window.addEventListener('resize', scheduleUpdate) window.addEventListener('resize', scheduleUpdate)

View File

@@ -1562,6 +1562,14 @@ body {
border-radius: 12px; border-radius: 12px;
} }
.data-source-drawer-collapse {
margin-bottom: 12px;
}
.data-source-drawer-collapse:last-of-type {
margin-bottom: 0;
}
.stat-card { .stat-card {
background: white; background: white;
padding: 24px; padding: 24px;

View File

@@ -19,6 +19,7 @@ import { useWebSocket } from '../../hooks/useWebSocket'
interface BuiltInDataSource { interface BuiltInDataSource {
id: number id: number
source: string
name: string name: string
module: string module: string
priority: string priority: string
@@ -180,6 +181,19 @@ interface CustomDataSource {
updated_at: string | null updated_at: string | null
} }
interface EditableDataSourceConfig {
id: number
name: string
description: string | null
source_type: string
endpoint: string
auth_type: string
auth_config: Record<string, any>
headers: Record<string, string>
config: Record<string, any>
is_active?: boolean
}
interface ViewDataSource { interface ViewDataSource {
id: number id: number
name: string name: string
@@ -205,6 +219,7 @@ function DataSources() {
const [drawerVisible, setDrawerVisible] = useState(false) const [drawerVisible, setDrawerVisible] = useState(false)
const [viewDrawerVisible, setViewDrawerVisible] = useState(false) const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null) const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
const [builtinEditingSource, setBuiltinEditingSource] = useState<BuiltInDataSource | null>(null)
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null) const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
const [recordCount, setRecordCount] = useState<number>(0) const [recordCount, setRecordCount] = useState<number>(0)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
@@ -219,6 +234,81 @@ function DataSources() {
const [customActionsCollapsed, customContainerRef] = useCollapsedActions() const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
const [form] = Form.useForm() const [form] = Form.useForm()
const headersMapToList = useCallback((headers?: Record<string, string> | null) => {
return Object.entries(headers || {})
.filter(([key, value]) => key && value !== undefined && value !== null && String(value).trim() !== '')
.map(([key, value]) => ({ key, value }))
}, [])
const headersListToMap = useCallback((headers?: Array<{ key?: string; value?: string }> | Record<string, string>) => {
if (!headers) return {}
if (!Array.isArray(headers)) return headers
return headers.reduce<Record<string, string>>((acc, item) => {
const key = item?.key?.trim()
const value = item?.value?.trim()
if (!key || value === undefined) return acc
acc[key] = value
return acc
}, {})
}, [])
const applyConfigToForm = useCallback((config?: Partial<EditableDataSourceConfig> | null) => {
form.setFieldsValue({
name: config?.name || '',
description: config?.description || '',
source_type: config?.source_type || 'http',
endpoint: config?.endpoint || '',
auth_type: config?.auth_type || 'none',
auth_config: config?.auth_config || {},
headers: headersMapToList(config?.headers || {}),
config: config?.config || { timeout: 30, retry: 3 },
})
}, [form, headersMapToList])
const loadConfigDetail = useCallback(async (configId: number) => {
const res = await axios.get<EditableDataSourceConfig>(`/api/v1/datasources/configs/${configId}`)
return res.data
}, [])
const createDefaultConfigDraft = useCallback((overrides?: Partial<EditableDataSourceConfig>) => ({
source_type: 'http',
auth_type: 'none',
headers: {},
config: { timeout: 30, retry: 3 },
...overrides,
}), [])
const getBuiltinOverrideDescription = useCallback(
(source?: Pick<BuiltInDataSource, 'name'> | null) =>
source ? `Built-in datasource override for ${source.name}` : undefined,
[],
)
const createFormPayload = useCallback((values: any) => ({
...values,
name: builtinEditingSource ? builtinEditingSource.source : values.name,
description:
values.description ||
getBuiltinOverrideDescription(builtinEditingSource),
source_type: builtinEditingSource ? 'http' : values.source_type,
headers: headersListToMap(values.headers),
}), [builtinEditingSource, getBuiltinOverrideDescription, headersListToMap])
const closeDrawerAfterLoadError = useCallback((
errorMessage: string,
options?: { clearBuiltin?: boolean; clearEditingConfig?: boolean },
) => {
messageApi.error(errorMessage)
setDrawerVisible(false)
if (options?.clearBuiltin) {
setBuiltinEditingSource(null)
}
if (options?.clearEditingConfig) {
setEditingConfig(null)
}
}, [messageApi])
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -711,9 +801,11 @@ function DataSources() {
const handleViewSource = async (source: BuiltInDataSource) => { const handleViewSource = async (source: BuiltInDataSource) => {
try { try {
const [res, statsRes] = await Promise.all([ const existingOverride = customSources.find((item) => item.name === source.source)
const [res, statsRes, overrideDetail] = await Promise.all([
axios.get(`/api/v1/datasources/${source.id}`), axios.get(`/api/v1/datasources/${source.id}`),
axios.get(`/api/v1/datasources/${source.id}/stats`) axios.get(`/api/v1/datasources/${source.id}/stats`),
existingOverride ? loadConfigDetail(existingOverride.id) : Promise.resolve(null),
]) ])
const data = res.data const data = res.data
setViewingSource({ setViewingSource({
@@ -721,10 +813,10 @@ function DataSources() {
name: data.name, name: data.name,
description: null, description: null,
source_type: data.collector_class, source_type: data.collector_class,
endpoint: data.endpoint || '', endpoint: overrideDetail?.endpoint || data.endpoint || '',
auth_type: 'none', auth_type: overrideDetail?.auth_type || 'none',
headers: {}, headers: overrideDetail?.headers || {},
config: {}, config: overrideDetail?.config || {},
collector_class: data.collector_class, collector_class: data.collector_class,
module: data.module, module: data.module,
priority: data.priority, priority: data.priority,
@@ -753,7 +845,8 @@ function DataSources() {
const values = await form.validateFields() const values = await form.validateFields()
setTesting(true) setTesting(true)
setTestResult(null) setTestResult(null)
const res = await axios.post('/api/v1/datasources/configs/test', values) const payload = createFormPayload(values)
const res = await axios.post('/api/v1/datasources/configs/test', payload)
setTestResult(res.data) setTestResult(res.data)
if (res.data.success) { if (res.data.success) {
messageApi.success('连接测试成功') messageApi.success('连接测试成功')
@@ -771,16 +864,18 @@ function DataSources() {
const handleSave = async () => { const handleSave = async () => {
try { try {
const values = await form.validateFields() const values = await form.validateFields()
const payload = createFormPayload(values)
if (editingConfig) { if (editingConfig) {
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values) await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, payload)
messageApi.success('配置已更新') messageApi.success('配置已更新')
} else { } else {
await axios.post('/api/v1/datasources/configs', values) await axios.post('/api/v1/datasources/configs', payload)
messageApi.success('配置已创建') messageApi.success('配置已创建')
} }
setDrawerVisible(false) setDrawerVisible(false)
form.resetFields() form.resetFields()
setEditingConfig(null) setEditingConfig(null)
setBuiltinEditingSource(null)
setTestResult(null) setTestResult(null)
fetchData() fetchData()
} catch (error: unknown) { } catch (error: unknown) {
@@ -800,6 +895,23 @@ function DataSources() {
} }
} }
const handleResetBuiltinOverride = async () => {
if (!builtinEditingSource || !editingConfig) return
try {
await axios.delete(`/api/v1/datasources/configs/${editingConfig.id}`)
messageApi.success(`已恢复 ${builtinEditingSource.name} 的默认配置`)
setDrawerVisible(false)
form.resetFields()
setEditingConfig(null)
setBuiltinEditingSource(null)
setTestResult(null)
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '恢复默认失败')
}
}
const handleToggleCustom = async (id: number, current: boolean) => { const handleToggleCustom = async (id: number, current: boolean) => {
try { try {
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current }) await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
@@ -811,24 +923,53 @@ function DataSources() {
} }
} }
const openDrawer = (config?: CustomDataSource) => { const openDrawer = async (config?: CustomDataSource) => {
setBuiltinEditingSource(null)
setEditingConfig(config || null) setEditingConfig(config || null)
if (config) {
form.setFieldsValue({
...config,
auth_config: {},
})
} else {
form.resetFields()
form.setFieldsValue({
source_type: 'http',
auth_type: 'none',
config: { timeout: 30, retry: 3 },
headers: {},
})
}
setDrawerVisible(true)
setTestResult(null) setTestResult(null)
setDrawerVisible(true)
if (config) {
try {
const detail = await loadConfigDetail(config.id)
applyConfigToForm(detail)
} catch {
closeDrawerAfterLoadError('获取配置详情失败', { clearEditingConfig: true })
}
return
}
form.resetFields()
applyConfigToForm(createDefaultConfigDraft())
}
const openBuiltinConfigDrawer = async (source: BuiltInDataSource) => {
setBuiltinEditingSource(source)
setTestResult(null)
setDrawerVisible(true)
const existingOverride = customSources.find((item) => item.name === source.source)
setEditingConfig(existingOverride || null)
if (existingOverride) {
try {
const detail = await loadConfigDetail(existingOverride.id)
applyConfigToForm(detail)
} catch {
closeDrawerAfterLoadError('获取内置数据源配置失败', {
clearBuiltin: true,
clearEditingConfig: true,
})
}
return
}
form.resetFields()
applyConfigToForm(createDefaultConfigDraft({
name: source.source,
description: getBuiltinOverrideDescription(source),
endpoint: source.endpoint || '',
}))
} }
const handleCopyLink = async (value: string, successText: string) => { const handleCopyLink = async (value: string, successText: string) => {
@@ -945,12 +1086,18 @@ function DataSources() {
title: '操作', title: '操作',
key: 'action', key: 'action',
fixed: 'right' as const, fixed: 'right' as const,
width: builtinActionsCollapsed ? 40 : 164, width: builtinActionsCollapsed ? 40 : 228,
onCell: () => actionCellProps, onCell: () => actionCellProps,
render: (_: unknown, record: BuiltInDataSource) => ( render: (_: unknown, record: BuiltInDataSource) => (
<TableActions <TableActions
collapsed={builtinActionsCollapsed} collapsed={builtinActionsCollapsed}
items={[ items={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => { void openBuiltinConfigDrawer(record) },
},
{ {
key: 'trigger', key: 'trigger',
label: '触发', label: '触发',
@@ -967,6 +1114,14 @@ function DataSources() {
}, },
]} ]}
> >
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => { void openBuiltinConfigDrawer(record) }}
>
</Button>
<Button <Button
type="link" type="link"
size="small" size="small"
@@ -1035,7 +1190,7 @@ function DataSources() {
key: 'edit', key: 'edit',
label: '编辑', label: '编辑',
icon: <EditOutlined />, icon: <EditOutlined />,
onClick: () => openDrawer(record), onClick: () => { void openDrawer(record) },
}, },
{ {
key: 'toggle', key: 'toggle',
@@ -1059,7 +1214,7 @@ function DataSources() {
}, },
]} ]}
> >
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}></Button> <Button type="link" size="small" icon={<EditOutlined />} onClick={() => { void openDrawer(record) }}></Button>
<Button <Button
type="link" type="link"
size="small" size="small"
@@ -1171,7 +1326,7 @@ function DataSources() {
children: ( children: (
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}> <div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
<div className="data-source-custom-toolbar"> <div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}> <Button type="primary" icon={<PlusOutlined />} onClick={() => { void openDrawer() }}>
</Button> </Button>
</div> </div>
@@ -1215,24 +1370,40 @@ function DataSources() {
</div> </div>
<Drawer <Drawer
title={editingConfig ? '编辑数据源' : '添加数据源'} title={builtinEditingSource ? `编辑内置数据源配置 · ${builtinEditingSource.name}` : editingConfig ? '编辑数据源' : '添加数据源'}
width={600} width={600}
open={drawerVisible} open={drawerVisible}
onClose={() => { onClose={() => {
setDrawerVisible(false) setDrawerVisible(false)
form.resetFields() form.resetFields()
setEditingConfig(null) setEditingConfig(null)
setBuiltinEditingSource(null)
setTestResult(null) setTestResult(null)
}} }}
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button <Space>
icon={<ExperimentOutlined />} {builtinEditingSource && editingConfig ? (
loading={testing} <Popconfirm
onClick={handleTest} title="恢复内置默认配置?"
> description="这会删除当前 override并重新使用代码内置默认配置。"
okText="恢复默认"
</Button> cancelText="取消"
onConfirm={handleResetBuiltinOverride}
>
<Button danger icon={<ClearOutlined />}>
</Button>
</Popconfirm>
) : null}
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleTest}
>
</Button>
</Space>
<Space> <Space>
<Button onClick={() => setDrawerVisible(false)}></Button> <Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleSave}> <Button type="primary" onClick={handleSave}>
@@ -1243,29 +1414,46 @@ function DataSources() {
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item {builtinEditingSource ? (
name="name" <Card size="small" bordered={false} style={{ marginBottom: 16, background: '#fafafa' }}>
label="名称" <Row gutter={[12, 12]}>
rules={[{ required: true, message: '请输入名称' }]} <Col span={12}>
> <div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input placeholder="My API Data Source" /> <Input value={builtinEditingSource.name} disabled />
</Form.Item> </Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>Collector Key</div>
<Input value={builtinEditingSource.source} disabled />
</Col>
</Row>
</Card>
) : (
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input placeholder="My API Data Source" />
</Form.Item>
)}
<Form.Item name="description" label="描述"> <Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="数据源描述" /> <Input.TextArea rows={2} placeholder="数据源描述" />
</Form.Item> </Form.Item>
<Form.Item {builtinEditingSource ? null : (
name="source_type" <Form.Item
label="数据源类型" name="source_type"
rules={[{ required: true, message: '请选择类型' }]} label="数据源类型"
> rules={[{ required: true, message: '请选择类型' }]}
<Select> >
<Select.Option value="http">HTTP API</Select.Option> <Select>
<Select.Option value="api">REST API</Select.Option> <Select.Option value="http">HTTP API</Select.Option>
<Select.Option value="database"></Select.Option> <Select.Option value="api">REST API</Select.Option>
</Select> <Select.Option value="database"></Select.Option>
</Form.Item> </Select>
</Form.Item>
)}
<Form.Item <Form.Item
name="endpoint" name="endpoint"
@@ -1276,6 +1464,7 @@ function DataSources() {
</Form.Item> </Form.Item>
<Collapse <Collapse
className="data-source-drawer-collapse"
items={[ items={[
{ {
key: 'auth', key: 'auth',
@@ -1311,6 +1500,12 @@ function DataSources() {
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key"> <Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
<Input placeholder="X-API-Key" /> <Input placeholder="X-API-Key" />
</Form.Item> </Form.Item>
<Form.Item name={['auth_config', 'in']} label="传递位置" initialValue="header">
<Select>
<Select.Option value="header">Header</Select.Option>
<Select.Option value="query">Query Param</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['auth_config', 'api_key']} label="API Key"> <Form.Item name={['auth_config', 'api_key']} label="API Key">
<Input.Password placeholder="API Key" /> <Input.Password placeholder="API Key" />
</Form.Item> </Form.Item>
@@ -1345,6 +1540,7 @@ function DataSources() {
/> />
<Collapse <Collapse
className="data-source-drawer-collapse"
items={[ items={[
{ {
key: 'headers', key: 'headers',
@@ -1376,6 +1572,7 @@ function DataSources() {
/> />
<Collapse <Collapse
className="data-source-drawer-collapse"
items={[ items={[
{ {
key: 'config', key: 'config',

View File

@@ -21,5 +21,5 @@
} }
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.tooling.json" }]
} }

View File

@@ -59,6 +59,7 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid" FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256" AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log" AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}" AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
@@ -371,6 +372,41 @@ log_success() {
log_line "done" "$GREEN" "$1" log_line "done" "$GREEN" "$1"
} }
get_recommended_lan_ipv4() {
local candidate
while read -r candidate; do
[ -z "$candidate" ] && continue
case "$candidate" in
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
continue
;;
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
printf "%s" "$candidate"
return 0
;;
esac
done <<EOF
$(hostname -I 2>/dev/null | tr ' ' '\n')
EOF
return 1
}
log_lan_access_notes() {
local frontend_port="$1"
local backend_port="$2"
local recommended_lan_ip=""
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}"
log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health"
else
log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}"
log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health"
fi
}
print_splash() { print_splash() {
clear_wait_spinner clear_wait_spinner
printf "%b" "$CYAN" printf "%b" "$CYAN"
@@ -766,8 +802,8 @@ ensure_frontend_deps() {
cd "$SCRIPT_DIR/frontend" cd "$SCRIPT_DIR/frontend"
set_wait_detail "检查 vite 是否已安装" set_wait_detail "检查 Vite Bun 入口是否已安装"
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})" log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install" set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
if ! run_with_retry \ if ! run_with_retry \
@@ -780,9 +816,9 @@ ensure_frontend_deps() {
fi fi
fi fi
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
close_wait_session_context "$owns_wait_session" close_wait_session_context "$owns_wait_session"
log_error "前端依赖安装失败,未找到 vite" log_error "前端依赖安装失败,未找到 Vite Bun 入口"
exit 1 exit 1
fi fi
@@ -1324,15 +1360,15 @@ cleanup_frontend_processes() {
rm -f "$FRONTEND_PID_FILE" rm -f "$FRONTEND_PID_FILE"
fi fi
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
} }
start_frontend_with_retry() { start_frontend_with_retry() {
local frontend_port="$1" local frontend_port="$1"
local frontend_port_requested="${2:-0}" local frontend_port_requested="${2:-0}"
local frontend_lan_enabled="${3:-0}"
local retry=1 local retry=1
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
@@ -1342,7 +1378,13 @@ start_frontend_with_retry() {
fi fi
cd "$SCRIPT_DIR/frontend" cd "$SCRIPT_DIR/frontend"
: > /tmp/planet_frontend.log : > /tmp/planet_frontend.log
nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 & local -a frontend_args
frontend_args=("$FRONTEND_VITE_ENTRY")
if [ "$frontend_lan_enabled" -eq 1 ]; then
frontend_args+=(--host 0.0.0.0)
fi
frontend_args+=(--port "$frontend_port" --strictPort)
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
FRONTEND_PID=$! FRONTEND_PID=$!
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE" printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
@@ -1368,6 +1410,7 @@ start_frontend_with_retry() {
start_frontend_service() { start_frontend_service() {
local frontend_port="$1" local frontend_port="$1"
local frontend_port_requested="$2" local frontend_port_requested="$2"
local frontend_lan_enabled="${3:-0}"
if [ "$frontend_port_requested" -eq 1 ]; then if [ "$frontend_port_requested" -eq 1 ]; then
kill_port_if_requested "$frontend_port" "前端" kill_port_if_requested "$frontend_port" "前端"
@@ -1379,8 +1422,12 @@ start_frontend_service() {
log_success "前端依赖已就绪" log_success "前端依赖已就绪"
start_wait_session "启动前端服务" start_wait_session "启动前端服务"
set_wait_detail "启动 Vite 开发服务器" if [ "$frontend_lan_enabled" -eq 1 ]; then
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then set_wait_detail "启动 Vite 开发服务器(局域网开放)"
else
set_wait_detail "启动 Vite 开发服务器"
fi
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
stop_wait_session stop_wait_session
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}" log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}"
tail -10 /tmp/planet_frontend.log tail -10 /tmp/planet_frontend.log
@@ -1399,6 +1446,7 @@ parse_service_args() {
FRONTEND_PORT_REQUESTED=0 FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0 AI_PROVIDER_REQUESTED=0
DATABASE_REQUESTED=0 DATABASE_REQUESTED=0
FRONTEND_LAN_ENABLED=0
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
@@ -1433,6 +1481,10 @@ parse_service_args() {
DATABASE_REQUESTED=1 DATABASE_REQUESTED=1
shift 1 shift 1
;; ;;
--allow-lan)
FRONTEND_LAN_ENABLED=1
shift 1
;;
*) *)
log_error "未知参数: $1" log_error "未知参数: $1"
exit 1 exit 1
@@ -1479,7 +1531,7 @@ stop_ai_provider_service() {
} }
stop_frontend_service() { stop_frontend_service() {
if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
cleanup_frontend_processes "$FRONTEND_PORT" cleanup_frontend_processes "$FRONTEND_PORT"
@@ -1607,13 +1659,16 @@ start() {
print_splash print_splash
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
log_success "启动完成" log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
} }
stop() { stop() {
@@ -1633,7 +1688,7 @@ restart() {
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop stop
sleep 1 sleep 1
start start "$@"
return 0 return 0
fi fi
@@ -1658,7 +1713,7 @@ restart() {
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
stop_frontend_service stop_frontend_service
sleep 1 sleep 1
start_frontend_service "$FRONTEND_PORT" 1 start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
fi fi
echo "" echo ""
@@ -1674,6 +1729,9 @@ restart() {
fi fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
log_note "前端: http://localhost:${FRONTEND_PORT}" log_note "前端: http://localhost:${FRONTEND_PORT}"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
fi fi
} }
@@ -1754,9 +1812,9 @@ case "$1" in
;; ;;
*) *)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}" log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>" log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
log_note "stop 停止服务" log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d" log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
log_note "createuser 交互创建用户" log_note "createuser 交互创建用户"
log_note "health 检查健康状态" log_note "health 检查健康状态"
log_note "log 查看日志" log_note "log 查看日志"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.31.1" version = "0.34.0"
description = "智能星球计划 - 态势感知系统" description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "0.31.1" version = "0.34.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },