Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b623a6385 | ||
|
|
0082cf3fbd |
2
TODO.md
2
TODO.md
@@ -22,3 +22,5 @@
|
||||
- [ ] 可选优化(非必做):将 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
|
||||
|
||||
@@ -420,6 +420,7 @@ async def list_datasources(
|
||||
collector_list.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
|
||||
@@ -30,6 +30,7 @@ COLLECTOR_URL_KEYS = {
|
||||
"iptoasn_prefix_geo": "iptoasn.combined_url",
|
||||
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
|
||||
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
|
||||
"news_live_streams": "news_live_streams.channels_url",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -86,3 +86,11 @@ opengeofeed:
|
||||
nro:
|
||||
# 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"
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -18,52 +24,537 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
data_type = "news_live_stream"
|
||||
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]]:
|
||||
request_url = (self._resolved_url or "").strip()
|
||||
if not request_url:
|
||||
return []
|
||||
|
||||
async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
datasource_config = await self._load_datasource_config()
|
||||
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,
|
||||
headers={
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
headers=request_headers,
|
||||
params=request_params or None,
|
||||
json=request_json,
|
||||
data=request_data,
|
||||
)
|
||||
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]]:
|
||||
if isinstance(response, dict):
|
||||
candidates = response.get("sources") or response.get("streams") or response.get("data") or []
|
||||
elif isinstance(response, list):
|
||||
candidates = response
|
||||
async def _load_datasource_config(self) -> DataSourceConfig | None:
|
||||
if not self._db_session:
|
||||
return None
|
||||
|
||||
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:
|
||||
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]] = []
|
||||
for index, item in enumerate(candidates):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}"
|
||||
name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip()
|
||||
stream_id = (
|
||||
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:
|
||||
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 = {
|
||||
"provider": item.get("provider") or item.get("publisher") or "Collector",
|
||||
"region": item.get("region") or item.get("country") or "Global",
|
||||
"language": item.get("language") or "und",
|
||||
"source_type": item.get("source_type") or "iframe",
|
||||
"embed_url": item.get("embed_url") or item.get("url") or "",
|
||||
"stream_url": item.get("stream_url") or "",
|
||||
"homepage_url": item.get("homepage_url") or item.get("source_url") or "",
|
||||
"poster_url": item.get("poster_url") or "",
|
||||
"provider": self._clean_text(item.get("provider") or item.get("publisher") or item.get("network")) or "Collector",
|
||||
"region": self._clean_text(item.get("region") or item.get("country") or item.get("market")) or "Global",
|
||||
"language": self._clean_text(item.get("language") or item.get("lang") or item.get("locale")) or "und",
|
||||
"source_type": source_type,
|
||||
"embed_url": embed_url,
|
||||
"stream_url": stream_url,
|
||||
"homepage_url": homepage_url,
|
||||
"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),
|
||||
"notes": item.get("notes") or item.get("description") or "",
|
||||
"is_enabled": item.get("is_enabled", True),
|
||||
"notes": self._clean_text(item.get("notes") or item.get("description") or item.get("summary")),
|
||||
"is_enabled": self._parse_enabled(item),
|
||||
}
|
||||
|
||||
normalized.append(
|
||||
@@ -72,7 +563,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
"name": name,
|
||||
"description": metadata["notes"],
|
||||
"metadata": metadata,
|
||||
"reference_date": item.get("reference_date", datetime.now(UTC).isoformat()),
|
||||
"reference_date": item.get("reference_date") or datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
|
||||
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
|
||||
|
||||
DEFAULT_TV_SETTINGS = {
|
||||
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
||||
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
||||
"auto_fallback": True,
|
||||
"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),
|
||||
"collector_source": record.source,
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,40 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [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
|
||||
@@ -48,8 +82,6 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
|
||||
## [0.31.2] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
@@ -86,6 +118,8 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式)
|
||||
- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
|
||||
@@ -99,8 +133,6 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
## [0.30.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- [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-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)
|
||||
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
|
||||
|
||||
|
||||
156
docs/plans/earth-news-source-configuration-and-collector-plan.md
Normal file
156
docs/plans/earth-news-source-configuration-and-collector-plan.md
Normal 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.
|
||||
@@ -95,3 +95,93 @@
|
||||
- 手工配置源
|
||||
- `news_live_streams` 采集器采集源
|
||||
- 当前默认兜底源为 `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`
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.32.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.34.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| 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 巡航适配层,并修复空白点击推进与连线动画回归 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.32.0",
|
||||
"version": "0.34.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
@@ -25,8 +25,8 @@
|
||||
"vite": "^5.0.10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "bun ./node_modules/vite/bin/vite.js",
|
||||
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "bun ./node_modules/vite/bin/vite.js preview"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +453,246 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -156,11 +156,11 @@
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
||||
<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="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索</span>
|
||||
</button>
|
||||
</div>
|
||||
<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="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-backdrop" class="earth-settings-backdrop"></div>
|
||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||
|
||||
3
frontend/public/earth/js/controls.js
vendored
3
frontend/public/earth/js/controls.js
vendored
@@ -32,6 +32,7 @@ import {
|
||||
ensureNewsPanelReady,
|
||||
updateNewsToggleUI,
|
||||
} from "./news.js";
|
||||
import { openSearchPanel } from "./search.js";
|
||||
import {
|
||||
setButtonTooltip,
|
||||
setLayerButtonState,
|
||||
@@ -1797,7 +1798,7 @@ function setupTerrainControls() {
|
||||
setupLayerPanel();
|
||||
|
||||
bindListener(searchBtn, "click", () => {
|
||||
showStatusMessage("搜索功能待开发", "info");
|
||||
openSearchPanel();
|
||||
});
|
||||
|
||||
bindListener(terrainBtn, "pointerenter", () => {
|
||||
|
||||
@@ -18,6 +18,18 @@ const CARD_CONFIG = {
|
||||
{ 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: {
|
||||
icon: '🛰️',
|
||||
title: '卫星详情',
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
toggleSatellites,
|
||||
getShowSatellites,
|
||||
getSatelliteLegendItems,
|
||||
getSatelliteData,
|
||||
setSelectedSatelliteLegend,
|
||||
clearSelectedSatelliteLegend,
|
||||
getSatelliteCount,
|
||||
@@ -162,6 +163,7 @@ import {
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
import { initSearchPanel } from "./search.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -609,6 +611,391 @@ function getBGPCollectorBriefHtml(marker) {
|
||||
return `<strong>${name}</strong><br>${count} 条事件`;
|
||||
}
|
||||
|
||||
function getSearchCardCoords() {
|
||||
return {
|
||||
x: Math.round(window.innerWidth * SEARCH_CARD_X_RATIO),
|
||||
y: Math.round(window.innerHeight * SEARCH_CARD_Y_RATIO),
|
||||
absolute: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchString(...parts) {
|
||||
return parts
|
||||
.flat()
|
||||
.filter((part) => part !== undefined && part !== null && part !== false)
|
||||
.map((part) => String(part).trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function computeSearchScore(query, ...parts) {
|
||||
const text = normalizeSearchString(...parts);
|
||||
if (!text) return -1;
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return -1;
|
||||
|
||||
if (text === normalizedQuery) return 240;
|
||||
if (text.startsWith(normalizedQuery)) return 180;
|
||||
if (text.includes(normalizedQuery)) return 120;
|
||||
|
||||
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) return -1;
|
||||
|
||||
let score = 0;
|
||||
for (const token of tokens) {
|
||||
if (text.startsWith(token)) {
|
||||
score += 60;
|
||||
continue;
|
||||
}
|
||||
if (text.includes(token)) {
|
||||
score += 36;
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function getCableFocusCoords(cable) {
|
||||
if (!cable?.userData?.localCenter) return null;
|
||||
return vector3ToLatLon(cable.userData.localCenter);
|
||||
}
|
||||
|
||||
function getLandingPointFocusCoords(point) {
|
||||
if (!point?.position) return null;
|
||||
return vector3ToLatLon(point.position);
|
||||
}
|
||||
|
||||
function getSatelliteFocusCoords(index) {
|
||||
const positions = getSatellitePositions();
|
||||
const vector = positions?.[index]?.current;
|
||||
if (!vector) return null;
|
||||
return vector3ToLatLon(vector);
|
||||
}
|
||||
|
||||
function getBGPFocusCoords(marker) {
|
||||
const lat = marker?.userData?.displayLatitude ?? marker?.userData?.latitude;
|
||||
const lon = marker?.userData?.displayLongitude ?? marker?.userData?.longitude;
|
||||
if (typeof lat !== "number" || typeof lon !== "number") return null;
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
|
||||
if (!coords || !camera) return;
|
||||
await focusEarthView(camera, {
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
zoom,
|
||||
duration: 950,
|
||||
suppressStatus: true,
|
||||
});
|
||||
}
|
||||
|
||||
function showLandingPointInfo(point, coords) {
|
||||
const cableNames = Array.isArray(point?.userData?.cableNames)
|
||||
? point.userData.cableNames
|
||||
: [];
|
||||
setLegendMode("cables");
|
||||
showInfoCard(
|
||||
"landing_point",
|
||||
{
|
||||
name: point?.userData?.name || "-",
|
||||
country: point?.userData?.country || "-",
|
||||
status: point?.userData?.status || "-",
|
||||
cable_count: cableNames.length,
|
||||
cables: cableNames.length > 0 ? cableNames.join(" / ") : "-",
|
||||
},
|
||||
coords,
|
||||
);
|
||||
}
|
||||
|
||||
async function focusSearchCable(cable) {
|
||||
await setCablesEnabled(true, {
|
||||
suppressStatus: true,
|
||||
suppressLoadingUi: true,
|
||||
});
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getCableFocusCoords(cable);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.14));
|
||||
}
|
||||
|
||||
const cableId = cable?.userData?.cableId;
|
||||
if (cableId !== undefined) {
|
||||
setCableState(cableId, CABLE_STATE.LOCKED);
|
||||
}
|
||||
lockedObject = cable;
|
||||
lockedObjectType = "cable";
|
||||
handleCableClick(cable);
|
||||
showCableInfo(cable, getSearchCardCoords());
|
||||
}
|
||||
|
||||
async function focusSearchLandingPoint(point) {
|
||||
await setCablesEnabled(true, {
|
||||
suppressStatus: true,
|
||||
suppressLoadingUi: true,
|
||||
});
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getLandingPointFocusCoords(point);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.22));
|
||||
}
|
||||
|
||||
const relatedCableNames = Array.isArray(point?.userData?.cableNames)
|
||||
? point.userData.cableNames
|
||||
: [];
|
||||
clearAllCableStates();
|
||||
getCableLines().forEach((cable) => {
|
||||
if (relatedCableNames.includes(cable.userData?.name)) {
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
|
||||
}
|
||||
});
|
||||
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
|
||||
showLandingPointInfo(point, getSearchCardCoords());
|
||||
showStatusMessage(`已定位登陆点:${point.userData?.name || "未知登陆点"}`, "info");
|
||||
}
|
||||
|
||||
async function focusSearchSatellite(index) {
|
||||
await setSatellitesEnabled(true, {
|
||||
suppressStatus: true,
|
||||
suppressLoadingUi: true,
|
||||
});
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const sat = selectSatellite(index);
|
||||
if (!sat?.properties) return;
|
||||
|
||||
const coords = getSatelliteFocusCoords(index);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.18));
|
||||
}
|
||||
|
||||
lockedObject = sat;
|
||||
lockedObjectType = "satellite";
|
||||
lockedSatellite = sat;
|
||||
lockedSatelliteIndex = index;
|
||||
setLockedSatelliteIndex(index);
|
||||
showPredictedOrbit(sat);
|
||||
const satPositions = getSatellitePositions();
|
||||
if (satPositions?.[index]) {
|
||||
setSatelliteRingState(index, "locked", satPositions[index].current);
|
||||
}
|
||||
showSatelliteInfo(sat.properties, getSearchCardCoords());
|
||||
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
|
||||
}
|
||||
|
||||
async function focusSearchBGPMarker(marker) {
|
||||
if (!getShowBGP()) {
|
||||
toggleBGP(true);
|
||||
}
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getBGPFocusCoords(marker);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
|
||||
}
|
||||
|
||||
const earth = getEarth();
|
||||
if (marker?.userData?.type === "bgp") {
|
||||
setBGPMarkerState(marker, "locked");
|
||||
lockedObject = marker;
|
||||
lockedObjectType = "bgp";
|
||||
showBGPEventOverlay(marker, earth);
|
||||
applyBGPEventSatelliteHighlights(marker);
|
||||
showBGPInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(`已定位 BGP 事件:${marker.userData?.collector || "未知观测站"}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (marker?.userData?.type === "bgp_collector") {
|
||||
setBGPMarkerState(marker, "locked");
|
||||
lockedObject = marker;
|
||||
lockedObjectType = "bgp_collector";
|
||||
showBGPCollectorCoverageOverlay(marker, earth);
|
||||
showBGPCollectorInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(`已定位观测站:${marker.userData?.collector || "未知观测站"}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEarthSearchResults(query) {
|
||||
const results = [];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return results;
|
||||
|
||||
getCableLines().forEach((cable) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
cable.userData?.name,
|
||||
cable.userData?.owner,
|
||||
cable.userData?.status,
|
||||
cable.userData?.length,
|
||||
"海缆 电缆 cable",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `cable:${cable.userData?.cableId || cable.uuid}`,
|
||||
kind: "cable",
|
||||
icon: "cable",
|
||||
typeLabel: "海缆",
|
||||
title: cable.userData?.name || "未知海缆",
|
||||
subtitle: [cable.userData?.owner, cable.userData?.status].filter(Boolean).join(" · ") || "海底光缆系统",
|
||||
score,
|
||||
entity: cable,
|
||||
});
|
||||
});
|
||||
|
||||
getLandingPoints().forEach((point, index) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
point.userData?.name,
|
||||
point.userData?.country,
|
||||
point.userData?.status,
|
||||
point.userData?.cableNames,
|
||||
"登陆点 landing point",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `landing:${point.uuid || index}`,
|
||||
kind: "landing_point",
|
||||
icon: "location_on",
|
||||
typeLabel: "登陆点",
|
||||
title: point.userData?.name || "未知登陆点",
|
||||
subtitle:
|
||||
[point.userData?.country, Array.isArray(point.userData?.cableNames) ? `${point.userData.cableNames.length} 条海缆` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "海缆登陆点",
|
||||
score,
|
||||
entity: point,
|
||||
});
|
||||
});
|
||||
|
||||
getSatelliteData().forEach((satellite, index) => {
|
||||
const props = satellite?.properties;
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
props?.name,
|
||||
props?.norad_cat_id,
|
||||
props?.inclination,
|
||||
"卫星 satellite norad",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `sat:${props?.norad_cat_id || index}`,
|
||||
kind: "satellite",
|
||||
icon: "satellite_alt",
|
||||
typeLabel: "卫星",
|
||||
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
|
||||
subtitle: props?.norad_cat_id ? `NORAD ${props.norad_cat_id}` : "在轨卫星",
|
||||
score,
|
||||
entity: { index },
|
||||
});
|
||||
});
|
||||
|
||||
getBGPAnomalyMarkers().forEach((marker) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
marker.userData?.collector,
|
||||
marker.userData?.prefix,
|
||||
marker.userData?.city,
|
||||
marker.userData?.country,
|
||||
marker.userData?.anomaly_type,
|
||||
marker.userData?.incident_type,
|
||||
marker.userData?.origin_asn,
|
||||
marker.userData?.new_origin_asn,
|
||||
"bgp 事件 anomaly prefix asn",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `bgp:${marker.userData?.id || marker.uuid}`,
|
||||
kind: "bgp",
|
||||
icon: "hub",
|
||||
typeLabel: "BGP事件",
|
||||
title:
|
||||
formatBGPAnomalyTypeLabel(
|
||||
marker.userData?.incident_type || marker.userData?.anomaly_type,
|
||||
) || "BGP 事件",
|
||||
subtitle:
|
||||
[
|
||||
marker.userData?.collector,
|
||||
marker.userData?.prefix,
|
||||
formatBGPLocation(marker.userData?.city, marker.userData?.country),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "BGP 异常事件",
|
||||
score,
|
||||
entity: marker,
|
||||
});
|
||||
});
|
||||
|
||||
getBGPCollectorMarkers().forEach((marker) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
marker.userData?.collector,
|
||||
marker.userData?.city,
|
||||
marker.userData?.country,
|
||||
marker.userData?.status,
|
||||
"bgp collector 观测站",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `collector:${marker.userData?.collector || marker.uuid}`,
|
||||
kind: "bgp_collector",
|
||||
icon: "travel_explore",
|
||||
typeLabel: "观测站",
|
||||
title: marker.userData?.collector || "未知观测站",
|
||||
subtitle:
|
||||
[
|
||||
formatBGPLocation(marker.userData?.city, marker.userData?.country),
|
||||
formatBGPCollectorStatus(marker.userData?.status || "online"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "BGP 观测站",
|
||||
score,
|
||||
entity: marker,
|
||||
});
|
||||
});
|
||||
|
||||
return results
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return left.title.localeCompare(right.title, "zh-CN");
|
||||
})
|
||||
.slice(0, SEARCH_RESULT_LIMIT);
|
||||
}
|
||||
|
||||
async function handleSearchSelection(result) {
|
||||
if (!result) return;
|
||||
|
||||
if (result.kind === "cable") {
|
||||
await focusSearchCable(result.entity);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "landing_point") {
|
||||
await focusSearchLandingPoint(result.entity);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "satellite") {
|
||||
await focusSearchSatellite(result.entity.index);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "bgp" || result.kind === "bgp_collector") {
|
||||
await focusSearchBGPMarker(result.entity);
|
||||
}
|
||||
}
|
||||
|
||||
function getBGPStatusText(bgpResult) {
|
||||
if (bgpResult.totalCount > 0) {
|
||||
return `${bgpResult.totalCount} 起活跃事件`;
|
||||
@@ -1188,6 +1575,10 @@ export function init() {
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
initSearchPanel({
|
||||
resolveResults: resolveEarthSearchResults,
|
||||
onSelectResult: handleSearchSelection,
|
||||
});
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
@@ -1474,6 +1865,9 @@ async function loadData() {
|
||||
}
|
||||
|
||||
const POSITION_UPDATE_FORCE_DELTA = 250;
|
||||
const SEARCH_RESULT_LIMIT = 28;
|
||||
const SEARCH_CARD_X_RATIO = 0.68;
|
||||
const SEARCH_CARD_Y_RATIO = 0.18;
|
||||
|
||||
export async function reloadData() {
|
||||
await loadData();
|
||||
|
||||
227
frontend/public/earth/js/search.js
Normal file
227
frontend/public/earth/js/search.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -877,11 +877,12 @@ function renderSourceOptions() {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]";
|
||||
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||||
const option = document.createElement("option");
|
||||
option.value = source.id;
|
||||
option.textContent = `${source.name}${defaultMark}${failMark}`;
|
||||
option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -913,8 +914,12 @@ function renderSource(source) {
|
||||
const latestLabel = latestUpdatedAt
|
||||
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
|
||||
: "尚未同步";
|
||||
const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : "";
|
||||
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`;
|
||||
const sourceOriginLabel = source?.collector_source
|
||||
? `采集源 ${source.collector_source}`
|
||||
: source
|
||||
? "内置源"
|
||||
: "";
|
||||
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`;
|
||||
}
|
||||
if (notes) {
|
||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||
|
||||
@@ -1562,6 +1562,14 @@ body {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.data-source-drawer-collapse {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.data-source-drawer-collapse:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
|
||||
interface BuiltInDataSource {
|
||||
id: number
|
||||
source: string
|
||||
name: string
|
||||
module: string
|
||||
priority: string
|
||||
@@ -180,6 +181,19 @@ interface CustomDataSource {
|
||||
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 {
|
||||
id: number
|
||||
name: string
|
||||
@@ -205,6 +219,7 @@ function DataSources() {
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
|
||||
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
|
||||
const [builtinEditingSource, setBuiltinEditingSource] = useState<BuiltInDataSource | null>(null)
|
||||
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
|
||||
const [recordCount, setRecordCount] = useState<number>(0)
|
||||
const [testing, setTesting] = useState(false)
|
||||
@@ -219,6 +234,81 @@ function DataSources() {
|
||||
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
|
||||
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 () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -711,9 +801,11 @@ function DataSources() {
|
||||
|
||||
const handleViewSource = async (source: BuiltInDataSource) => {
|
||||
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}/stats`)
|
||||
axios.get(`/api/v1/datasources/${source.id}/stats`),
|
||||
existingOverride ? loadConfigDetail(existingOverride.id) : Promise.resolve(null),
|
||||
])
|
||||
const data = res.data
|
||||
setViewingSource({
|
||||
@@ -721,10 +813,10 @@ function DataSources() {
|
||||
name: data.name,
|
||||
description: null,
|
||||
source_type: data.collector_class,
|
||||
endpoint: data.endpoint || '',
|
||||
auth_type: 'none',
|
||||
headers: {},
|
||||
config: {},
|
||||
endpoint: overrideDetail?.endpoint || data.endpoint || '',
|
||||
auth_type: overrideDetail?.auth_type || 'none',
|
||||
headers: overrideDetail?.headers || {},
|
||||
config: overrideDetail?.config || {},
|
||||
collector_class: data.collector_class,
|
||||
module: data.module,
|
||||
priority: data.priority,
|
||||
@@ -753,7 +845,8 @@ function DataSources() {
|
||||
const values = await form.validateFields()
|
||||
setTesting(true)
|
||||
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)
|
||||
if (res.data.success) {
|
||||
messageApi.success('连接测试成功')
|
||||
@@ -771,16 +864,18 @@ function DataSources() {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const payload = createFormPayload(values)
|
||||
if (editingConfig) {
|
||||
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
|
||||
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, payload)
|
||||
messageApi.success('配置已更新')
|
||||
} else {
|
||||
await axios.post('/api/v1/datasources/configs', values)
|
||||
await axios.post('/api/v1/datasources/configs', payload)
|
||||
messageApi.success('配置已创建')
|
||||
}
|
||||
setDrawerVisible(false)
|
||||
form.resetFields()
|
||||
setEditingConfig(null)
|
||||
setBuiltinEditingSource(null)
|
||||
setTestResult(null)
|
||||
fetchData()
|
||||
} 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) => {
|
||||
try {
|
||||
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)
|
||||
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)
|
||||
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) => {
|
||||
@@ -945,12 +1086,18 @@ function DataSources() {
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right' as const,
|
||||
width: builtinActionsCollapsed ? 40 : 164,
|
||||
width: builtinActionsCollapsed ? 40 : 228,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: BuiltInDataSource) => (
|
||||
<TableActions
|
||||
collapsed={builtinActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => { void openBuiltinConfigDrawer(record) },
|
||||
},
|
||||
{
|
||||
key: 'trigger',
|
||||
label: '触发',
|
||||
@@ -967,6 +1114,14 @@ function DataSources() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => { void openBuiltinConfigDrawer(record) }}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -1035,7 +1190,7 @@ function DataSources() {
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => openDrawer(record),
|
||||
onClick: () => { void openDrawer(record) },
|
||||
},
|
||||
{
|
||||
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
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -1171,7 +1326,7 @@ function DataSources() {
|
||||
children: (
|
||||
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
|
||||
<div className="data-source-custom-toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { void openDrawer() }}>
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1215,24 +1370,40 @@ function DataSources() {
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={editingConfig ? '编辑数据源' : '添加数据源'}
|
||||
title={builtinEditingSource ? `编辑内置数据源配置 · ${builtinEditingSource.name}` : editingConfig ? '编辑数据源' : '添加数据源'}
|
||||
width={600}
|
||||
open={drawerVisible}
|
||||
onClose={() => {
|
||||
setDrawerVisible(false)
|
||||
form.resetFields()
|
||||
setEditingConfig(null)
|
||||
setBuiltinEditingSource(null)
|
||||
setTestResult(null)
|
||||
}}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
icon={<ExperimentOutlined />}
|
||||
loading={testing}
|
||||
onClick={handleTest}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
<Space>
|
||||
{builtinEditingSource && editingConfig ? (
|
||||
<Popconfirm
|
||||
title="恢复内置默认配置?"
|
||||
description="这会删除当前 override,并重新使用代码内置默认配置。"
|
||||
okText="恢复默认"
|
||||
cancelText="取消"
|
||||
onConfirm={handleResetBuiltinOverride}
|
||||
>
|
||||
<Button danger icon={<ClearOutlined />}>
|
||||
恢复默认
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Button
|
||||
icon={<ExperimentOutlined />}
|
||||
loading={testing}
|
||||
onClick={handleTest}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
@@ -1243,29 +1414,46 @@ function DataSources() {
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input placeholder="My API Data Source" />
|
||||
</Form.Item>
|
||||
{builtinEditingSource ? (
|
||||
<Card size="small" bordered={false} style={{ marginBottom: 16, background: '#fafafa' }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>内置数据源</div>
|
||||
<Input value={builtinEditingSource.name} disabled />
|
||||
</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="描述">
|
||||
<Input.TextArea rows={2} placeholder="数据源描述" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="source_type"
|
||||
label="数据源类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="http">HTTP API</Select.Option>
|
||||
<Select.Option value="api">REST API</Select.Option>
|
||||
<Select.Option value="database">数据库</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{builtinEditingSource ? null : (
|
||||
<Form.Item
|
||||
name="source_type"
|
||||
label="数据源类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="http">HTTP API</Select.Option>
|
||||
<Select.Option value="api">REST API</Select.Option>
|
||||
<Select.Option value="database">数据库</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="endpoint"
|
||||
@@ -1276,6 +1464,7 @@ function DataSources() {
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
className="data-source-drawer-collapse"
|
||||
items={[
|
||||
{
|
||||
key: 'auth',
|
||||
@@ -1311,6 +1500,12 @@ function DataSources() {
|
||||
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
|
||||
<Input placeholder="X-API-Key" />
|
||||
</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">
|
||||
<Input.Password placeholder="API Key" />
|
||||
</Form.Item>
|
||||
@@ -1345,6 +1540,7 @@ function DataSources() {
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
className="data-source-drawer-collapse"
|
||||
items={[
|
||||
{
|
||||
key: 'headers',
|
||||
@@ -1376,6 +1572,7 @@ function DataSources() {
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
className="data-source-drawer-collapse"
|
||||
items={[
|
||||
{
|
||||
key: 'config',
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"references": [{ "path": "./tsconfig.tooling.json" }]
|
||||
}
|
||||
|
||||
92
planet.sh
92
planet.sh
@@ -59,6 +59,7 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
|
||||
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
||||
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
||||
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_LOG_FILE="/tmp/planet_aiprovider_build.log"
|
||||
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
|
||||
@@ -371,6 +372,41 @@ log_success() {
|
||||
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() {
|
||||
clear_wait_spinner
|
||||
printf "%b" "$CYAN"
|
||||
@@ -766,8 +802,8 @@ ensure_frontend_deps() {
|
||||
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
|
||||
set_wait_detail "检查 vite 是否已安装"
|
||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
||||
set_wait_detail "检查 Vite Bun 入口是否已安装"
|
||||
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
||||
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
||||
if ! run_with_retry \
|
||||
@@ -780,9 +816,9 @@ ensure_frontend_deps() {
|
||||
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"
|
||||
log_error "前端依赖安装失败,未找到 vite"
|
||||
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1324,15 +1360,15 @@ cleanup_frontend_processes() {
|
||||
rm -f "$FRONTEND_PID_FILE"
|
||||
fi
|
||||
|
||||
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||||
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true
|
||||
pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true
|
||||
pkill -f "bun run dev" 2>/dev/null || true
|
||||
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||||
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||||
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
start_frontend_with_retry() {
|
||||
local frontend_port="$1"
|
||||
local frontend_port_requested="${2:-0}"
|
||||
local frontend_lan_enabled="${3:-0}"
|
||||
local retry=1
|
||||
|
||||
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
|
||||
@@ -1342,7 +1378,13 @@ start_frontend_with_retry() {
|
||||
fi
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
: > /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=$!
|
||||
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
|
||||
|
||||
@@ -1368,6 +1410,7 @@ start_frontend_with_retry() {
|
||||
start_frontend_service() {
|
||||
local frontend_port="$1"
|
||||
local frontend_port_requested="$2"
|
||||
local frontend_lan_enabled="${3:-0}"
|
||||
|
||||
if [ "$frontend_port_requested" -eq 1 ]; then
|
||||
kill_port_if_requested "$frontend_port" "前端"
|
||||
@@ -1379,8 +1422,12 @@ start_frontend_service() {
|
||||
log_success "前端依赖已就绪"
|
||||
|
||||
start_wait_session "启动前端服务"
|
||||
set_wait_detail "启动 Vite 开发服务器"
|
||||
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then
|
||||
if [ "$frontend_lan_enabled" -eq 1 ]; 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
|
||||
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||||
tail -10 /tmp/planet_frontend.log
|
||||
@@ -1399,6 +1446,7 @@ parse_service_args() {
|
||||
FRONTEND_PORT_REQUESTED=0
|
||||
AI_PROVIDER_REQUESTED=0
|
||||
DATABASE_REQUESTED=0
|
||||
FRONTEND_LAN_ENABLED=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -1433,6 +1481,10 @@ parse_service_args() {
|
||||
DATABASE_REQUESTED=1
|
||||
shift 1
|
||||
;;
|
||||
--allow-lan)
|
||||
FRONTEND_LAN_ENABLED=1
|
||||
shift 1
|
||||
;;
|
||||
*)
|
||||
log_error "未知参数: $1"
|
||||
exit 1
|
||||
@@ -1479,7 +1531,7 @@ stop_ai_provider_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"
|
||||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||||
cleanup_frontend_processes "$FRONTEND_PORT"
|
||||
@@ -1607,13 +1659,16 @@ start() {
|
||||
print_splash
|
||||
|
||||
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_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
|
||||
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
|
||||
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
|
||||
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
|
||||
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||||
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||||
fi
|
||||
}
|
||||
|
||||
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
|
||||
stop
|
||||
sleep 1
|
||||
start
|
||||
start "$@"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -1658,7 +1713,7 @@ restart() {
|
||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||
stop_frontend_service
|
||||
sleep 1
|
||||
start_frontend_service "$FRONTEND_PORT" 1
|
||||
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -1674,6 +1729,9 @@ restart() {
|
||||
fi
|
||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||||
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||||
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1754,9 +1812,9 @@ case "$1" in
|
||||
;;
|
||||
*)
|
||||
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 "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
|
||||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
|
||||
log_note "createuser 交互创建用户"
|
||||
log_note "health 检查健康状态"
|
||||
log_note "log 查看日志"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.32.0"
|
||||
version = "0.34.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user