Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea99a9529 | ||
|
|
f9c1334365 | ||
|
|
5f47ec1659 | ||
|
|
229be0bced | ||
|
|
50a417ca83 | ||
|
|
e9464a9833 | ||
|
|
86807f6af6 | ||
|
|
8b8f7138c0 | ||
|
|
d5f3784ffb | ||
|
|
195a8bf71c | ||
|
|
987c378f99 |
3
TODO.md
3
TODO.md
@@ -22,8 +22,11 @@
|
||||
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
||||
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
||||
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
||||
- [ ] 为 Planet / Earth 补一个可用的日志查看系统:先明确前后端/AI Provider/采集任务的日志入口、最近日志聚合、筛选与 tail 能力,再决定是先做脚本级统一入口还是控制台内置日志面板
|
||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
||||
- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机
|
||||
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
||||
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
|
||||
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
|
||||
|
||||
@@ -4,12 +4,15 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import ROOT_DIR
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.persistent_logs import record_audit_log, record_system_log
|
||||
from app.services.system_control import (
|
||||
build_task_id,
|
||||
clear_active_task_id,
|
||||
@@ -23,6 +26,15 @@ from app.services.system_control import (
|
||||
set_active_task_id,
|
||||
upsert_task_state,
|
||||
)
|
||||
from app.services.system_logs import (
|
||||
DEFAULT_LOG_LINE_LIMIT,
|
||||
MAX_LOG_LINE_LIMIT,
|
||||
SUPPORTED_LOG_LEVELS,
|
||||
append_buffer_log,
|
||||
list_log_sources,
|
||||
normalize_log_level,
|
||||
read_log_snapshot,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -47,6 +59,59 @@ class RestartTaskLogsResponse(BaseModel):
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class SystemLogSourceSummary(BaseModel):
|
||||
source_id: str
|
||||
name: str
|
||||
kind: str
|
||||
location: str
|
||||
description: str
|
||||
category: str
|
||||
status: str
|
||||
|
||||
|
||||
class SystemLogSourcesResponse(BaseModel):
|
||||
items: list[SystemLogSourceSummary]
|
||||
|
||||
|
||||
class SystemLogDailyMarker(BaseModel):
|
||||
date_token: str
|
||||
total: int
|
||||
dominant_level: str
|
||||
|
||||
|
||||
class SystemLogSnapshotResponse(BaseModel):
|
||||
source_id: str
|
||||
name: str
|
||||
kind: str
|
||||
location: str
|
||||
description: str
|
||||
category: str
|
||||
status: str
|
||||
level: str
|
||||
selected_levels: list[str] = []
|
||||
search_query: str = ""
|
||||
available_levels: list[str]
|
||||
daily_markers: list[SystemLogDailyMarker] = []
|
||||
line_limit: int
|
||||
line_count: int
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class EarthClientLogEventCreate(BaseModel):
|
||||
level: str = "error"
|
||||
message: str
|
||||
category: str | None = None
|
||||
url: str | None = None
|
||||
module: str | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class EarthClientLogEventResponse(BaseModel):
|
||||
accepted: bool
|
||||
source_id: str
|
||||
level: str
|
||||
|
||||
|
||||
def ensure_super_admin(current_user: User) -> None:
|
||||
if not require_super_admin(current_user.role):
|
||||
raise HTTPException(
|
||||
@@ -55,9 +120,22 @@ def ensure_super_admin(current_user: User) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
|
||||
if raw_value in {None, ""}:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(raw_value, "%Y-%m-%d").date().isoformat()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{field_name} must be in YYYY-MM-DD format",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
||||
async def create_restart_task(
|
||||
payload: RestartTaskCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
@@ -133,11 +211,31 @@ async def create_restart_task(
|
||||
requested_by=requested_by,
|
||||
)
|
||||
clear_active_task_id(task_id)
|
||||
await record_audit_log(
|
||||
action="system.restart_task.requested",
|
||||
actor_id=current_user.id,
|
||||
actor_name=current_user.username,
|
||||
target_type="restart_task",
|
||||
target_id=task_id,
|
||||
result="failed",
|
||||
ip=request.client.host if request.client else None,
|
||||
details={"action": payload.action, "message": task_state["message"]},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=task_state["message"],
|
||||
) from exc
|
||||
|
||||
await record_audit_log(
|
||||
action="system.restart_task.requested",
|
||||
actor_id=current_user.id,
|
||||
actor_name=current_user.username,
|
||||
target_type="restart_task",
|
||||
target_id=task_id,
|
||||
result="accepted",
|
||||
ip=request.client.host if request.client else None,
|
||||
details={"action": payload.action},
|
||||
)
|
||||
return task_state
|
||||
|
||||
|
||||
@@ -165,3 +263,92 @@ async def get_restart_task_logs(
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
||||
return {"task_id": task_id, "lines": get_task_logs(task_id)}
|
||||
|
||||
|
||||
@router.get("/logs/sources", response_model=SystemLogSourcesResponse)
|
||||
async def get_system_log_sources(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
return {"items": list_log_sources()}
|
||||
|
||||
|
||||
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
|
||||
async def get_system_log_snapshot(
|
||||
source_id: str,
|
||||
limit: int = DEFAULT_LOG_LINE_LIMIT,
|
||||
level: str = "all",
|
||||
levels: str | None = Query(None, description="Comma-separated log levels"),
|
||||
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
|
||||
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
||||
search: str | None = Query(None, description="Case-insensitive substring search"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
|
||||
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}",
|
||||
)
|
||||
if str(level).strip().lower() not in SUPPORTED_LOG_LEVELS and normalize_log_level(level) == "all" and str(level).strip().lower() not in {"", "all"}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
||||
if levels:
|
||||
for raw_level in str(levels).split(","):
|
||||
normalized_level = str(raw_level).strip().lower()
|
||||
if not normalized_level:
|
||||
continue
|
||||
if normalized_level not in SUPPORTED_LOG_LEVELS and normalize_log_level(normalized_level) == "all":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
||||
normalized_start_date = validate_log_date(start_date, "start_date")
|
||||
normalized_end_date = validate_log_date(end_date, "end_date")
|
||||
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
|
||||
|
||||
snapshot = read_log_snapshot(
|
||||
source_id,
|
||||
limit,
|
||||
level=level,
|
||||
levels=levels,
|
||||
start_date=normalized_start_date,
|
||||
end_date=normalized_end_date,
|
||||
search=search,
|
||||
)
|
||||
if snapshot is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found")
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.post("/logs/earth-client", response_model=EarthClientLogEventResponse)
|
||||
async def ingest_earth_client_log(
|
||||
payload: EarthClientLogEventCreate,
|
||||
request: Request,
|
||||
):
|
||||
normalized_level = normalize_log_level(payload.level)
|
||||
append_buffer_log(
|
||||
"earth-client",
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
context={
|
||||
"category": payload.category or "",
|
||||
"url": payload.url or "",
|
||||
"module": payload.module or "",
|
||||
"detail": payload.detail or "",
|
||||
},
|
||||
)
|
||||
await record_system_log(
|
||||
source="earth-client",
|
||||
service="earth",
|
||||
module=payload.module or "earth-client",
|
||||
event="earth.client.runtime_log",
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
category=payload.category or "client-runtime",
|
||||
context={
|
||||
"url": payload.url or "",
|
||||
"detail": payload.detail or "",
|
||||
"module": payload.module or "",
|
||||
"client_ip": request.client.host if request.client else "",
|
||||
},
|
||||
)
|
||||
return {"accepted": True, "source_id": "earth-client", "level": normalized_level}
|
||||
|
||||
@@ -23,8 +23,11 @@ from app.models.collected_data import CollectedData
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
from app.services.persistent_logs import record_system_log
|
||||
from app.core.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__, service="api")
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
@@ -181,6 +184,12 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
|
||||
mean_motion=metadata.get("mean_motion"),
|
||||
)
|
||||
|
||||
constellation_group = _normalize_satellite_constellation_group(
|
||||
metadata.get("constellation_group"),
|
||||
record.name,
|
||||
)
|
||||
footprint_policy = _get_satellite_footprint_policy(constellation_group)
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
@@ -190,6 +199,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
|
||||
"id": record.id,
|
||||
"norad_cat_id": norad_id,
|
||||
"name": record.name,
|
||||
"constellation_group": constellation_group,
|
||||
"footprint_policy": footprint_policy,
|
||||
"international_designator": metadata.get("international_designator"),
|
||||
"epoch": metadata.get("epoch"),
|
||||
"inclination": metadata.get("inclination"),
|
||||
@@ -210,6 +221,31 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _normalize_satellite_constellation_group(
|
||||
raw_group: Any,
|
||||
name: Optional[str],
|
||||
) -> Optional[str]:
|
||||
normalized_group = str(raw_group or "").strip().lower()
|
||||
if normalized_group:
|
||||
return normalized_group
|
||||
|
||||
normalized_name = str(name or "").strip().upper()
|
||||
if normalized_name.startswith("STARLINK"):
|
||||
return "starlink"
|
||||
if normalized_name.startswith("IRIDIUM"):
|
||||
return "iridium-next"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_satellite_footprint_policy(constellation_group: Optional[str]) -> str:
|
||||
if constellation_group == "starlink":
|
||||
return "starlink_ground_footprint"
|
||||
if constellation_group == "iridium-next":
|
||||
return "iridium_coverage_ring"
|
||||
return "none"
|
||||
|
||||
|
||||
def _current_collected_data_stmt(source: str):
|
||||
return (
|
||||
select(CollectedData)
|
||||
@@ -990,6 +1026,21 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception_event(
|
||||
"Failed to build cables GeoJSON response",
|
||||
event="visualization.cables.load_failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
await record_system_log(
|
||||
source="backend",
|
||||
service="api",
|
||||
module=__name__,
|
||||
event="visualization.cables.load_failed",
|
||||
level="error",
|
||||
message="Failed to build cables GeoJSON response",
|
||||
category="visualization",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@@ -1026,6 +1077,21 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception_event(
|
||||
"Failed to build landing points GeoJSON response",
|
||||
event="visualization.landing_points.load_failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
await record_system_log(
|
||||
source="backend",
|
||||
service="api",
|
||||
module=__name__,
|
||||
event="visualization.landing_points.load_failed",
|
||||
level="error",
|
||||
message="Failed to build landing points GeoJSON response",
|
||||
category="visualization",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -10,10 +9,11 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__, service="api")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -22,11 +22,18 @@ async def authenticate_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
if payload.get("type") != "access":
|
||||
logger.warning(f"WebSocket auth failed: wrong token type")
|
||||
logger.warning_event(
|
||||
"WebSocket auth failed: wrong token type",
|
||||
event="auth.websocket.invalid_token_type",
|
||||
)
|
||||
return None
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.warning(f"WebSocket auth failed: {e}")
|
||||
logger.warning_event(
|
||||
"WebSocket auth failed",
|
||||
event="auth.websocket.decode_failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -36,10 +43,17 @@ async def websocket_endpoint(
|
||||
token: str = Query(...),
|
||||
):
|
||||
"""WebSocket endpoint for real-time data"""
|
||||
logger.info(f"WebSocket connection attempt with token: {token[:20]}...")
|
||||
logger.info_event(
|
||||
"WebSocket connection attempt",
|
||||
event="auth.websocket.connection_attempt",
|
||||
context={"token_preview": f"{token[:8]}..."},
|
||||
)
|
||||
payload = await authenticate_token(token)
|
||||
if payload is None:
|
||||
logger.warning("WebSocket authentication failed, closing connection")
|
||||
logger.warning_event(
|
||||
"WebSocket authentication failed, closing connection",
|
||||
event="auth.websocket.connection_rejected",
|
||||
)
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Redis caching service"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Optional, Any
|
||||
|
||||
import redis
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Lazy Redis client initialization
|
||||
@@ -47,7 +47,7 @@ class CacheService:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache get error: {e}")
|
||||
logger.warning_event("Cache get error", event="cache.get.failed", context={"error": str(e)})
|
||||
return None
|
||||
|
||||
def set(
|
||||
@@ -61,7 +61,7 @@ class CacheService:
|
||||
serialized = json.dumps(value, default=str)
|
||||
return self.client.setex(key, expire_seconds, serialized)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache set error: {e}")
|
||||
logger.warning_event("Cache set error", event="cache.set.failed", context={"error": str(e)})
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
@@ -69,7 +69,7 @@ class CacheService:
|
||||
try:
|
||||
return self.client.delete(key) > 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache delete error: {e}")
|
||||
logger.warning_event("Cache delete error", event="cache.delete.failed", context={"error": str(e)})
|
||||
return False
|
||||
|
||||
def delete_pattern(self, pattern: str) -> int:
|
||||
@@ -80,7 +80,7 @@ class CacheService:
|
||||
return self.client.delete(*keys)
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache delete_pattern error: {e}")
|
||||
logger.warning_event("Cache delete_pattern error", event="cache.delete_pattern.failed", context={"error": str(e)})
|
||||
return 0
|
||||
|
||||
def get_or_set(
|
||||
|
||||
161
backend/app/core/logging.py
Normal file
161
backend/app/core/logging.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.core.request_context import get_request_id
|
||||
|
||||
DEFAULT_SERVICE = "backend"
|
||||
DEFAULT_EVENT = "app.log"
|
||||
DEFAULT_LOG_LEVEL = os.getenv("PLANET_LOG_LEVEL", "INFO").upper()
|
||||
REDACTED = "[REDACTED]"
|
||||
SENSITIVE_FIELD_NAMES = {
|
||||
"access_token",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
SENSITIVE_TEXT_PATTERNS = (
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*)(.+)"),
|
||||
re.compile(r"(?i)(bearer\s+)([A-Za-z0-9._\-]+)"),
|
||||
re.compile(r"(?i)(token\s*[:=]\s*)(.+)"),
|
||||
re.compile(r"(?i)(password\s*[:=]\s*)(.+)"),
|
||||
re.compile(r"(?i)(cookie\s*[:=]\s*)(.+)"),
|
||||
)
|
||||
|
||||
|
||||
def sanitize_log_value(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): (REDACTED if str(key).lower() in SENSITIVE_FIELD_NAMES else sanitize_log_value(item))
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [sanitize_log_value(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
sanitized = value
|
||||
for pattern in SENSITIVE_TEXT_PATTERNS:
|
||||
sanitized = pattern.sub(lambda match: f"{match.group(1)}{REDACTED}", sanitized)
|
||||
return sanitized
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_context(context: Any) -> dict[str, Any]:
|
||||
if context is None:
|
||||
return {}
|
||||
if isinstance(context, Mapping):
|
||||
sanitized = sanitize_log_value(context)
|
||||
return {str(key): value for key, value in sanitized.items()}
|
||||
return {"value": sanitize_log_value(context)}
|
||||
|
||||
|
||||
class PlanetContextFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = getattr(record, "request_id", None) or get_request_id() or "-"
|
||||
record.service = getattr(record, "service", None) or DEFAULT_SERVICE
|
||||
record.event = getattr(record, "event", None) or DEFAULT_EVENT
|
||||
record.context = _normalize_context(getattr(record, "context", None))
|
||||
record.message = sanitize_log_value(record.getMessage())
|
||||
return True
|
||||
|
||||
|
||||
class PlanetFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
timestamp = self.formatTime(record, self.datefmt)
|
||||
level = record.levelname
|
||||
service = getattr(record, "service", DEFAULT_SERVICE)
|
||||
module_name = record.name
|
||||
event = getattr(record, "event", DEFAULT_EVENT)
|
||||
request_id = getattr(record, "request_id", "-")
|
||||
message = sanitize_log_value(record.getMessage())
|
||||
context = _normalize_context(getattr(record, "context", None))
|
||||
context_suffix = ""
|
||||
if context:
|
||||
context_suffix = f" context={json.dumps(context, ensure_ascii=False, sort_keys=True)}"
|
||||
rendered = (
|
||||
f"{timestamp} {level} service={service} module={module_name} "
|
||||
f"event={event} request_id={request_id} message={message}{context_suffix}"
|
||||
)
|
||||
if record.exc_info:
|
||||
rendered = f"{rendered}\n{self.formatException(record.exc_info)}"
|
||||
return rendered
|
||||
|
||||
|
||||
class PlanetLoggerAdapter(logging.LoggerAdapter):
|
||||
def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
|
||||
extra = dict(self.extra)
|
||||
extra.update(kwargs.get("extra", {}))
|
||||
if "context" in extra:
|
||||
extra["context"] = _normalize_context(extra.get("context"))
|
||||
kwargs["extra"] = extra
|
||||
return sanitize_log_value(msg), kwargs
|
||||
|
||||
def log_event(
|
||||
self,
|
||||
level: int,
|
||||
message: str,
|
||||
*,
|
||||
event: str,
|
||||
context: Mapping[str, Any] | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
self.log(level, message, extra={"event": event, "context": context or {}, **extra})
|
||||
|
||||
def debug_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.DEBUG, message, event=event, context=context, **extra)
|
||||
|
||||
def info_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.INFO, message, event=event, context=context, **extra)
|
||||
|
||||
def warning_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.WARNING, message, event=event, context=context, **extra)
|
||||
|
||||
def error_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.ERROR, message, event=event, context=context, **extra)
|
||||
|
||||
def exception_event(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
event: str,
|
||||
context: Mapping[str, Any] | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
self.error(message, exc_info=True, extra={"event": event, "context": context or {}, **extra})
|
||||
|
||||
|
||||
def get_logger(name: str, *, service: str = DEFAULT_SERVICE) -> PlanetLoggerAdapter:
|
||||
return PlanetLoggerAdapter(logging.getLogger(name), {"service": service})
|
||||
|
||||
|
||||
def configure_logging(level: str | None = None) -> None:
|
||||
root_logger = logging.getLogger()
|
||||
if getattr(configure_logging, "_configured", False):
|
||||
if level:
|
||||
root_logger.setLevel(level.upper())
|
||||
return
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
handler.addFilter(PlanetContextFilter())
|
||||
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel((level or DEFAULT_LOG_LEVEL).upper())
|
||||
|
||||
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
target_logger = logging.getLogger(logger_name)
|
||||
target_logger.handlers.clear()
|
||||
target_logger.propagate = True
|
||||
|
||||
logging.captureWarnings(True)
|
||||
configure_logging._configured = True
|
||||
14
backend/app/core/request_context.py
Normal file
14
backend/app/core/request_context.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
request_id_context: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||
|
||||
|
||||
def set_request_id(request_id: str | None) -> None:
|
||||
request_id_context.set(request_id)
|
||||
|
||||
|
||||
def get_request_id() -> str | None:
|
||||
return request_id_context.get()
|
||||
@@ -5,10 +5,22 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sess
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DB_POOL_CONFIG = {
|
||||
"pool_pre_ping": True,
|
||||
"pool_recycle": 1800,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_timeout": 30,
|
||||
}
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG if hasattr(settings, "DEBUG") else False,
|
||||
**DB_POOL_CONFIG,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
@@ -97,6 +109,19 @@ async def init_db():
|
||||
import app.models.system_setting # noqa: F401
|
||||
import app.models.playground_session # noqa: F401
|
||||
import app.models.playground_message # noqa: F401
|
||||
import app.models.system_log # noqa: F401
|
||||
|
||||
logger.warning_event(
|
||||
"Database pool settings active",
|
||||
event="database.pool.initialized",
|
||||
context={
|
||||
"pool_pre_ping": DB_POOL_CONFIG["pool_pre_ping"],
|
||||
"pool_recycle": DB_POOL_CONFIG["pool_recycle"],
|
||||
"pool_size": DB_POOL_CONFIG["pool_size"],
|
||||
"max_overflow": DB_POOL_CONFIG["max_overflow"],
|
||||
"pool_timeout": DB_POOL_CONFIG["pool_timeout"],
|
||||
},
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -7,6 +8,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.api.main import api_router
|
||||
from app.api.v1 import websocket
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging
|
||||
from app.core.request_context import set_request_id
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.db.session import init_db
|
||||
from app.services.scheduler import (
|
||||
@@ -17,6 +20,9 @@ from app.services.scheduler import (
|
||||
)
|
||||
|
||||
|
||||
configure_logging()
|
||||
|
||||
|
||||
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
if request.url.path.startswith("/ws") and request.method == "GET":
|
||||
@@ -28,6 +34,18 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid4().hex
|
||||
set_request_id(request_id)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
set_request_id(None)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
@@ -58,6 +76,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
app.add_middleware(WebSocketCORSMiddleware)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.bgp_observation import BGPObservation
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.playground_session import PlaygroundSession
|
||||
from app.models.playground_message import PlaygroundMessage
|
||||
from app.models.system_log import SystemLog, AuditLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -26,4 +27,6 @@ __all__ = [
|
||||
"BGPAnomaly",
|
||||
"BGPIncident",
|
||||
"BGPObservation",
|
||||
"SystemLog",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
40
backend/app/models/system_log.py
Normal file
40
backend/app/models/system_log.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import JSON, Column, DateTime, Integer, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class SystemLog(Base):
|
||||
__tablename__ = "system_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
source = Column(String(50), nullable=False, index=True)
|
||||
service = Column(String(50), nullable=True)
|
||||
module = Column(String(120), nullable=True)
|
||||
event = Column(String(160), nullable=True, index=True)
|
||||
level = Column(String(20), nullable=False, index=True)
|
||||
message = Column(Text, nullable=False)
|
||||
request_id = Column(String(64), nullable=True, index=True)
|
||||
trace_id = Column(String(64), nullable=True)
|
||||
user_id = Column(Integer, nullable=True, index=True)
|
||||
category = Column(String(80), nullable=True, index=True)
|
||||
context = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
actor_id = Column(Integer, nullable=True, index=True)
|
||||
actor_name = Column(String(255), nullable=True)
|
||||
action = Column(String(120), nullable=False, index=True)
|
||||
target_type = Column(String(80), nullable=True)
|
||||
target_id = Column(String(120), nullable=True)
|
||||
result = Column(String(40), nullable=True, index=True)
|
||||
request_id = Column(String(64), nullable=True, index=True)
|
||||
ip = Column(String(64), nullable=True)
|
||||
details = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -46,6 +46,9 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
item["_celestrak_group"] = group
|
||||
all_satellites.extend(data)
|
||||
print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'")
|
||||
except Exception as e:
|
||||
@@ -78,6 +81,7 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
"name": item.get("OBJECT_NAME", "Unknown"),
|
||||
"reference_date": item.get("EPOCH", ""),
|
||||
"metadata": {
|
||||
"constellation_group": item.get("_celestrak_group"),
|
||||
"norad_cat_id": item.get("NORAD_CAT_ID"),
|
||||
"international_designator": item.get("OBJECT_ID"),
|
||||
"epoch": item.get("EPOCH"),
|
||||
|
||||
@@ -30,6 +30,14 @@ class RegionProfile:
|
||||
accent: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionAnchor:
|
||||
region: str
|
||||
label: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsFeedSource:
|
||||
id: str
|
||||
@@ -95,6 +103,39 @@ REGION_PROFILES: dict[str, RegionProfile] = {
|
||||
),
|
||||
}
|
||||
|
||||
REGION_ANCHORS: dict[str, RegionAnchor] = {
|
||||
"americas": RegionAnchor(
|
||||
region="americas",
|
||||
label="美洲",
|
||||
latitude=37.0902,
|
||||
longitude=-95.7129,
|
||||
),
|
||||
"europe": RegionAnchor(
|
||||
region="europe",
|
||||
label="欧洲",
|
||||
latitude=50.1109,
|
||||
longitude=8.6821,
|
||||
),
|
||||
"middle-east-africa": RegionAnchor(
|
||||
region="middle-east-africa",
|
||||
label="中东与非洲",
|
||||
latitude=25.2048,
|
||||
longitude=55.2708,
|
||||
),
|
||||
"asia-pacific": RegionAnchor(
|
||||
region="asia-pacific",
|
||||
label="亚太",
|
||||
latitude=1.3521,
|
||||
longitude=103.8198,
|
||||
),
|
||||
"global": RegionAnchor(
|
||||
region="global",
|
||||
label="全球",
|
||||
latitude=20.0,
|
||||
longitude=0.0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
||||
return (
|
||||
@@ -213,6 +254,10 @@ def get_region_profile(region: str) -> RegionProfile:
|
||||
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
||||
|
||||
|
||||
def get_region_anchor(region: str) -> RegionAnchor:
|
||||
return REGION_ANCHORS.get(region, REGION_ANCHORS["global"])
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
@@ -342,6 +387,7 @@ def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
anchor = get_region_anchor(item.feed_region)
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
@@ -352,6 +398,10 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An
|
||||
"region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
"location_label": anchor.label,
|
||||
"location_inferred": True,
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
|
||||
86
backend/app/services/persistent_logs.py
Normal file
86
backend/app/services/persistent_logs.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.logging import get_logger, sanitize_log_value
|
||||
from app.core.request_context import get_request_id
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.system_log import AuditLog, SystemLog
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def record_system_log(
|
||||
*,
|
||||
source: str,
|
||||
level: str,
|
||||
message: str,
|
||||
service: str | None = None,
|
||||
module: str | None = None,
|
||||
event: str | None = None,
|
||||
request_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
user_id: int | None = None,
|
||||
category: str | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
session.add(
|
||||
SystemLog(
|
||||
source=source,
|
||||
service=service,
|
||||
module=module,
|
||||
event=event,
|
||||
level=level.lower(),
|
||||
message=str(sanitize_log_value(message)),
|
||||
request_id=request_id or get_request_id(),
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
category=category,
|
||||
context=sanitize_log_value(context or {}),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception_event(
|
||||
"Failed to persist system log",
|
||||
event="system_log.persist.failed",
|
||||
context={"event_name": event, "source": source},
|
||||
)
|
||||
|
||||
|
||||
async def record_audit_log(
|
||||
*,
|
||||
action: str,
|
||||
actor_id: int | None = None,
|
||||
actor_name: str | None = None,
|
||||
target_type: str | None = None,
|
||||
target_id: str | None = None,
|
||||
result: str | None = None,
|
||||
request_id: str | None = None,
|
||||
ip: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
session.add(
|
||||
AuditLog(
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
result=result,
|
||||
request_id=request_id or get_request_id(),
|
||||
ip=ip,
|
||||
details=sanitize_log_value(details or {}),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception_event(
|
||||
"Failed to persist audit log",
|
||||
event="audit_log.persist.failed",
|
||||
context={"action": action},
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Task Scheduler for running collection jobs."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -9,13 +8,14 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.session import async_session_factory
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.task import CollectionTask
|
||||
from app.services.collectors.registry import collector_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
|
||||
@@ -54,7 +54,11 @@ async def _update_next_run_at(datasource: DataSource, session) -> None:
|
||||
async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
||||
collector = collector_registry.get(datasource.source)
|
||||
if not collector:
|
||||
logger.warning("Collector not found for datasource %s", datasource.source)
|
||||
logger.warning_event(
|
||||
"Collector not found for datasource",
|
||||
event="collector.schedule.collector_missing",
|
||||
context={"collector_name": datasource.source},
|
||||
)
|
||||
return
|
||||
|
||||
collector_registry.set_active(datasource.source, datasource.is_active)
|
||||
@@ -72,13 +76,17 @@ async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
||||
replace_existing=True,
|
||||
kwargs={"collector_name": datasource.source},
|
||||
)
|
||||
logger.info(
|
||||
"Scheduled collector: %s (every %sm)",
|
||||
datasource.source,
|
||||
datasource.frequency_minutes,
|
||||
logger.info_event(
|
||||
"Scheduled collector",
|
||||
event="collector.schedule.updated",
|
||||
context={"collector_name": datasource.source, "frequency_minutes": datasource.frequency_minutes},
|
||||
)
|
||||
else:
|
||||
logger.info("Collector disabled: %s", datasource.source)
|
||||
logger.info_event(
|
||||
"Collector disabled",
|
||||
event="collector.schedule.disabled",
|
||||
context={"collector_name": datasource.source},
|
||||
)
|
||||
|
||||
await _update_next_run_at(datasource, session)
|
||||
|
||||
@@ -87,18 +95,30 @@ async def run_collector_task(collector_name: str):
|
||||
"""Run a single collector task."""
|
||||
collector = collector_registry.get(collector_name)
|
||||
if not collector:
|
||||
logger.error("Collector not found: %s", collector_name)
|
||||
logger.error_event(
|
||||
"Collector not found",
|
||||
event="collector.run.collector_missing",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(DataSource).where(DataSource.source == collector_name))
|
||||
datasource = result.scalar_one_or_none()
|
||||
if not datasource:
|
||||
logger.error("Datasource not found for collector: %s", collector_name)
|
||||
logger.error_event(
|
||||
"Datasource not found for collector",
|
||||
event="collector.run.datasource_missing",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return
|
||||
|
||||
if not datasource.is_active:
|
||||
logger.info("Skipping disabled collector: %s", collector_name)
|
||||
logger.info_event(
|
||||
"Skipping disabled collector",
|
||||
event="collector.run.skipped_disabled",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return
|
||||
|
||||
running_result = await db.execute(
|
||||
@@ -122,10 +142,10 @@ async def run_collector_task(collector_name: str):
|
||||
and (now - started_at) > timedelta(minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES)
|
||||
)
|
||||
if not is_stale:
|
||||
logger.warning(
|
||||
"Skipping collector %s trigger because task %s is already running",
|
||||
collector_name,
|
||||
existing_running.id,
|
||||
logger.warning_event(
|
||||
"Skipping collector trigger because task is already running",
|
||||
event="collector.run.skipped_already_running",
|
||||
context={"collector_name": collector_name, "task_id": existing_running.id},
|
||||
)
|
||||
return
|
||||
|
||||
@@ -143,31 +163,47 @@ async def run_collector_task(collector_name: str):
|
||||
else stale_reason
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(
|
||||
"Marked stale running task %s as failed before rerun of %s",
|
||||
existing_running.id,
|
||||
collector_name,
|
||||
logger.warning_event(
|
||||
"Marked stale running task as failed before rerun",
|
||||
event="collector.run.stale_task_failed",
|
||||
context={"collector_name": collector_name, "task_id": existing_running.id},
|
||||
)
|
||||
|
||||
try:
|
||||
collector._datasource_id = datasource.id
|
||||
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
|
||||
logger.info_event(
|
||||
"Running collector",
|
||||
event="collector.run.started",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||
)
|
||||
task_result = await collector.run(db)
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = task_result.get("status")
|
||||
await _update_next_run_at(datasource, db)
|
||||
logger.info("Collector %s completed: %s", collector_name, task_result)
|
||||
logger.info_event(
|
||||
"Collector completed",
|
||||
event="collector.run.completed",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "cancelled"
|
||||
await db.commit()
|
||||
logger.warning("Collector %s cancelled by operator", collector_name)
|
||||
logger.warning_event(
|
||||
"Collector cancelled by operator",
|
||||
event="collector.run.cancelled",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "failed"
|
||||
await db.commit()
|
||||
logger.exception("Collector %s failed: %s", collector_name, exc)
|
||||
logger.exception_event(
|
||||
"Collector failed",
|
||||
event="collector.run.failed",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id, "error": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
|
||||
@@ -194,7 +230,11 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
|
||||
|
||||
if stale_tasks:
|
||||
await db.commit()
|
||||
logger.warning("Cleaned up %s stale running collection task(s)", len(stale_tasks))
|
||||
logger.warning_event(
|
||||
"Cleaned up stale running collection tasks",
|
||||
event="collector.cleanup.stale_tasks_cleaned",
|
||||
context={"count": len(stale_tasks)},
|
||||
)
|
||||
|
||||
return len(stale_tasks)
|
||||
|
||||
@@ -203,14 +243,14 @@ def start_scheduler() -> None:
|
||||
"""Start the scheduler."""
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
logger.info_event("Scheduler started", event="scheduler.started")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
"""Stop the scheduler."""
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("Scheduler stopped")
|
||||
logger.info_event("Scheduler stopped", event="scheduler.stopped")
|
||||
|
||||
|
||||
async def sync_scheduler_with_datasources() -> None:
|
||||
@@ -271,12 +311,20 @@ def run_collector_now(collector_name: str) -> bool:
|
||||
"""Run a collector immediately (not scheduled)."""
|
||||
collector = collector_registry.get(collector_name)
|
||||
if not collector:
|
||||
logger.error("Collector not found: %s", collector_name)
|
||||
logger.error_event(
|
||||
"Collector not found",
|
||||
event="collector.trigger.collector_missing",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return False
|
||||
|
||||
existing_task = get_running_collector_task(collector_name)
|
||||
if existing_task is not None and not existing_task.done():
|
||||
logger.warning("Collector %s is already running in-memory; skipping duplicate trigger", collector_name)
|
||||
logger.warning_event(
|
||||
"Collector is already running in-memory; skipping duplicate trigger",
|
||||
event="collector.trigger.skipped_already_running",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -289,10 +337,18 @@ def run_collector_now(collector_name: str) -> bool:
|
||||
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||
|
||||
task.add_done_callback(_cleanup_task)
|
||||
logger.info("Triggered collector: %s", collector_name)
|
||||
logger.info_event(
|
||||
"Triggered collector",
|
||||
event="collector.trigger.started",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
||||
logger.error_event(
|
||||
"Failed to trigger collector",
|
||||
event="collector.trigger.failed",
|
||||
context={"collector_name": collector_name, "error": str(exc)},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
532
backend/app/services/system_logs.py
Normal file
532
backend/app/services/system_logs.py
Normal file
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.security import redis_client
|
||||
|
||||
DEFAULT_LOG_LINE_LIMIT = 200
|
||||
MAX_LOG_LINE_LIMIT = 1000
|
||||
BUFFER_LOG_LIMIT = 1000
|
||||
BUFFER_LOG_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
LOG_BUFFER_KEY_PREFIX = "planet:system_logs"
|
||||
|
||||
LOG_LEVEL_ERROR = "error"
|
||||
LOG_LEVEL_WARNING = "warning"
|
||||
LOG_LEVEL_INFO = "info"
|
||||
LOG_LEVEL_DEBUG = "debug"
|
||||
LOG_LEVEL_ALL = "all"
|
||||
|
||||
SUPPORTED_LOG_LEVELS = {
|
||||
LOG_LEVEL_ALL,
|
||||
LOG_LEVEL_ERROR,
|
||||
LOG_LEVEL_WARNING,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
}
|
||||
|
||||
LOG_LEVEL_ALIASES = {
|
||||
"warn": LOG_LEVEL_WARNING,
|
||||
"warning": LOG_LEVEL_WARNING,
|
||||
"err": LOG_LEVEL_ERROR,
|
||||
"error": LOG_LEVEL_ERROR,
|
||||
"info": LOG_LEVEL_INFO,
|
||||
"information": LOG_LEVEL_INFO,
|
||||
"debug": LOG_LEVEL_DEBUG,
|
||||
"trace": LOG_LEVEL_DEBUG,
|
||||
"critical": LOG_LEVEL_ERROR,
|
||||
"fatal": LOG_LEVEL_ERROR,
|
||||
}
|
||||
|
||||
TIMESTAMP_FORMATS = (
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
|
||||
LEVEL_PATTERNS = (
|
||||
("CRITICAL", LOG_LEVEL_ERROR),
|
||||
("FATAL", LOG_LEVEL_ERROR),
|
||||
("ERROR", LOG_LEVEL_ERROR),
|
||||
("WARNING", LOG_LEVEL_WARNING),
|
||||
("WARN", LOG_LEVEL_WARNING),
|
||||
("INFO", LOG_LEVEL_INFO),
|
||||
("DEBUG", LOG_LEVEL_DEBUG),
|
||||
("TRACE", LOG_LEVEL_DEBUG),
|
||||
)
|
||||
|
||||
LEADING_LEVEL_PATTERN = re.compile(
|
||||
r"^\s*(?:\[[^\]]+\]\s*)?(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b[:\s-]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
EMBEDDED_LEVEL_PATTERN = re.compile(
|
||||
r"\b(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogSource:
|
||||
source_id: str
|
||||
name: str
|
||||
kind: str
|
||||
location: str
|
||||
description: str
|
||||
category: str
|
||||
status: str = "ok"
|
||||
buffer_key: str | None = None
|
||||
container_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuredLogEntry:
|
||||
timestamp: datetime | None
|
||||
level: str | None
|
||||
display_line: str
|
||||
raw_line: str
|
||||
search_text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyLogMarker:
|
||||
date_token: str
|
||||
total: int
|
||||
dominant_level: str
|
||||
|
||||
|
||||
LOG_SOURCES: dict[str, LogSource] = {
|
||||
"backend": LogSource(
|
||||
source_id="backend",
|
||||
name="后端服务",
|
||||
kind="file",
|
||||
location="/tmp/planet_backend.log",
|
||||
description="FastAPI 后端、调度器和采集任务共享日志。",
|
||||
category="service",
|
||||
),
|
||||
"frontend": LogSource(
|
||||
source_id="frontend",
|
||||
name="前端开发服务",
|
||||
kind="file",
|
||||
location="/tmp/planet_frontend.log",
|
||||
description="控制台与 Earth 前端开发服务输出。",
|
||||
category="service",
|
||||
),
|
||||
"ai-provider": LogSource(
|
||||
source_id="ai-provider",
|
||||
name="AI Provider",
|
||||
kind="docker",
|
||||
location="docker://planet_aiprovider",
|
||||
description="AI Provider 容器实时输出日志。",
|
||||
category="service",
|
||||
container_name="planet_aiprovider",
|
||||
),
|
||||
"earth-client": LogSource(
|
||||
source_id="earth-client",
|
||||
name="Earth 浏览器端",
|
||||
kind="buffer",
|
||||
location="redis://planet:system_logs:earth-client",
|
||||
description="Earth 浏览器端上报的运行时错误与关键业务日志。",
|
||||
category="client",
|
||||
buffer_key=f"{LOG_BUFFER_KEY_PREFIX}:earth-client",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_log_level(level: str | None) -> str:
|
||||
if level is None:
|
||||
return LOG_LEVEL_ALL
|
||||
normalized = str(level).strip().lower()
|
||||
if normalized in {"", LOG_LEVEL_ALL}:
|
||||
return LOG_LEVEL_ALL
|
||||
return LOG_LEVEL_ALIASES.get(normalized, LOG_LEVEL_ALL)
|
||||
|
||||
|
||||
def normalize_log_levels(level: str | None = None, levels: str | None = None) -> tuple[str, ...]:
|
||||
normalized_levels: list[str] = []
|
||||
if levels:
|
||||
for item in str(levels).split(","):
|
||||
normalized = normalize_log_level(item)
|
||||
if normalized != LOG_LEVEL_ALL and normalized not in normalized_levels:
|
||||
normalized_levels.append(normalized)
|
||||
normalized_level = normalize_log_level(level)
|
||||
if normalized_level != LOG_LEVEL_ALL and normalized_level not in normalized_levels:
|
||||
normalized_levels.append(normalized_level)
|
||||
return tuple(normalized_levels)
|
||||
|
||||
|
||||
def get_source_status(source: LogSource) -> str:
|
||||
if source.kind == "file":
|
||||
path = Path(source.location)
|
||||
if not path.exists():
|
||||
return "missing"
|
||||
return "ok" if path.stat().st_size > 0 else "empty"
|
||||
if source.kind == "docker":
|
||||
return "ok" if shutil.which("docker") else "docker_unavailable"
|
||||
if source.kind == "buffer":
|
||||
if not source.buffer_key:
|
||||
return "source_unavailable"
|
||||
try:
|
||||
return "ok" if redis_client.llen(source.buffer_key) > 0 else "empty"
|
||||
except Exception:
|
||||
return "source_unavailable"
|
||||
return "source_unavailable"
|
||||
|
||||
|
||||
def list_log_sources() -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
for source in LOG_SOURCES.values():
|
||||
items.append(
|
||||
{
|
||||
"source_id": source.source_id,
|
||||
"name": source.name,
|
||||
"kind": source.kind,
|
||||
"location": source.location,
|
||||
"description": source.description,
|
||||
"category": source.category,
|
||||
"status": get_source_status(source),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def get_buffer_log_key(source_id: str) -> str:
|
||||
return f"{LOG_BUFFER_KEY_PREFIX}:{source_id}"
|
||||
|
||||
|
||||
def append_buffer_log(
|
||||
source_id: str,
|
||||
*,
|
||||
level: str,
|
||||
message: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"timestamp": datetime.now(tz=UTC).isoformat(),
|
||||
"level": normalize_log_level(level),
|
||||
"message": message,
|
||||
"context": context or {},
|
||||
}
|
||||
buffer_key = get_buffer_log_key(source_id)
|
||||
redis_client.rpush(buffer_key, json.dumps(payload, ensure_ascii=False))
|
||||
redis_client.ltrim(buffer_key, -BUFFER_LOG_LIMIT, -1)
|
||||
redis_client.expire(buffer_key, BUFFER_LOG_TTL_SECONDS)
|
||||
|
||||
|
||||
def parse_timestamp(raw_value: str | None) -> datetime | None:
|
||||
if not raw_value:
|
||||
return None
|
||||
candidate = str(raw_value).strip()
|
||||
if not candidate:
|
||||
return None
|
||||
candidate = candidate.replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
pass
|
||||
for fmt in TIMESTAMP_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(candidate, fmt).replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def parse_prefixed_timestamp(line: str) -> tuple[datetime | None, str]:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return None, ""
|
||||
for prefix_length in (35, 32, 29, 26, 23, 19):
|
||||
if len(stripped) < prefix_length:
|
||||
continue
|
||||
prefix = stripped[:prefix_length]
|
||||
timestamp = parse_timestamp(prefix)
|
||||
if timestamp is not None:
|
||||
return timestamp, stripped[prefix_length:].lstrip()
|
||||
first_token = stripped.split(maxsplit=1)[0]
|
||||
timestamp = parse_timestamp(first_token)
|
||||
if timestamp is not None:
|
||||
remainder = stripped[len(first_token):].lstrip()
|
||||
return timestamp, remainder
|
||||
return None, stripped
|
||||
|
||||
|
||||
def infer_log_level_from_text(text: str, *, allow_embedded: bool = True) -> str | None:
|
||||
leading_match = LEADING_LEVEL_PATTERN.match(text)
|
||||
if leading_match:
|
||||
return normalize_log_level(leading_match.group(1))
|
||||
|
||||
if allow_embedded:
|
||||
embedded_match = EMBEDDED_LEVEL_PATTERN.search(text)
|
||||
if embedded_match:
|
||||
return normalize_log_level(embedded_match.group(1))
|
||||
upper_text = text.upper()
|
||||
for pattern, normalized in LEVEL_PATTERNS:
|
||||
if f"{pattern}:" in upper_text or f"{pattern} " in upper_text:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def build_display_line(timestamp: datetime | None, level: str | None, message: str) -> str:
|
||||
message_part = message.strip() if message else ""
|
||||
parts = []
|
||||
if timestamp is not None:
|
||||
parts.append(timestamp.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S"))
|
||||
if level:
|
||||
parts.append(level.upper())
|
||||
if message_part:
|
||||
parts.append(message_part)
|
||||
return " ".join(parts).strip()
|
||||
|
||||
|
||||
def sanitize_text_log_line(line: str) -> str:
|
||||
return CONTROL_CHAR_PATTERN.sub("", line)
|
||||
|
||||
|
||||
def parse_text_log_entry(line: str) -> StructuredLogEntry:
|
||||
sanitized_line = sanitize_text_log_line(line).rstrip("\n")
|
||||
timestamp, remainder = parse_prefixed_timestamp(sanitized_line)
|
||||
level = infer_log_level_from_text(remainder or sanitized_line, allow_embedded=False)
|
||||
display_line = sanitized_line
|
||||
return StructuredLogEntry(
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
display_line=display_line,
|
||||
raw_line=display_line,
|
||||
search_text=display_line.lower(),
|
||||
)
|
||||
|
||||
|
||||
def build_buffer_entry(payload: dict[str, Any]) -> StructuredLogEntry:
|
||||
timestamp = parse_timestamp(str(payload.get("timestamp", "")).strip())
|
||||
level = normalize_log_level(payload.get("level"))
|
||||
if level == LOG_LEVEL_ALL:
|
||||
level = None
|
||||
message = str(payload.get("message", "")).strip()
|
||||
context = payload.get("context")
|
||||
context_map = context if isinstance(context, dict) else {}
|
||||
context_fragments = []
|
||||
for key in ("category", "module", "url", "detail"):
|
||||
value = str(context_map.get(key, "")).strip()
|
||||
if value:
|
||||
context_fragments.append(f"{key}={value}")
|
||||
message_with_context = " | ".join([message, *context_fragments]) if context_fragments else message
|
||||
display_line = build_display_line(timestamp, level, message_with_context)
|
||||
search_text = " ".join(
|
||||
[
|
||||
message,
|
||||
json.dumps(context_map, ensure_ascii=False, sort_keys=True),
|
||||
display_line,
|
||||
]
|
||||
).lower()
|
||||
return StructuredLogEntry(
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
display_line=display_line,
|
||||
raw_line=json.dumps(payload, ensure_ascii=False, sort_keys=True),
|
||||
search_text=search_text,
|
||||
)
|
||||
|
||||
|
||||
def read_file_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
path = Path(source.location)
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
recent_lines = deque(handle, maxlen=scan_limit)
|
||||
return [
|
||||
parse_text_log_entry(line)
|
||||
for line in recent_lines
|
||||
if sanitize_text_log_line(line).strip()
|
||||
]
|
||||
|
||||
|
||||
def read_docker_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
if not shutil.which("docker") or not source.container_name:
|
||||
return []
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"logs",
|
||||
"--timestamps",
|
||||
"--tail",
|
||||
str(scan_limit),
|
||||
source.container_name,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
if completed.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
parse_text_log_entry(line)
|
||||
for line in completed.stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def read_buffer_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
if not source.buffer_key:
|
||||
return []
|
||||
try:
|
||||
raw_items = redis_client.lrange(source.buffer_key, -scan_limit, -1)
|
||||
except Exception:
|
||||
return []
|
||||
entries: list[StructuredLogEntry] = []
|
||||
for raw_item in raw_items:
|
||||
try:
|
||||
payload = json.loads(raw_item)
|
||||
except json.JSONDecodeError:
|
||||
entries.append(parse_text_log_entry(str(raw_item)))
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
entries.append(build_buffer_entry(payload))
|
||||
else:
|
||||
entries.append(parse_text_log_entry(str(raw_item)))
|
||||
return entries
|
||||
|
||||
|
||||
def read_source_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
if source.kind == "file":
|
||||
return read_file_entries(source, scan_limit)
|
||||
if source.kind == "docker":
|
||||
return read_docker_entries(source, scan_limit)
|
||||
if source.kind == "buffer":
|
||||
return read_buffer_entries(source, scan_limit)
|
||||
return []
|
||||
|
||||
|
||||
def matches_levels(entry: StructuredLogEntry, selected_levels: tuple[str, ...]) -> bool:
|
||||
if not selected_levels:
|
||||
return True
|
||||
return entry.level in selected_levels
|
||||
|
||||
|
||||
def matches_date_range(
|
||||
entry: StructuredLogEntry,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
) -> bool:
|
||||
if not start_date and not end_date:
|
||||
return True
|
||||
if entry.timestamp is None:
|
||||
return False
|
||||
date_token = entry.timestamp.astimezone(UTC).date().isoformat()
|
||||
if start_date and date_token < start_date:
|
||||
return False
|
||||
if end_date and date_token > end_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def matches_search(entry: StructuredLogEntry, search: str | None) -> bool:
|
||||
if search is None:
|
||||
return True
|
||||
query = search.strip().lower()
|
||||
if not query:
|
||||
return True
|
||||
return query in entry.search_text
|
||||
|
||||
|
||||
def build_daily_log_markers(entries: list[StructuredLogEntry]) -> list[dict[str, Any]]:
|
||||
grouped: dict[str, list[StructuredLogEntry]] = {}
|
||||
for entry in entries:
|
||||
if entry.timestamp is None:
|
||||
continue
|
||||
date_token = entry.timestamp.astimezone(UTC).date().isoformat()
|
||||
grouped.setdefault(date_token, []).append(entry)
|
||||
|
||||
markers: list[DailyLogMarker] = []
|
||||
for date_token, group in sorted(grouped.items()):
|
||||
level_counts = Counter(
|
||||
entry.level
|
||||
for entry in group
|
||||
if entry.level in SUPPORTED_LOG_LEVELS and entry.level != LOG_LEVEL_ALL
|
||||
)
|
||||
dominant_level = LOG_LEVEL_INFO
|
||||
if level_counts:
|
||||
dominant_level = sorted(
|
||||
level_counts.items(),
|
||||
key=lambda item: (
|
||||
-item[1],
|
||||
("error", "warning", "info", "debug").index(item[0]),
|
||||
),
|
||||
)[0][0]
|
||||
markers.append(
|
||||
DailyLogMarker(
|
||||
date_token=date_token,
|
||||
total=len(group),
|
||||
dominant_level=dominant_level,
|
||||
)
|
||||
)
|
||||
return [marker.__dict__ for marker in markers]
|
||||
|
||||
|
||||
def read_log_snapshot(
|
||||
source_id: str,
|
||||
limit: int,
|
||||
*,
|
||||
level: str = LOG_LEVEL_ALL,
|
||||
levels: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
source = LOG_SOURCES.get(source_id)
|
||||
if source is None:
|
||||
return None
|
||||
|
||||
selected_levels = normalize_log_levels(level, levels)
|
||||
search_query = (search or "").strip()
|
||||
scan_limit = max(min(MAX_LOG_LINE_LIMIT * 5, 5000), limit * 5, BUFFER_LOG_LIMIT if source.kind == "buffer" else 1000)
|
||||
all_entries = read_source_entries(source, scan_limit)
|
||||
marker_entries = [
|
||||
entry
|
||||
for entry in all_entries
|
||||
if matches_levels(entry, selected_levels) and matches_search(entry, search_query)
|
||||
]
|
||||
filtered_entries = [
|
||||
entry
|
||||
for entry in marker_entries
|
||||
if matches_date_range(entry, start_date, end_date)
|
||||
]
|
||||
visible_entries = filtered_entries[-limit:]
|
||||
|
||||
compatibility_level = selected_levels[0] if len(selected_levels) == 1 else LOG_LEVEL_ALL
|
||||
return {
|
||||
"source_id": source.source_id,
|
||||
"name": source.name,
|
||||
"kind": source.kind,
|
||||
"location": source.location,
|
||||
"description": source.description,
|
||||
"category": source.category,
|
||||
"status": get_source_status(source),
|
||||
"level": compatibility_level,
|
||||
"selected_levels": list(selected_levels),
|
||||
"search_query": search_query,
|
||||
"available_levels": [
|
||||
LOG_LEVEL_ALL,
|
||||
LOG_LEVEL_ERROR,
|
||||
LOG_LEVEL_WARNING,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
],
|
||||
"daily_markers": build_daily_log_markers(marker_entries),
|
||||
"line_limit": limit,
|
||||
"line_count": len(visible_entries),
|
||||
"lines": [entry.display_line for entry in visible_entries],
|
||||
}
|
||||
@@ -35,6 +35,7 @@ async def test_health_check():
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert "version" in data
|
||||
assert response.headers["x-request-id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -161,6 +162,345 @@ async def test_alerts_endpoint_with_auth(auth_headers):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_sources_requires_super_admin(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password_hash="hashed",
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_sources_with_super_admin(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.list_log_sources",
|
||||
return_value=[
|
||||
{
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
}
|
||||
],
|
||||
):
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"][0]["source_id"] == "backend"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_with_super_admin(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "all",
|
||||
"selected_levels": [],
|
||||
"search_query": "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 2,
|
||||
"lines": ["line 1", "line 2"],
|
||||
},
|
||||
):
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/backend?limit=50", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["source_id"] == "backend"
|
||||
assert data["line_count"] == 2
|
||||
assert data["lines"] == ["line 1", "line 2"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_supports_level_filter(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "error",
|
||||
"selected_levels": ["error"],
|
||||
"search_query": "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 1,
|
||||
"lines": ["ERROR: failed"],
|
||||
},
|
||||
):
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/backend?limit=50&level=error", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["level"] == "error"
|
||||
assert data["lines"] == ["ERROR: failed"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_supports_date_range_filter(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "all",
|
||||
"selected_levels": [],
|
||||
"search_query": "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 1,
|
||||
"lines": ["2026-04-23 INFO: service started"],
|
||||
},
|
||||
) as mock_read_log_snapshot:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/system/logs/backend?limit=50&start_date=2026-04-20&end_date=2026-04-23",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_read_log_snapshot.assert_called_once_with(
|
||||
"backend",
|
||||
50,
|
||||
level="all",
|
||||
levels=None,
|
||||
start_date="2026-04-20",
|
||||
end_date="2026-04-23",
|
||||
search=None,
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_supports_levels_and_search_filter(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "all",
|
||||
"selected_levels": ["error", "warning"],
|
||||
"search_query": "timeout",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 1,
|
||||
"lines": ["2026-04-23 10:00:00 ERROR timeout"],
|
||||
},
|
||||
) as mock_read_log_snapshot:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/system/logs/backend?limit=50&levels=error,warning&search=timeout",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_read_log_snapshot.assert_called_once_with(
|
||||
"backend",
|
||||
50,
|
||||
level="all",
|
||||
levels="error,warning",
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
search="timeout",
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_rejects_invalid_date_range(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/system/logs/backend?start_date=2026-04-31",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "start_date must be in YYYY-MM-DD format" in response.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_earth_client_log_accepts_public_events():
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch("app.api.v1.system_control.append_buffer_log") as mock_append_buffer_log:
|
||||
with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/system/logs/earth-client",
|
||||
json={
|
||||
"level": "error",
|
||||
"message": "登陆点加载失败: 登陆点接口返回 HTTP 500",
|
||||
"category": "startup-load",
|
||||
"module": "layer-startup",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["accepted"] is True
|
||||
assert data["source_id"] == "earth-client"
|
||||
mock_append_buffer_log.assert_called_once()
|
||||
mock_record_system_log.assert_awaited_once()
|
||||
persisted_kwargs = mock_record_system_log.await_args.kwargs
|
||||
assert persisted_kwargs["source"] == "earth-client"
|
||||
assert persisted_kwargs["event"] == "earth.client.runtime_log"
|
||||
assert persisted_kwargs["category"] == "startup-load"
|
||||
assert persisted_kwargs["level"] == "error"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_id_header_is_echoed_when_provided():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/health", headers={"X-Request-ID": "planet-test-request"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-request-id"] == "planet-test-request"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_token():
|
||||
"""Test that invalid token is rejected"""
|
||||
@@ -263,6 +603,8 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
|
||||
assert "content_blocks" in data
|
||||
assert "text_blocks" in data
|
||||
assert "thinking_blocks" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -382,8 +724,6 @@ async def test_save_playground_session_with_auth(auth_headers):
|
||||
assert data["state"]["objective"] == "测试目标"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
49
backend/tests/test_earth_news.py
Normal file
49
backend/tests/test_earth_news.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.services.earth_news import ParsedNewsItem, _serialize_item
|
||||
|
||||
|
||||
def test_serialize_item_includes_region_anchor_for_cruise():
|
||||
item = ParsedNewsItem(
|
||||
id="google-apac:test",
|
||||
title="Example APAC story",
|
||||
summary="Example summary",
|
||||
url="https://example.com/story",
|
||||
source="Example Source",
|
||||
feed_name="Global Monitor / APAC",
|
||||
feed_region="asia-pacific",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 4, 23, 2, 30, tzinfo=UTC),
|
||||
)
|
||||
|
||||
payload = _serialize_item(item, active_region="asia-pacific")
|
||||
|
||||
assert payload["latitude"] == 1.3521
|
||||
assert payload["longitude"] == 103.8198
|
||||
assert payload["location_label"] == "亚太"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["is_focus_match"] is True
|
||||
assert payload["published_at"] == "2026-04-23T02:30:00Z"
|
||||
|
||||
|
||||
def test_serialize_item_falls_back_to_global_anchor():
|
||||
item = ParsedNewsItem(
|
||||
id="custom:test",
|
||||
title="Fallback story",
|
||||
summary="Fallback summary",
|
||||
url="https://example.com/fallback",
|
||||
source="Fallback Source",
|
||||
feed_name="Fallback Feed",
|
||||
feed_region="unknown-region",
|
||||
homepage_url="https://example.com",
|
||||
published_at=None,
|
||||
)
|
||||
|
||||
payload = _serialize_item(item, active_region="americas")
|
||||
|
||||
assert payload["latitude"] == 20.0
|
||||
assert payload["longitude"] == 0.0
|
||||
assert payload["location_label"] == "全球"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["is_focus_match"] is False
|
||||
assert payload["published_at"] is None
|
||||
78
backend/tests/test_logging.py
Normal file
78
backend/tests/test_logging.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from app.core.logging import PlanetContextFilter, PlanetFormatter, get_logger
|
||||
from app.core.request_context import set_request_id
|
||||
|
||||
|
||||
def _capture_output(callback):
|
||||
stream = StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
handler.addFilter(PlanetContextFilter())
|
||||
|
||||
adapter = get_logger("tests.logging")
|
||||
target_logger = adapter.logger
|
||||
original_handlers = list(target_logger.handlers)
|
||||
original_level = target_logger.level
|
||||
original_propagate = target_logger.propagate
|
||||
|
||||
target_logger.handlers = [handler]
|
||||
target_logger.setLevel(logging.INFO)
|
||||
target_logger.propagate = False
|
||||
|
||||
try:
|
||||
callback(adapter)
|
||||
finally:
|
||||
handler.flush()
|
||||
target_logger.handlers = original_handlers
|
||||
target_logger.setLevel(original_level)
|
||||
target_logger.propagate = original_propagate
|
||||
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def test_structured_logger_injects_request_id_and_event():
|
||||
set_request_id("req-test-123")
|
||||
try:
|
||||
output = _capture_output(
|
||||
lambda logger: logger.info_event(
|
||||
"collector started",
|
||||
event="collector.run.started",
|
||||
context={"collector_name": "bgp_news"},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
set_request_id(None)
|
||||
|
||||
assert "request_id=req-test-123" in output
|
||||
assert "event=collector.run.started" in output
|
||||
assert "service=backend" in output
|
||||
assert '"collector_name": "bgp_news"' in output
|
||||
|
||||
|
||||
def test_structured_logger_redacts_sensitive_text_and_context():
|
||||
set_request_id("req-test-redact")
|
||||
try:
|
||||
output = _capture_output(
|
||||
lambda logger: logger.error_event(
|
||||
"Authorization: Bearer super-secret-token",
|
||||
event="auth.token.failed",
|
||||
context={
|
||||
"token": "plain-secret",
|
||||
"nested": {"password": "hunter2"},
|
||||
"safe": "visible",
|
||||
},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
set_request_id(None)
|
||||
|
||||
assert "super-secret-token" not in output
|
||||
assert "plain-secret" not in output
|
||||
assert "hunter2" not in output
|
||||
assert "[REDACTED]" in output
|
||||
assert '"safe": "visible"' in output
|
||||
217
backend/tests/test_system_logs.py
Normal file
217
backend/tests/test_system_logs.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services import system_logs
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.store: dict[str, list[str]] = {}
|
||||
|
||||
def rpush(self, key: str, value: str) -> None:
|
||||
self.store.setdefault(key, []).append(value)
|
||||
|
||||
def ltrim(self, key: str, start: int, end: int) -> None:
|
||||
items = self.store.get(key, [])
|
||||
normalized_end = None if end == -1 else end + 1
|
||||
self.store[key] = items[start:normalized_end]
|
||||
|
||||
def expire(self, key: str, seconds: int) -> None:
|
||||
return None
|
||||
|
||||
def lrange(self, key: str, start: int, end: int) -> list[str]:
|
||||
items = self.store.get(key, [])
|
||||
normalized_end = None if end == -1 else end + 1
|
||||
return items[start:normalized_end]
|
||||
|
||||
def llen(self, key: str) -> int:
|
||||
return len(self.store.get(key, []))
|
||||
|
||||
|
||||
def test_read_log_snapshot_uses_structured_buffer_timestamp_level_and_search(monkeypatch):
|
||||
fake_redis = FakeRedis()
|
||||
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
||||
monkeypatch.setattr(
|
||||
system_logs,
|
||||
"LOG_SOURCES",
|
||||
{
|
||||
"earth-client": system_logs.LogSource(
|
||||
source_id="earth-client",
|
||||
name="Earth 浏览器端",
|
||||
kind="buffer",
|
||||
location="redis://planet:system_logs:earth-client",
|
||||
description="Earth 浏览器端上报日志",
|
||||
category="client",
|
||||
buffer_key=system_logs.get_buffer_log_key("earth-client"),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
fake_redis.rpush(
|
||||
system_logs.get_buffer_log_key("earth-client"),
|
||||
json.dumps(
|
||||
{
|
||||
"timestamp": "2026-04-22T10:15:30Z",
|
||||
"level": "warning",
|
||||
"message": "news feed degraded",
|
||||
"context": {"module": "news", "detail": "timeout"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
fake_redis.rpush(
|
||||
system_logs.get_buffer_log_key("earth-client"),
|
||||
json.dumps(
|
||||
{
|
||||
"timestamp": "2026-04-23T06:01:00Z",
|
||||
"level": "error",
|
||||
"message": "landing points failed",
|
||||
"context": {"module": "layer-startup", "detail": "http 500"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
snapshot = system_logs.read_log_snapshot(
|
||||
"earth-client",
|
||||
50,
|
||||
levels="error,warning",
|
||||
start_date="2026-04-23",
|
||||
end_date="2026-04-23",
|
||||
search="landing",
|
||||
)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot["selected_levels"] == ["error", "warning"]
|
||||
assert snapshot["search_query"] == "landing"
|
||||
assert snapshot["line_count"] == 1
|
||||
assert snapshot["lines"][0].startswith("2026-04-23 06:01:00 ERROR landing points failed")
|
||||
assert snapshot["daily_markers"] == [
|
||||
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
|
||||
]
|
||||
|
||||
|
||||
def test_read_log_snapshot_parses_file_timestamp_and_builds_markers(tmp_path: Path, monkeypatch):
|
||||
log_path = tmp_path / "backend.log"
|
||||
log_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"2026-04-22 08:00:00 INFO service booted",
|
||||
"2026-04-23 09:15:00 WARNING disk pressure detected",
|
||||
"2026-04-23 09:16:00 ERROR sync failed",
|
||||
"2026-04-24 10:00:00 DEBUG collector trace",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
system_logs,
|
||||
"LOG_SOURCES",
|
||||
{
|
||||
"backend": system_logs.LogSource(
|
||||
source_id="backend",
|
||||
name="后端服务",
|
||||
kind="file",
|
||||
location=str(log_path),
|
||||
description="测试文件日志",
|
||||
category="service",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
snapshot = system_logs.read_log_snapshot(
|
||||
"backend",
|
||||
50,
|
||||
levels="warning,error",
|
||||
search="failed",
|
||||
)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot["line_count"] == 1
|
||||
assert snapshot["lines"] == ["2026-04-23 09:16:00 ERROR sync failed"]
|
||||
assert snapshot["daily_markers"] == [
|
||||
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
|
||||
]
|
||||
assert snapshot["status"] == "ok"
|
||||
|
||||
|
||||
def test_append_buffer_log_persists_normalized_level(monkeypatch):
|
||||
fake_redis = FakeRedis()
|
||||
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
||||
|
||||
system_logs.append_buffer_log(
|
||||
"earth-client",
|
||||
level="warn",
|
||||
message="feed delayed",
|
||||
context={"module": "news"},
|
||||
)
|
||||
|
||||
stored_items = fake_redis.lrange(system_logs.get_buffer_log_key("earth-client"), 0, -1)
|
||||
payload = json.loads(stored_items[0])
|
||||
assert payload["level"] == "warning"
|
||||
assert payload["message"] == "feed delayed"
|
||||
|
||||
|
||||
def test_infer_log_level_prefers_leading_prefix_over_query_string():
|
||||
line = 'INFO: 127.0.0.1 - "GET /api/v1/system/logs/backend?limit=200&level=error&levels=error HTTP/1.1" 200 OK'
|
||||
|
||||
entry = system_logs.parse_text_log_entry(line)
|
||||
|
||||
assert entry.level == "info"
|
||||
|
||||
|
||||
def test_parse_text_log_entry_does_not_promote_exception_context_to_error():
|
||||
line = "websockets.exceptions.ConnectionClosedError: sent 1011 (internal error) keepalive ping timeout"
|
||||
|
||||
entry = system_logs.parse_text_log_entry(line)
|
||||
|
||||
assert entry.level is None
|
||||
|
||||
|
||||
def test_parse_text_log_entry_still_detects_explicit_error_prefix():
|
||||
line = "ERROR: [Errno 98] Address already in use"
|
||||
|
||||
entry = system_logs.parse_text_log_entry(line)
|
||||
|
||||
assert entry.level == "error"
|
||||
|
||||
|
||||
def test_read_log_snapshot_strips_nul_bytes_from_file_lines(tmp_path: Path, monkeypatch):
|
||||
log_path = tmp_path / "backend.log"
|
||||
log_path.write_bytes(
|
||||
(
|
||||
b"INFO: service booted\n"
|
||||
b"ERROR: bind failed\n"
|
||||
+ b"\x00" * 32
|
||||
+ b"2026-04-23 23:41:32 INFO service=backend message=request served\n"
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
system_logs,
|
||||
"LOG_SOURCES",
|
||||
{
|
||||
"backend": system_logs.LogSource(
|
||||
source_id="backend",
|
||||
name="后端服务",
|
||||
kind="file",
|
||||
location=str(log_path),
|
||||
description="测试文件日志",
|
||||
category="service",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
snapshot = system_logs.read_log_snapshot("backend", 50)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot["line_count"] == 3
|
||||
assert snapshot["lines"] == [
|
||||
"INFO: service booted",
|
||||
"ERROR: bind failed",
|
||||
"2026-04-23 23:41:32 INFO service=backend message=request served",
|
||||
]
|
||||
@@ -8,6 +8,151 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.41.0] — 2026-04-27
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层系统完成地表到天空的注册顺序与关注优先的面板顺序拆分,支持基座海陆色块、国界、高清材质、云图、地形、算力、BGP、卫星、轨迹与海缆的稳定层级
|
||||
- 国界层新增真实行政区轮廓交互与中国/台湾联动高亮,修复高清材质、地形、footprint、卫星与经纬线之间的遮挡和 hover 竞争
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增无轮廓基座地图,所有图层关闭时仍保留 `#010609` 海洋与 `#080f1b` 陆地色块
|
||||
- 将大气云图抽象为独立图层并接入桌面/移动端图层开关、持久化状态与启动同步
|
||||
- 高清材质改为独立纹理覆盖层,地形显示在高清材质上方,并在高清材质关闭/恢复时保持原地形开关意图
|
||||
- 补充 Earth 渲染层级与图层样式文档,记录正式图层名、变量名、材质颜色、线宽与 renderOrder
|
||||
|
||||
---
|
||||
|
||||
## [0.40.5] — 2026-04-26
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星拖尾改用 Instanced screen-space ribbon,单 draw call 渲染所有轨迹段,支持像素级宽度控制
|
||||
- Iridium 地面覆盖重写为球面投影径向网格,修复填充光晕不可见问题;新增外圈 LineLoop
|
||||
- 搜索面板打开时改用双 rAF 延迟聚焦输入框,确保 CSS 过渡完成后焦点可靠触发
|
||||
- 代码清理:提取 `IRIDIUM_OVERLAY_COLOR`、`IRIDIUM_REFERENCE_ALTITUDE_KM` 常量,消除重复三角函数调用
|
||||
|
||||
---
|
||||
|
||||
## [0.39.0] — 2026-04-24
|
||||
|
||||
## [0.40.4] — 2026-04-26
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增页面可见性恢复处理,页面从后台切回前台时主动刷新卫星位置,避免累积后台时间在下一帧一次性回放
|
||||
- 抽出卫星轨迹状态与轨迹几何清理 helper,统一后台恢复与清空数据时的轨迹重置路径
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复页面在后台停留较久后恢复前台时,卫星轨迹因超大 `deltaTime` 突然跳变、拖尾异常拉长的问题
|
||||
- 修复后台恢复后首帧仍沿用旧轨迹缓存,导致轨迹与当前卫星位置短时错位的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.40.3] — 2026-04-25
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星点云升级为自定义 ShaderMaterial,支持 per-point alpha 控制,锁定/悬停卫星从点云中精确隐藏
|
||||
- 修复锁定环与自发光选中标记的 depthTest 错误(false → true),消除远端渲染穿透 artifact
|
||||
- 新增锁定环悬停态缩放与线宽(LOCKED_RING_HOVER_SCALE / LOCKED_RING_HOVER_LINE_WIDTH)
|
||||
- 修复 updateLockedDotWorldTransform / updateLockedHaloWorldTransform 未强制刷新 matrixWorld 导致的位置漂移
|
||||
|
||||
---
|
||||
|
||||
## [0.40.2] — 2026-04-24
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星点大小随镜头缩放动态调整,拉近变大、拉远变小,响应与相机距离线性对应
|
||||
- 调小卫星点默认基础尺寸(dotSize 2.8),缩放范围更合理
|
||||
|
||||
---
|
||||
|
||||
## [0.40.1] — 2026-04-24
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星选中标记(lockedring / lockeddot / 光晕)颜色统一跟随图例轨道倾角分类配色
|
||||
- 修复 Starlink footprint 在特定视角下遮蔽卫星点的渲染顺序问题(Group renderOrder 影响子 Mesh 排序)
|
||||
- footprint 材质改为 `depthTest: false` + 相机朝向 limbFade,替代 polygonOffset 深度竞争方案
|
||||
- 修复选中海缆时误触发附近卫星高亮(该行为属于 BGP 事件点逻辑,不应用于海缆)
|
||||
|
||||
---
|
||||
|
||||
## [0.40.0] — 2026-04-24
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 卫星 footprint 正式按星座能力分层:Starlink 保留专用地表覆盖,Iridium 改为独立外圈覆盖表达,其它非 Starlink 星座不再误用同一套 footprint
|
||||
- Earth 卫星详情卡补齐覆盖能力与当前显示说明,用户现在可以直接看见每颗卫星为什么显示 footprint、为何回退为自身发光
|
||||
|
||||
### 🔧 Improvements
|
||||
- 后端可视化接口新增并透传 `constellation_group` 与 `footprint_policy`,前端据此执行 capability-gated footprint renderer
|
||||
- 新增 Iridium 独立 coverage ring adapter,并继续保留 Starlink 专用 footprint 调校与昼夜可读性增强
|
||||
- 新增 Earth 卫星 footprint 策略技术文档,明确 GNSS、generic LEO、GEO 与 Iridium 的显示边界
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复前后端对 Iridium footprint policy 命名不一致,导致策略分发语义含混的问题
|
||||
- 清理 Starlink footprint 渲染中的未使用常量与过时命名,减少后续继续调校时的歧义
|
||||
|
||||
---
|
||||
|
||||
## [0.39.0] — 2026-04-24
|
||||
|
||||
### ✨ Highlights
|
||||
- 后端正式落下统一结构化日志地基:请求上下文、事件名、脱敏与持久化链路开始收口为可扩展的企业级日志体系
|
||||
- 系统日志页重构为真正的日志工作台:顶部筛选更紧凑,终端日志区成为主视觉,移动端 Earth 新闻/态势细节交互继续补稳
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 `backend/app/core/logging.py`,统一 `request_id`、`service`、`event` 注入与敏感字段脱敏,并接入后端主入口、调度器、缓存、数据库和可视化链路
|
||||
- 系统日志页筛选区重排为更紧凑的两层结构,信息摘要并入终端工具栏 tooltip,日志终端区留出更稳定的按钮避让空间
|
||||
- Earth 移动端态势抽屉补齐宽度约束与图例换行规则,新闻详情抽屉在巡航切换时可同步更新标题和摘要
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 `/tmp/planet_backend.log` 中混入空字节时,日志摘要条行数与实际可见日志不一致的问题
|
||||
- 修复移动端“态势”tab 在内容渲染后被图例文本撑宽、超出一屏的问题
|
||||
- 修复移动端新闻详情抽屉在巡航切换下一条新闻时标题更新但 summary 不同步的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.38.0] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 新闻正式接入通用巡航层:新闻和 BGP 统一进入可配置巡航模块,桌面端与移动端都能在巡航聚焦时展示对应新闻卡片
|
||||
- 系统日志页升级为结构化过滤链路:按真实时间戳、结构化级别和字符串检索统一筛选,不再依赖前端或后端从日志文本里猜结果
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新闻巡航补齐业务适配层:按发生地与时间生成巡航目标,桌面端与移动端统一标题 + summary 卡片风格,并增加连线与打字机摘要展示
|
||||
- 日志页筛选体验重排,统一服务源、级别、行数、时间和检索布局,日历标记改为由后端返回的结构化每日聚合结果驱动
|
||||
- 后端补充 `system_logs` 结构化解析与多级别精确过滤能力,Earth 浏览器端日志缓冲与系统日志 API 现在走同一套筛选语义
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复新闻巡航模块开启后难以关闭、桌面/移动端设置状态互相污染的问题
|
||||
- 修复新闻巡航卡片缺少摘要、移动端详情样式不统一、新闻巡航缺少连线的问题
|
||||
- 修复日志级别筛选会被访问日志 query string 中的 `level=error` 等参数污染,从而把 `INFO` 行误判为 `ERROR` 的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.37.2] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
|
||||
|
||||
### 🔧 Improvements
|
||||
- 经纬线正式接入 Earth layer registry,复用现有图层切换、移动端图层卡片与设置持久化流
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.37.1] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
|
||||
|
||||
### 🔧 Improvements
|
||||
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
## [0.37.0] — 2026-04-23
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
|
||||
- [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
|
||||
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
||||
- [earth-country-boundary-overlay-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-country-boundary-overlay-plan.md)
|
||||
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
|
||||
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
|
||||
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
|
||||
|
||||
793
docs/plans/enterprise-logging-system-plan.md
Normal file
793
docs/plans/enterprise-logging-system-plan.md
Normal file
@@ -0,0 +1,793 @@
|
||||
# Planet 企业级日志系统实施计划
|
||||
|
||||
## Goal
|
||||
|
||||
把 Planet 当前“能看一点运行输出”的日志能力,升级为一套真正可用、可定位、可纠错、可追责、可演进的企业级日志系统。
|
||||
|
||||
这里的“企业级”不是指一上来就接入很重的外部平台,而是指这套系统需要同时满足下面五件事:
|
||||
|
||||
1. 排障可用
|
||||
2. 历史可查
|
||||
3. 业务可解释
|
||||
4. 权限操作可追责
|
||||
5. 出错后能够反向定位到请求、任务、模块和操作者
|
||||
|
||||
最终目标不是“把更多 stdout 放到日志页里”,而是建立一套统一的日志契约与落地链路:
|
||||
|
||||
- 统一日志字段
|
||||
- 统一事件命名
|
||||
- 统一采集入口
|
||||
- 统一查询视图
|
||||
- 清晰的实时日志、持久化事件、审计日志分层
|
||||
|
||||
## Why
|
||||
|
||||
当前仓库已经有一些日志基础,但离真正可用的日志系统还有明显距离。
|
||||
|
||||
已有基础:
|
||||
|
||||
- 后端运行日志可通过 `/tmp/planet_backend.log` 查看
|
||||
- 前端开发服务日志可通过 `/tmp/planet_frontend.log` 查看
|
||||
- AI Provider 可从 Docker 容器读取日志
|
||||
- Earth 浏览器端关键日志可上报到后端并进入 Redis 缓冲
|
||||
- 已有 `system_logs` / `audit_logs` 持久化能力
|
||||
- 管理台已有“系统日志”页面,支持来源、级别、日期、搜索
|
||||
|
||||
当前缺口:
|
||||
|
||||
- 后端日志仍以 `uvicorn` / 文本输出为主,不是统一结构化事件流
|
||||
- 不同模块的日志格式不一致,很多地方只有 message,没有 event 语义
|
||||
- 还没有统一的后端 logger 封装与字段注入机制
|
||||
- 前端虽然能上报错误,但还没有统一 logger API 和统一事件词汇
|
||||
- Earth 与管理台之间的错误事件还没有形成可串联的事件链路
|
||||
- 历史持久化还偏点状,很多高价值失败并没有系统性落库
|
||||
- 系统日志页当前更像“运行输出查看器”,不是“多层日志查询台”
|
||||
- 审计日志与运行日志尚未形成明确的产品级联动
|
||||
|
||||
所以当前真正的问题不是“有没有日志页”,而是:
|
||||
|
||||
**当前系统能看见输出,但还不能稳定回答“发生了什么、影响了谁、在哪条链路上坏了、是否已修复、是谁触发的”。**
|
||||
|
||||
## Current State
|
||||
|
||||
截至 2026-04-23,当前代码中的日志相关能力大致如下。
|
||||
|
||||
### 1. 日志来源
|
||||
|
||||
当前系统日志页主要读取以下来源:
|
||||
|
||||
- `backend`
|
||||
读取 `/tmp/planet_backend.log`
|
||||
- `frontend`
|
||||
读取 `/tmp/planet_frontend.log`
|
||||
- `ai-provider`
|
||||
读取 Docker 容器日志
|
||||
- `earth-client`
|
||||
读取 Redis 缓冲的浏览器端日志
|
||||
|
||||
这些来源定义在:
|
||||
|
||||
- [backend/app/services/system_logs.py](/home/ray/dev/linkong/planet/backend/app/services/system_logs.py)
|
||||
|
||||
### 2. 当前日志读取模型
|
||||
|
||||
当前 `read_log_snapshot()` 的职责是:
|
||||
|
||||
- 读取某个来源的最近若干行
|
||||
- 解析基础级别与时间
|
||||
- 按级别、日期、搜索进行过滤
|
||||
- 返回用于日志页展示的快照
|
||||
|
||||
这个模型适合“运维查看器”,但不适合企业级日志系统,原因是:
|
||||
|
||||
- 读取基于文本尾部扫描,不是基于事件模型
|
||||
- 不同来源的结构粒度完全不同
|
||||
- 过滤依赖文本解析,准确率有限
|
||||
- 没有请求、任务、用户、资源、动作等核心关联字段
|
||||
|
||||
### 3. 已有持久化能力
|
||||
|
||||
当前已经存在两个持久化入口:
|
||||
|
||||
- `record_system_log(...)`
|
||||
- `record_audit_log(...)`
|
||||
|
||||
位置:
|
||||
|
||||
- [backend/app/services/persistent_logs.py](/home/ray/dev/linkong/planet/backend/app/services/persistent_logs.py)
|
||||
|
||||
这说明系统并不是从 0 开始,但也说明当前最大的问题是:
|
||||
|
||||
**持久化能力存在,但没有成为统一默认路径。**
|
||||
|
||||
### 4. 已有 request_id 基础
|
||||
|
||||
当前系统已具备 `request_id` 相关基础,部分持久化能力也会尝试写入 `request_id`。
|
||||
|
||||
这为后续做:
|
||||
|
||||
- 请求链路排障
|
||||
- 前后端关联查询
|
||||
- 任务执行追踪
|
||||
|
||||
提供了很好的基础。
|
||||
|
||||
### 5. 当前日志页定位
|
||||
|
||||
当前日志页已经具备:
|
||||
|
||||
- 来源切换
|
||||
- 级别筛选
|
||||
- 日期筛选
|
||||
- 搜索
|
||||
- 文本控制台视图
|
||||
|
||||
但它仍然是“单层视图”:
|
||||
|
||||
- 上面是筛选器
|
||||
- 下面是一块文本控制台
|
||||
|
||||
它还不是:
|
||||
|
||||
- 运行日志 + 事件日志 + 审计日志 的统一入口
|
||||
- 也没有事件详情、关联跳转、纠错建议、链路追踪能力
|
||||
|
||||
## Core Principles
|
||||
|
||||
这套日志系统后续必须遵循下面几个原则。
|
||||
|
||||
### 1. 分层,而不是混存
|
||||
|
||||
日志必须拆成三层:
|
||||
|
||||
1. 运行日志
|
||||
2. 持久化事件日志
|
||||
3. 审计日志
|
||||
|
||||
它们的用途不同,绝不能继续混成一个概念。
|
||||
|
||||
#### 运行日志
|
||||
|
||||
用于:
|
||||
|
||||
- 实时排障
|
||||
- 观察服务运行状态
|
||||
- 看 stdout / stderr / exception / collector 输出
|
||||
|
||||
特点:
|
||||
|
||||
- 数据量大
|
||||
- 时效性强
|
||||
- 保留周期短
|
||||
- 不要求每条都落库
|
||||
|
||||
#### 持久化事件日志
|
||||
|
||||
用于:
|
||||
|
||||
- 记录高价值错误
|
||||
- 记录关键业务失败
|
||||
- 支撑历史追溯
|
||||
- 支撑趋势分析
|
||||
|
||||
特点:
|
||||
|
||||
- 只持久化有价值事件
|
||||
- 必须结构化
|
||||
- 必须有统一 event 命名
|
||||
|
||||
#### 审计日志
|
||||
|
||||
用于:
|
||||
|
||||
- 留痕
|
||||
- 追责
|
||||
- 还原高权限操作
|
||||
|
||||
特点:
|
||||
|
||||
- 必须单独建模
|
||||
- 不与普通运行日志混用
|
||||
|
||||
### 2. 结构化优先
|
||||
|
||||
正式日志必须可拆字段,不能长期依赖自由文本。
|
||||
|
||||
最低要求至少能拿到:
|
||||
|
||||
- `timestamp`
|
||||
- `level`
|
||||
- `service`
|
||||
- `module`
|
||||
- `event`
|
||||
- `message`
|
||||
- `request_id`
|
||||
- `trace_id`
|
||||
- `user_id` / `actor`
|
||||
- `context`
|
||||
|
||||
### 3. 事件命名优先于 message 命名
|
||||
|
||||
人看的 message 可以变化,但机器查询和跨模块关联必须依赖稳定事件名。
|
||||
|
||||
例如:
|
||||
|
||||
- `collector.run.started`
|
||||
- `collector.run.completed`
|
||||
- `collector.run.failed`
|
||||
- `earth.layer.load_failed`
|
||||
- `earth.cruise.route_build_failed`
|
||||
- `system.restart_task.failed`
|
||||
- `auth.websocket.invalid_token`
|
||||
|
||||
### 4. 查询链路必须可串联
|
||||
|
||||
企业级日志系统的核心不是“有很多日志”,而是“能串起来”。
|
||||
|
||||
最终一条高价值事件,至少要能回链到下面任意几类对象:
|
||||
|
||||
- 某个请求
|
||||
- 某个任务
|
||||
- 某个用户
|
||||
- 某个数据源
|
||||
- 某个 Earth 模块
|
||||
- 某个管理动作
|
||||
|
||||
### 5. 默认脱敏
|
||||
|
||||
日志体系必须明确禁止记录:
|
||||
|
||||
- token
|
||||
- password
|
||||
- Authorization header
|
||||
- cookie
|
||||
- session
|
||||
- 明文敏感个人信息
|
||||
|
||||
并且需要有统一脱敏器,而不是靠调用者自觉。
|
||||
|
||||
### 6. “可纠错”不是一句口号
|
||||
|
||||
这里的“可纠错”至少包含三层:
|
||||
|
||||
1. 日志字段足够解释错误,方便人排查
|
||||
2. 系统能识别常见错误模式并给出纠偏建议
|
||||
3. 关键错误支持闭环动作,例如重试、重建索引、重新触发采集、跳转到对应对象
|
||||
|
||||
也就是说,这套日志系统最终不只是“告诉你出错了”,而要尽量接近“告诉你为什么出错、怎么修、去哪修”。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
第一阶段不追求:
|
||||
|
||||
- 全量接入 ELK / Loki / Datadog / OpenTelemetry 全家桶
|
||||
- 做分布式 trace 全链路可视化大屏
|
||||
- 把所有历史日志都迁进数据库
|
||||
- 先做特别复杂的规则引擎
|
||||
|
||||
第一阶段追求的是:
|
||||
|
||||
- 在当前仓库和当前部署方式下,先把基础日志体系做正确
|
||||
- 再为后续平台化接入预留好接口
|
||||
|
||||
## Target Architecture
|
||||
|
||||
推荐目标架构如下。
|
||||
|
||||
### Layer 1: Runtime Logs
|
||||
|
||||
职责:
|
||||
|
||||
- 承载后端、前端开发服务、容器输出、浏览器端缓冲事件
|
||||
- 提供最近窗口内的实时查看能力
|
||||
|
||||
来源:
|
||||
|
||||
- 文件
|
||||
- Docker
|
||||
- Redis 缓冲
|
||||
- 后续可扩展到 stdout collector
|
||||
|
||||
接口:
|
||||
|
||||
- `GET /api/v1/system/logs/sources`
|
||||
- `GET /api/v1/system/logs/{source_id}`
|
||||
|
||||
这层继续保留,但需要做结构化增强和来源补强。
|
||||
|
||||
### Layer 2: Persistent System Events
|
||||
|
||||
职责:
|
||||
|
||||
- 只存高价值事件
|
||||
- 供历史追溯、事件列表、趋势和纠错使用
|
||||
|
||||
数据来源:
|
||||
|
||||
- 后端关键异常
|
||||
- 浏览器端关键失败
|
||||
- 采集器/调度器关键失败
|
||||
- 业务关键告警与降级事件
|
||||
|
||||
接口建议:
|
||||
|
||||
- `GET /api/v1/system/events`
|
||||
- `GET /api/v1/system/events/{id}`
|
||||
- `POST /api/v1/system/events/{id}/actions/...`(后续)
|
||||
|
||||
### Layer 3: Audit Logs
|
||||
|
||||
职责:
|
||||
|
||||
- 留痕高权限操作
|
||||
- 记录操作者、对象、结果、请求号
|
||||
|
||||
接口建议:
|
||||
|
||||
- `GET /api/v1/system/audit-logs`
|
||||
|
||||
### Layer 4: Error Intelligence / Triage
|
||||
|
||||
职责:
|
||||
|
||||
- 对高频错误做归类
|
||||
- 对已知错误给出解释与建议动作
|
||||
- 对相同错误进行 fingerprint 聚合
|
||||
|
||||
这是“可纠错”能力的关键层。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `fingerprint`
|
||||
- `root_cause_type`
|
||||
- `known_fix_hint`
|
||||
- `runbook_url`
|
||||
- `related_resource_type`
|
||||
- `related_resource_id`
|
||||
|
||||
## Canonical Event Model
|
||||
|
||||
推荐统一事件字段模型如下。
|
||||
|
||||
### Runtime Log Record
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-04-23T10:15:30Z",
|
||||
"level": "error",
|
||||
"service": "backend",
|
||||
"module": "app.services.scheduler",
|
||||
"event": "collector.run.failed",
|
||||
"message": "Collector bgp_news failed",
|
||||
"request_id": "req_xxx",
|
||||
"trace_id": "trace_xxx",
|
||||
"user_id": null,
|
||||
"actor": null,
|
||||
"resource_type": "collector",
|
||||
"resource_id": "bgp_news",
|
||||
"context": {
|
||||
"datasource_id": 12,
|
||||
"exception_type": "TimeoutError"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Persistent System Event
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1024,
|
||||
"event": "earth.layer.load_failed",
|
||||
"level": "error",
|
||||
"source": "earth-client",
|
||||
"service": "earth",
|
||||
"module": "cables",
|
||||
"message": "Failed to load cable layer",
|
||||
"fingerprint": "earth.layer.load_failed:cables:network_timeout",
|
||||
"request_id": "req_xxx",
|
||||
"trace_id": null,
|
||||
"user_id": 1,
|
||||
"resource_type": "earth_layer",
|
||||
"resource_id": "cables",
|
||||
"category": "visualization",
|
||||
"status": "open",
|
||||
"context": {
|
||||
"url": "/api/v1/visualization/geo/cables"
|
||||
},
|
||||
"created_at": "2026-04-23T10:15:30Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Audit Log
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 88,
|
||||
"action": "system.restart_task.requested",
|
||||
"actor_id": 1,
|
||||
"actor_name": "root",
|
||||
"target_type": "restart_task",
|
||||
"target_id": "restart_20260423_xxx",
|
||||
"result": "success",
|
||||
"request_id": "req_xxx",
|
||||
"ip": "127.0.0.1",
|
||||
"details": {
|
||||
"action": "restart_backend"
|
||||
},
|
||||
"created_at": "2026-04-23T10:15:30Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
## Phase 0: Logging Inventory And Naming Freeze
|
||||
|
||||
目标:
|
||||
|
||||
- 先统一“记录什么”和“怎么命名”,避免后面越做越乱
|
||||
|
||||
工作项:
|
||||
|
||||
- 盘点当前所有 `logging.getLogger` 使用点
|
||||
- 盘点裸 `print`
|
||||
- 盘点 `record_system_log` / `record_audit_log` 已落点位
|
||||
- 建立统一事件命名表
|
||||
- 定义 service / module / category / resource 字段枚举
|
||||
- 输出日志字段白名单和脱敏规范
|
||||
|
||||
完成标准:
|
||||
|
||||
- 有一份稳定的事件命名清单
|
||||
- 有一份字段规范清单
|
||||
- 后续新增日志不再“临时起名”
|
||||
|
||||
## Phase 1: Backend Structured Logging Foundation
|
||||
|
||||
目标:
|
||||
|
||||
- 把后端从“散落 logging + 文本输出”升级成“统一结构化 logger”
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增统一后端 logger helper,例如 `app/core/logging.py`
|
||||
- 自动注入:
|
||||
- `service`
|
||||
- `module`
|
||||
- `request_id`
|
||||
- `trace_id`
|
||||
- 增加统一脱敏 filter
|
||||
- 把关键模块先切到统一 logger:
|
||||
- API 层
|
||||
- scheduler
|
||||
- collectors
|
||||
- websocket
|
||||
- visualization
|
||||
- system control
|
||||
- 约束:
|
||||
- 正式路径禁止裸 `print`
|
||||
- 正式异常优先 `logger.exception(..., extra={...})`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 后端关键模块都有稳定 `event`
|
||||
- request 日志和异常日志能挂上 `request_id`
|
||||
- 不再依赖只看 `uvicorn` 原生文本输出来定位问题
|
||||
|
||||
## Phase 2: Persistent Event Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 把“值得长期保留的错误和关键事件”系统性落库
|
||||
|
||||
工作项:
|
||||
|
||||
- 重新定义 `record_system_log()` 的使用边界
|
||||
- 明确哪些事件必须持久化:
|
||||
- API 关键失败
|
||||
- 调度器失败
|
||||
- 采集器失败
|
||||
- Earth 客户端关键错误
|
||||
- 数据源不可用
|
||||
- 业务降级与恢复
|
||||
- 补齐字段:
|
||||
- `event`
|
||||
- `resource_type`
|
||||
- `resource_id`
|
||||
- `category`
|
||||
- `fingerprint`
|
||||
- `status`
|
||||
- 增加高频错误去重/聚合策略
|
||||
|
||||
完成标准:
|
||||
|
||||
- 高价值错误不再只存在于运行日志里
|
||||
- 能查询最近一周/一月的关键失败事件
|
||||
- 相同错误具备聚合基础
|
||||
|
||||
## Phase 3: Frontend And Earth Unified Logger
|
||||
|
||||
目标:
|
||||
|
||||
- 把前端从“点状 error 上报”升级成统一前端事件流
|
||||
|
||||
工作项:
|
||||
|
||||
- 在前端新增统一 logger API
|
||||
- 统一方法:
|
||||
- `debug`
|
||||
- `info`
|
||||
- `warn`
|
||||
- `error`
|
||||
- 统一字段:
|
||||
- `page`
|
||||
- `module`
|
||||
- `event`
|
||||
- `message`
|
||||
- `url`
|
||||
- `user_agent`
|
||||
- `context`
|
||||
- Earth 模块优先接入:
|
||||
- layer load failed
|
||||
- cruise build failed
|
||||
- popup render failed
|
||||
- connector render failed
|
||||
- websocket dropped
|
||||
- 管理台优先接入:
|
||||
- settings save failed
|
||||
- datasource toggle failed
|
||||
- restart task submit failed
|
||||
|
||||
完成标准:
|
||||
|
||||
- 前端日志事件名与后端可对齐
|
||||
- Earth 和管理台关键失败不再只停留在 console
|
||||
- 浏览器端关键问题能进入统一系统日志/事件层
|
||||
|
||||
## Phase 4: Audit Logging Completion
|
||||
|
||||
目标:
|
||||
|
||||
- 把管理员与高权限操作真正做成企业级审计
|
||||
|
||||
工作项:
|
||||
|
||||
- 扩大审计覆盖面:
|
||||
- 系统重启
|
||||
- 数据源启停
|
||||
- 调度规则变更
|
||||
- 配置变更
|
||||
- 人工触发采集
|
||||
- 删除/修改关键配置
|
||||
- 增加字段:
|
||||
- actor
|
||||
- target
|
||||
- before / after
|
||||
- request_id
|
||||
- IP
|
||||
- 审计页支持:
|
||||
- 动作筛选
|
||||
- 操作者筛选
|
||||
- 时间筛选
|
||||
- 目标对象筛选
|
||||
|
||||
完成标准:
|
||||
|
||||
- 所有高权限操作都能追到人、时间、对象、结果
|
||||
|
||||
## Phase 5: Log Console To Enterprise Observability UI
|
||||
|
||||
目标:
|
||||
|
||||
- 把当前“系统日志”页升级为真正的多层日志工作台
|
||||
|
||||
工作项:
|
||||
|
||||
- 将页面拆为三个主视图:
|
||||
1. 运行日志
|
||||
2. 关键事件
|
||||
3. 审计日志
|
||||
- 运行日志视图:
|
||||
- 保留大控制台
|
||||
- 支持来源、级别、日期、搜索
|
||||
- 关键事件视图:
|
||||
- 列表化展示高价值事件
|
||||
- 支持聚合、状态、指纹、对象筛选
|
||||
- 审计视图:
|
||||
- 列表化展示管理员动作
|
||||
- 增加详情抽屉:
|
||||
- 原始 message
|
||||
- context
|
||||
- request_id
|
||||
- related resource
|
||||
- recommended action
|
||||
|
||||
完成标准:
|
||||
|
||||
- 日志页不再只是“终端文本窗口”
|
||||
- 运维排障、历史追溯、审计留痕三者分层清晰
|
||||
|
||||
## Phase 6: Corrective Intelligence
|
||||
|
||||
目标:
|
||||
|
||||
- 让系统从“能看日志”进化到“能辅助修错”
|
||||
|
||||
工作项:
|
||||
|
||||
- 引入错误 fingerprint
|
||||
- 对已知错误配置:
|
||||
- 根因类型
|
||||
- 修复建议
|
||||
- runbook 链接
|
||||
- 推荐动作
|
||||
- 支持常见纠错动作:
|
||||
- 重试采集任务
|
||||
- 重载配置
|
||||
- 跳转到对应模块/资源
|
||||
- 打开相关日志过滤视图
|
||||
- 高频错误支持聚合与静默窗口
|
||||
|
||||
完成标准:
|
||||
|
||||
- 已知错误能给出明确建议
|
||||
- 运维不需要每次都从零猜
|
||||
|
||||
## Recommended Module Changes
|
||||
|
||||
### Backend
|
||||
|
||||
建议新增/增强的模块:
|
||||
|
||||
- `backend/app/core/logging.py`
|
||||
- 统一 logger 封装
|
||||
- formatter
|
||||
- filter
|
||||
- request/trace 注入
|
||||
- `backend/app/services/persistent_logs.py`
|
||||
- 扩展字段
|
||||
- 统一持久化策略
|
||||
- `backend/app/services/system_logs.py`
|
||||
- 逐步从“文本尾部查看器”升级为“运行日志聚合器”
|
||||
- `backend/app/services/log_classification.py`
|
||||
- 指纹
|
||||
- 根因分类
|
||||
- 纠错建议
|
||||
- `backend/app/api/v1/system_control.py`
|
||||
- 补充事件 / 审计 / 日志多视图接口
|
||||
|
||||
### Frontend
|
||||
|
||||
建议新增/增强:
|
||||
|
||||
- `frontend/src/lib/logger.ts`
|
||||
- 统一前端 logger API
|
||||
- `frontend/src/pages/Logs/Logs.tsx`
|
||||
- 升级为多层工作台
|
||||
- `frontend/public/earth/js/...`
|
||||
- 各 Earth 模块接入统一事件 logger
|
||||
|
||||
## Event Naming Convention
|
||||
|
||||
建议采用:
|
||||
|
||||
`<domain>.<resource>.<action>.<result>`
|
||||
|
||||
示例:
|
||||
|
||||
- `collector.datasource.run.started`
|
||||
- `collector.datasource.run.failed`
|
||||
- `earth.layer.cables.load.failed`
|
||||
- `earth.cruise.route.build.failed`
|
||||
- `system.restart_task.requested`
|
||||
- `system.restart_task.completed`
|
||||
- `auth.websocket.connect.failed`
|
||||
- `settings.datasource.priority.updated`
|
||||
|
||||
规则:
|
||||
|
||||
- 不用自然语言句子
|
||||
- 不把 ID 塞进 event 名里
|
||||
- 资源对象通过字段承载,不通过 event 名承载
|
||||
|
||||
## Query Model
|
||||
|
||||
最终推荐支持的查询维度:
|
||||
|
||||
- 时间范围
|
||||
- level
|
||||
- source
|
||||
- service
|
||||
- module
|
||||
- event
|
||||
- request_id
|
||||
- trace_id
|
||||
- user_id / actor
|
||||
- resource_type / resource_id
|
||||
- category
|
||||
- fingerprint
|
||||
- status
|
||||
- full-text search
|
||||
|
||||
## Retention Strategy
|
||||
|
||||
推荐保留策略:
|
||||
|
||||
- 运行日志:
|
||||
- 文件 / 容器 / Redis 缓冲保留短周期
|
||||
- 持久化事件:
|
||||
- 保留中长期
|
||||
- 审计日志:
|
||||
- 长期保留
|
||||
|
||||
初版可以先这样:
|
||||
|
||||
- 运行日志:7 到 14 天
|
||||
- 关键事件:90 到 180 天
|
||||
- 审计日志:180 天以上
|
||||
|
||||
后续再根据存储与合规要求调整。
|
||||
|
||||
## Security And Compliance
|
||||
|
||||
必须落实:
|
||||
|
||||
- 敏感字段脱敏
|
||||
- 前端上报白名单
|
||||
- 防止日志注入
|
||||
- 审计日志不可被普通管理员随意篡改
|
||||
- 高敏感纠错动作必须再次鉴权
|
||||
|
||||
## Success Criteria
|
||||
|
||||
当下面这些条件成立时,才算这套日志系统真的“成了”:
|
||||
|
||||
1. 一个后端请求失败时,能通过 `request_id` 在运行日志、持久化事件、审计日志之间串联查询
|
||||
2. 一个 Earth 前端错误能定位到页面、模块、事件名和上下文
|
||||
3. 一个采集器失败能同时看到运行日志、持久化事件和可执行纠错动作
|
||||
4. 一个管理员操作能查到操作者、目标对象、结果和 request_id
|
||||
5. 日志页不再只是文本控制台,而是完整的“运行日志 / 关键事件 / 审计日志”工作台
|
||||
6. 高频已知错误能聚合并给出修复建议
|
||||
|
||||
## Delivery Order
|
||||
|
||||
推荐严格按下面顺序做,不要乱跳:
|
||||
|
||||
1. Phase 0 命名与字段规范冻结
|
||||
2. Phase 1 后端结构化 logging 基础
|
||||
3. Phase 2 高价值事件持久化
|
||||
4. Phase 3 前端 / Earth 统一 logger
|
||||
5. Phase 4 审计覆盖补齐
|
||||
6. Phase 5 日志工作台 UI 重构
|
||||
7. Phase 6 指纹 / 纠错 / runbook
|
||||
|
||||
原因:
|
||||
|
||||
- 如果不先统一字段和命名,后面 UI 和持久化会越来越乱
|
||||
- 如果不先做后端结构化基础,前端上报再多也串不起来
|
||||
- 如果不先补持久化层,就只有“实时可看”,没有“历史可查”
|
||||
|
||||
## First Actionable Milestone
|
||||
|
||||
如果要从明天就开始做,最合理的第一个里程碑是:
|
||||
|
||||
### M1: 让后端关键路径全部拥有统一结构化事件
|
||||
|
||||
范围:
|
||||
|
||||
- API 请求入口/出口
|
||||
- scheduler
|
||||
- collectors
|
||||
- websocket
|
||||
- visualization
|
||||
- system control
|
||||
|
||||
交付物:
|
||||
|
||||
- 统一 logger helper
|
||||
- 统一 event naming 表
|
||||
- 统一 request_id 注入
|
||||
- 统一脱敏策略
|
||||
- 关键模块替换完成
|
||||
|
||||
完成这个里程碑后,Planet 才算真正拥有了“企业级日志系统的地基”。
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
- 前端上下文
|
||||
- Earth 前端结构
|
||||
- Earth 卫星 footprint 策略
|
||||
- Earth 渲染图层顺序
|
||||
- Earth 图层样式属性索引
|
||||
- 后端运行控制
|
||||
- collector 现状
|
||||
- 采集格式约定
|
||||
|
||||
229
docs/technical/earth-layer-style-reference.md
Normal file
229
docs/technical/earth-layer-style-reference.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Earth 图层样式属性索引
|
||||
|
||||
本文记录当前 Earth 前端各图层的材质、颜色、透明度、线宽、半径偏移和
|
||||
`renderOrder` 等样式属性。层级关系请配合
|
||||
[earth-render-layer-order.md](/home/ray/dev/linkong/planet/docs/technical/earth-render-layer-order.md)
|
||||
查看。
|
||||
|
||||
## 命名约定
|
||||
|
||||
| 类别 | 约定 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 全局配置对象 | `*_CONFIG` | `COUNTRY_BOUNDARY_CONFIG` |
|
||||
| 图层半径偏移 | `*AltitudeOffset` / `radiusOffset` | `lineAltitudeOffset`, `GRID_CONFIG.radiusOffset` |
|
||||
| 透明度 | `*Opacity` | `hoverLineOpacity` |
|
||||
| 渲染顺序 | `*RenderOrder` | `textureOverlayRenderOrder` |
|
||||
| 颜色 | `*Color`,十六进制数字或 CSS 色值 | `lineColor`, `colors.supercomputer` |
|
||||
| 线宽 | `lineWidth` / `*LineWidth` | `GRID_CONFIG.lineWidth` |
|
||||
|
||||
## Earth 基座与高清材质
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| Earth 基座半径 | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
|
||||
| Earth 基座颜色 | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
|
||||
| Earth 基座 emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
|
||||
| Earth 基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| Earth 基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| Earth 基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | 独立高清材质球半径 |
|
||||
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
|
||||
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
| 高清材质颜色乘色 | inline | `0xffffff` | `_earthTextureOverlayMaterial.color` |
|
||||
|
||||
## Earth 遮挡与昼夜
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 遮挡球半径系数 | `EARTH_MATERIAL_CONFIG.occluderRadiusFactor` | `0.999` | 深度遮挡球半径 |
|
||||
| 遮挡球分段 | `EARTH_MATERIAL_CONFIG.occluderSegments` | `48` | 遮挡球几何分段 |
|
||||
| 遮挡球 renderOrder | inline | `-1` | `occluder.renderOrder` |
|
||||
| 昼夜太阳方向 | `EARTH_MATERIAL_CONFIG.dayNight.sunDirection` | `{ x: 1, y: 0.2, z: 0.4 }` | 自定义 day/night shader |
|
||||
| 夜侧最低亮度 | `EARTH_MATERIAL_CONFIG.dayNight.nightFloor` | `0.32` | shader uniform |
|
||||
| 日侧增强 | `EARTH_MATERIAL_CONFIG.dayNight.dayBoost` | `0.94` | shader uniform |
|
||||
| 暮光宽度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightWidth` | `0.24` | shader uniform |
|
||||
| 暮光强度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity` | `0.14` | shader uniform |
|
||||
| 暮光颜色 | `EARTH_MATERIAL_CONFIG.dayNight.twilightColor` | `0x4ea0ff` | shader uniform |
|
||||
| 夜侧 tint 颜色 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintColor` | `0x0b1830` | shader uniform |
|
||||
| 夜侧 tint 强度 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity` | `0.05` | shader uniform |
|
||||
|
||||
## 大气辉光与云图
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 内层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosInnerRadiusFactor` | `1.01` | `atmosInnerGeo` |
|
||||
| 内层大气分段 | `EARTH_MATERIAL_CONFIG.atmosInnerSegments` | `64` | `atmosInnerGeo` |
|
||||
| 内层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosInnerColor` | `[0.25, 0.62, 1.0]` | shader RGB |
|
||||
| 内层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosInnerRimPower` | `3.2` | shader rim |
|
||||
| 内层大气强度 | `EARTH_MATERIAL_CONFIG.atmosInnerIntensity` | `0.18` | shader alpha multiplier |
|
||||
| 外层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosOuterRadiusFactor` | `1.016` | `atmosOuterGeo` |
|
||||
| 外层大气分段 | `EARTH_MATERIAL_CONFIG.atmosOuterSegments` | `48` | `atmosOuterGeo` |
|
||||
| 外层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosOuterColor` | `[0.18, 0.45, 0.9]` | shader RGB |
|
||||
| 外层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosOuterRimPower` | `5.0` | shader rim |
|
||||
| 外层大气强度 | `EARTH_MATERIAL_CONFIG.atmosOuterIntensity` | `0.02` | shader alpha multiplier |
|
||||
| 大气辉光 blending | inline | `THREE.AdditiveBlending` | `ShaderMaterial.blending` |
|
||||
| 大气辉光 renderOrder | inline | `1` | `atmosInner/Outer.renderOrder` |
|
||||
| 云图半径偏移 | `CLOUD_LAYER_CONFIG.radiusOffset` | `3` | 云层球半径 |
|
||||
| 云图分段 | `CLOUD_LAYER_CONFIG.widthSegments / heightSegments` | `64 / 64` | 云层球几何分段 |
|
||||
| 云图透明度 | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshPhongMaterial.opacity` |
|
||||
| 云图贴图 | `CLOUD_LAYER_CONFIG.textureUrl` | `"./assets/earth_clouds_1024.png"` | 云层贴图 |
|
||||
| 云图 blending | inline | `THREE.AdditiveBlending` | `MeshPhongMaterial.blending` |
|
||||
|
||||
## 海陆基座与国界
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 |
|
||||
| 海洋填充色 | local `OCEAN_HEX` | `0x010609` | 海陆基座 canvas 背景 |
|
||||
| 陆地填充色 | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | 海陆基座 canvas 陆地 |
|
||||
| 海陆基座透明度 | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
|
||||
| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` 半径 |
|
||||
| 海陆基座 renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
|
||||
| 海陆 mask 尺寸 | `landMaskWidth / landMaskHeight` | `2048 / 1024` | canvas / DataTexture 尺寸 |
|
||||
| 国界 tint 颜色 | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | 高清材质关闭时 tint |
|
||||
| 国界 tint 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.04` | `country-tint` 半径 |
|
||||
| 国界 tint renderOrder | `COUNTRY_BOUNDARY_CONFIG.tintRenderOrder` | `0.2` | `country-tint.renderOrder` |
|
||||
| 国界线颜色 | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | 普通国界线 |
|
||||
| 国界线透明度 | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | 普通国界线 opacity |
|
||||
| 国界线 hover 时压暗透明度 | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | hover 时普通国界线 opacity |
|
||||
| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.24` | 普通国界线半径 |
|
||||
| 国界线 renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | 普通国界线层级 |
|
||||
| 国界 hover 颜色 | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | 霓虹红橘 |
|
||||
| 国界 hover 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | hover 实线 opacity |
|
||||
| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.32` | hover 实线半径 |
|
||||
| 国界 hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | hover 实线层级 |
|
||||
| 国界 hover glow 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | glow 线 opacity |
|
||||
| 国界 hover glow 线宽 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | glow `LineBasicMaterial.linewidth` |
|
||||
| 国界 hover glow 层级偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | glow renderOrder = `2.29` |
|
||||
| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | glow 半径 = hover 半径 + 0.04 |
|
||||
|
||||
## 真实地形
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 地形 tile size | `TERRAIN_CONFIG.tileSize` | `256` | Terrarium tile 读取 |
|
||||
| 地形 base zoom | `TERRAIN_CONFIG.baseZoom` | `4` | 地形采样 zoom |
|
||||
| 地形几何分段 | `geometryWidthSegments / geometryHeightSegments` | `320 / 320` | 地形球几何 |
|
||||
| 地形基准半径偏移 | `TERRAIN_CONFIG.baseRadiusOffset` | `0.16` | 地形压过高清材质 |
|
||||
| 地形夸张系数 | `TERRAIN_CONFIG.exaggeration` | `34` | 海拔转世界单位 |
|
||||
| 地形陆地淡入高度 | `TERRAIN_CONFIG.landRevealFadeMeters` | `220` | 顶点 alpha |
|
||||
| 地形透明度 | `TERRAIN_CONFIG.opacity` | `0.62` | `MeshPhongMaterial.opacity` |
|
||||
| 地形颜色 | `TERRAIN_CONFIG.color` | `0x7f9d7f` | `MeshPhongMaterial.color` |
|
||||
| 地形 emissive | `TERRAIN_CONFIG.emissive` | `0x061008` | `MeshPhongMaterial.emissive` |
|
||||
| 地形 specular | `TERRAIN_CONFIG.specular` | `0x233126` | `MeshPhongMaterial.specular` |
|
||||
| 地形 shininess | `TERRAIN_CONFIG.shininess` | `10` | `MeshPhongMaterial.shininess` |
|
||||
| 地形 renderOrder | inline | `1.2` | `terrain.renderOrder` |
|
||||
| 地形 polygonOffset | inline | `factor -1`, `units -1` | 降低贴近球面时的闪烁 |
|
||||
|
||||
## 经纬线
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 经纬线半径偏移 | `GRID_CONFIG.radiusOffset` | `0.14` | 经纬线球面半径 |
|
||||
| 经纬线颜色 | `GRID_CONFIG.color` | `0xc0e0ff` | `LineBasicMaterial.color` |
|
||||
| 经纬线透明度 | `GRID_CONFIG.opacity` | `0.08` | `LineBasicMaterial.opacity` |
|
||||
| 经纬线线宽 | `GRID_CONFIG.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| 经纬线 renderOrder | `GRID_CONFIG.renderOrder` | `2.05` | 经纬线层级 |
|
||||
| 纬线间隔 | `GRID_CONFIG.latitudeStep` | `15` | 纬线生成步长 |
|
||||
| 经线间隔 | `GRID_CONFIG.longitudeStep` | `30` | 经线生成步长 |
|
||||
| 线段采样步长 | `GRID_CONFIG.segmentStep` | `5` | 经纬线采样步长 |
|
||||
|
||||
## 海缆与登陆点
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 默认海缆颜色 | `CABLE_COLORS.default` | `0xffff44` | 无数据颜色时使用 |
|
||||
| 海缆半径偏移 | `CABLE_CONFIG.line.altitudeOffset` | `0.2` | 海缆线半径 |
|
||||
| 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity |
|
||||
| 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 |
|
||||
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.1` | 登陆点球位置 |
|
||||
| 登陆点半径 | `CABLE_CONFIG.landingPoint.radius` | `0.4` | 登陆点球几何 |
|
||||
| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `2.5` | 登陆点缩放 |
|
||||
| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `MeshStandardMaterial.color` |
|
||||
| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | `MeshStandardMaterial.emissive` |
|
||||
| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | `emissiveIntensity` |
|
||||
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `MeshStandardMaterial.opacity` |
|
||||
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
|
||||
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.3` | dim 状态 |
|
||||
|
||||
## 卫星、轨迹和 footprint
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 卫星显示半径偏移 | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | 卫星点位置 |
|
||||
| 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size |
|
||||
| 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 |
|
||||
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | 呼吸动画 |
|
||||
| 卫星点呼吸速度 | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | 点 opacity 动画 |
|
||||
| 卫星背景点颜色 | inline | `0x0b1626` | backdrop point baseColor |
|
||||
| 卫星背景点透明度 | inline | `0.42` | backdrop point opacity |
|
||||
| 卫星点透明度 | inline | `0.9` | point material opacity |
|
||||
| 卫星背景点 renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
|
||||
| 卫星点 renderOrder | inline | `6` | `satellitePoints.renderOrder` |
|
||||
| 卫星轨迹长度 | `SATELLITE_CONFIG.trailLength` | `10` | trail buffer |
|
||||
| 卫星轨迹线宽 | `SATELLITE_CONFIG.trailLineWidth` | `3` | ribbon shader uniform |
|
||||
| 选中 ring 大小 | `SATELLITE_CONFIG.ringSize` | `0.07` | hover / locked ring sprite |
|
||||
| 卫星覆盖层 renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | locked ring / halo / orbit |
|
||||
| 自发光选中点颜色 | inline default | `"#ffd25a"` | `showSelfGlowStyle()` |
|
||||
| 自发光选中点透明度 | inline | `0.96` | locked dot material |
|
||||
| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | footprint fill |
|
||||
| footprint group renderOrder | inline | `0` | 避免 Group 排序盖过卫星点 |
|
||||
|
||||
## 算力中心
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 |
|
||||
| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` |
|
||||
| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 超算 marker |
|
||||
| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker |
|
||||
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover 状态 |
|
||||
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked 状态 |
|
||||
| dimmed 缩放 / 透明度 | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | dim 状态 |
|
||||
| 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | marker texture |
|
||||
| GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | marker texture |
|
||||
| 关联颜色 | `COMPUTE_CENTER_CONFIG.colors.linked` | `"#f8fafc"` | 关联态 |
|
||||
| 算力中心 renderOrder | local `COMPUTE_CENTER_RENDER_ORDER` | `4.5` | 地表设施低于卫星点 |
|
||||
|
||||
## BGP 观测
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `2.1` | anomaly marker |
|
||||
| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | collector marker |
|
||||
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | anomaly sprite |
|
||||
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | collector plane |
|
||||
| hover / dim 缩放 | `hoverScale / dimmedScale` | `1.16 / 0.92` | 交互状态 |
|
||||
| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | anomaly sprite |
|
||||
| hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 |
|
||||
| dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 |
|
||||
| collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 |
|
||||
| critical 颜色 | `BGP_CONFIG.severityColors.critical` | `0xff4d4f` | 严重事件 |
|
||||
| high 颜色 | `BGP_CONFIG.severityColors.high` | `0xff9f43` | 高危事件 |
|
||||
| medium 颜色 | `BGP_CONFIG.severityColors.medium` | `0xffd166` | 中危事件 |
|
||||
| low 颜色 | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | 低危事件 |
|
||||
| collector 基础色 | `BGP_CONFIG.collectorColor` | `0x6db7ff` | collector 默认色 |
|
||||
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 |
|
||||
| BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring |
|
||||
| BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring |
|
||||
| collector marker renderOrder | inline | `3` | `marker.renderOrder` |
|
||||
| anomaly marker renderOrder | inline | `5` normal, `7` active | `marker.renderOrder` |
|
||||
|
||||
## 天体与星空
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 天球半径 | `CELESTIAL_CONFIG.skyRadius` | `2600` | 天体背景 |
|
||||
| 天球透明度 | `CELESTIAL_CONFIG.skyOpacity` | `1` | 背景材质 |
|
||||
| 太阳距离 / 缩放 | `sunDistance / sunScale` | `2150 / 78` | 太阳 sprite |
|
||||
| 月亮距离 / 缩放 | `moonDistance / moonScale` | `2050 / 38` | 月亮 sprite |
|
||||
| 太阳 halo 缩放 | `CELESTIAL_CONFIG.sunHaloScale` | `136` | 太阳 halo |
|
||||
| 月亮 halo 缩放 | `CELESTIAL_CONFIG.moonHaloScale` | `62` | 月亮 halo |
|
||||
| 太阳光颜色 / 强度 | `sunLightColor / sunLightIntensity` | `0xfff4df / 1.02` | scene light |
|
||||
| 背光颜色 / 强度 | `backLightColor / backLightIntensity` | `0x2b4c78 / 0.3` | scene light |
|
||||
| 星空点数量 | `STARFIELD_CONFIG.count` | `8000` | `createStars()` |
|
||||
| 星空半径范围 | `minRadius + radiusJitter` | `800 + 200` | 随机分布 |
|
||||
| 星空点颜色 | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
|
||||
| 星空点大小 | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |
|
||||
|
||||
57
docs/technical/earth-render-layer-order.md
Normal file
57
docs/technical/earth-render-layer-order.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Earth 渲染图层顺序
|
||||
|
||||
本文记录当前 Earth 渲染器的图层顺序和每层意图。后续调整
|
||||
`renderOrder`、半径偏移、深度策略或指针交互时,需要同步更新这里。
|
||||
|
||||
注意:图层控制面板顺序和注册 / 启动加载顺序是两套语义。
|
||||
|
||||
| 顺序类型 | 当前顺序 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → BGP → 地形 → 高清材质 → 大气云图 → 国界 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 |
|
||||
| 注册 / 启动加载顺序 | 经纬线 → 国界 → 高清材质 → 大气云图 → 海缆 → 算力中心 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 |
|
||||
|
||||
## 地表图层栈
|
||||
|
||||
| 顺序 | 图层 | 来源 | 渲染 / 半径策略 | 深度 / 交互策略 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| -1000 | 天体背景 mesh | `celestial.js` | 背景球 | 不参与地表拾取 | 位于所有 Earth 内容之后。 |
|
||||
| -1 | Earth 遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0 | Earth 基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
|
||||
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 |
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
|
||||
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
|
||||
| 1 | 海缆 | `cables.js` | `CABLE_CONFIG.line.renderOrder` | 海缆拾取路径 | 保持现有海缆层级。 |
|
||||
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
|
||||
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
|
||||
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset` | 禁用 raycast | 只保证压过高清材质。 |
|
||||
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
|
||||
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
|
||||
| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested,Group renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 |
|
||||
| 3-5 | BGP 标记和覆盖层 | `bgp.js` | 各 marker 自身 renderOrder | BGP 拾取路径 | 保持现有 BGP 视觉层级。 |
|
||||
| 4.5 | 算力中心 | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | 算力中心拾取路径 | 地表设施,保持在卫星下方。 |
|
||||
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 |
|
||||
| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
|
||||
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
|
||||
|
||||
## 开关联动
|
||||
|
||||
| 开关 | 行为 |
|
||||
| --- | --- |
|
||||
| 高清材质 off | 隐藏高清材质,启用国界 tint / 基座表面,禁用地形和昼夜开关交互,并记住地形和昼夜之前状态。 |
|
||||
| 高清材质 on | 恢复高清材质,并恢复记住的地形 / 昼夜状态。 |
|
||||
| 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 |
|
||||
| 大气云图 | 只控制云图 mesh 显隐。 |
|
||||
| 国界 | 控制国界线和 hover 线显隐;海陆基座填充独立存在,作为 Earth 基座地图使用。 |
|
||||
|
||||
## 交互规则
|
||||
|
||||
| 交互 | 当前规则 |
|
||||
| --- | --- |
|
||||
| Earth 坐标 hover | 高清材质可见时使用高清材质 overlay 作为地表拾取目标,否则使用 Earth 基座球。 |
|
||||
| 国界 hover | 先把地表拾取坐标转成经纬度,再用 GeoJSON 点面判断;国界 hover 线本身不接收 raycast。 |
|
||||
| 国界 hover 视觉 | hover 时压暗普通国界线,并绘制无深度测试的光晕和实线。 |
|
||||
| 中国 / 台湾 hover | `CHN` 和 `TWN` 被归到同一个 hover 高亮组;tooltip 仍显示鼠标实际命中的 feature。 |
|
||||
| 地形 | 只作为视觉层参与,`terrain.raycast` 已禁用。 |
|
||||
| 卫星 | 使用屏幕空间卫星拾取,避免 footprint 或地表层挡住卫星点击。 |
|
||||
198
docs/technical/earth-satellite-footprint-policy.md
Normal file
198
docs/technical/earth-satellite-footprint-policy.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Earth Satellite Footprint Policy
|
||||
|
||||
本文件记录 Earth 卫星图层当前关于 `footprint` 的产品边界、资料依据和已落地实现,目标是避免把 Starlink 这套专用地表覆盖模型误用到其它星座上。
|
||||
|
||||
相关上下文:
|
||||
|
||||
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)
|
||||
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/backend-collectors.md)
|
||||
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
|
||||
## 当前目标
|
||||
|
||||
- 明确哪些非 Starlink 卫星不该显示贴地 footprint
|
||||
- 明确哪些星座未来可以有独立 footprint,但不能复用 Starlink bowtie / GSO-gap 模型
|
||||
- 把这条策略沉淀成可执行实现边界,而不是继续散落在视觉参数里
|
||||
|
||||
## 本地实际类别
|
||||
|
||||
当前 CelesTrak 卫星分组在 [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py) 中包括:
|
||||
|
||||
- `starlink`
|
||||
- `gps-ops`
|
||||
- `galileo`
|
||||
- `glonass`
|
||||
- `beidou`
|
||||
- `leo`
|
||||
- `geo`
|
||||
- `iridium-next`
|
||||
|
||||
其中非 Starlink 类别是:
|
||||
|
||||
- `gps-ops`
|
||||
- `galileo`
|
||||
- `glonass`
|
||||
- `beidou`
|
||||
- `leo`
|
||||
- `geo`
|
||||
- `iridium-next`
|
||||
|
||||
## 资料结论
|
||||
|
||||
### 1. GNSS / RNSS: `gps-ops`, `galileo`, `glonass`, `beidou`
|
||||
|
||||
默认不要画局部地表 footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- 公开资料强调的是 `Earth-pointing`、`Earth coverage`、`continuous global coverage`
|
||||
- 这类系统的公开语义是全球导航 / 授时覆盖,不是 Starlink 那种面向终端业务的局部 spot footprint
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认只显示卫星本体和轨道
|
||||
- 如果后续要强调“服务可达性”,只能做很弱的 global coverage 语义,不应画贴地局部光斑
|
||||
|
||||
资料:
|
||||
|
||||
- [GPS III EC Antenna Patterns](https://www.navcen.uscg.gov/sites/default/files/pdf/gps/GPS_ZIP/GPS_III_EC_Antenna_Patterns_SVN_74_75_76_77_78.pdf)
|
||||
- [ESA Galileo satellites](https://www.esa.int/Applications/Satellite_navigation/Galileo/Galileo_satellites)
|
||||
- [Navipedia Galileo General Introduction](https://gssc.esa.int/navipedia/index.php/Galileo_General_Introduction)
|
||||
- [BeiDou official overview](https://www.beidou.gov.cn/xt/gfxz/201812/P020190117356387956569.pdf)
|
||||
- [GPS.gov GNSS overview](https://www.gps.gov/systems/gnss/)
|
||||
|
||||
### 2. `iridium-next`
|
||||
|
||||
可以有 footprint,但不能复用 Starlink 的单一 bowtie footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- Iridium NEXT 公开资料强调的是固定多 spot beam 体系
|
||||
- 公开示例里常见的是 `48 fixed spot beams in 4 tiers`
|
||||
- 这和 Starlink 当前这套“单星、单主 footprint、带 GSO 缺口”的业务可视化不是同一个问题
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认:仍然不画 Starlink 式地表 footprint
|
||||
- 后续如果要做:单独接入 Iridium 多波束适配层
|
||||
- 在视觉上更接近多束 cluster / 蜂窝 / 分层束,而不是单个 bowtie 光斑
|
||||
|
||||
资料:
|
||||
|
||||
- [Iridium Satellite Spot Beam Coverage on the US](https://www.mathworks.com/help/phased/ug/iridium-satellite-spot-beam-coverage-on-the-us-1.html)
|
||||
|
||||
### 3. `geo`
|
||||
|
||||
默认不要画统一 footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- GEO 通信星公开上可能是 global beam、zone beam、spot beam、steerable spot beam
|
||||
- 没有 operator / payload / beam contour 元数据时,统一画一个 footprint 很容易错
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认只显示 GEO belt 和卫星驻点语义
|
||||
- 只有拿到 beam contour / operator metadata 时才允许画 footprint
|
||||
|
||||
资料:
|
||||
|
||||
- [ITU Handbook on Satellite](https://www.itu.int/dms_pub/itu-r/opb/hdb/R-HDB-42-2002-PDF-E.pdf)
|
||||
|
||||
### 4. `leo`(generic)
|
||||
|
||||
默认不要画 footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- `leo` 组过于混杂,可能同时包含通信、遥感、试验、观测等不同任务
|
||||
- 没有 mission / payload / antenna pattern 元数据时,无法判断是否存在可视化意义上的服务覆盖面
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认只显示卫星和轨道
|
||||
- 后续如果按 operator / mission subtype 细分,再决定是否引入独立 coverage mode
|
||||
|
||||
## 产品策略
|
||||
|
||||
当前统一策略如下:
|
||||
|
||||
- `Starlink`
|
||||
- 保留当前专用 `ground_footprint` 逻辑
|
||||
- `Iridium NEXT`
|
||||
- 预留独立适配层
|
||||
- 当前不复用 Starlink footprint
|
||||
- `GPS / Galileo / GLONASS / BeiDou`
|
||||
- 不显示贴地 footprint
|
||||
- `GEO`
|
||||
- 无 beam metadata 不显示 footprint
|
||||
- `generic LEO`
|
||||
- 无 mission metadata 不显示 footprint
|
||||
|
||||
## 已落地实现
|
||||
|
||||
本次实现只做最小可执行版本,不改现有 Starlink 视觉参数:
|
||||
|
||||
1. 后端把星座分组和 footprint 策略提示透给前端
|
||||
|
||||
- CelesTrak collector 会把 `GROUP` 记入 `metadata.constellation_group`
|
||||
- Visualization API 会输出:
|
||||
- `properties.constellation_group`
|
||||
- `properties.footprint_policy`
|
||||
|
||||
当前策略值:
|
||||
|
||||
- `starlink_ground_footprint`
|
||||
- `iridium_coverage_ring`
|
||||
- `none`
|
||||
|
||||
对应代码:
|
||||
|
||||
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
|
||||
- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)
|
||||
|
||||
2. 前端把 footprint 变成 capability-gated renderer
|
||||
|
||||
- `ground_footprint` 只有在 `footprint_policy === starlink_ground_footprint` 时才真正启用
|
||||
- `iridium-next` 不再回退成占位分支,而是走独立的 Iridium coverage ring adapter
|
||||
- 其它非 Starlink 即使用户全局选择了 `ground_footprint`,也会自动回退到 `self_glow`
|
||||
|
||||
对应代码:
|
||||
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [frontend/public/earth/js/iridium-footprint-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/iridium-footprint-adapter.js)
|
||||
|
||||
3. 卫星信息卡显示 capability,而不是只显示轨道参数
|
||||
|
||||
- 卫星详情现在会明确显示:
|
||||
- `星座/分组`
|
||||
- `覆盖能力`
|
||||
- `当前显示`
|
||||
- `覆盖模型`
|
||||
- 这样用户能直接看到:
|
||||
- 当前卫星是否支持 footprint
|
||||
- 当前显示是不是因为 capability gating 被回退
|
||||
- Iridium 和 Starlink 使用的不是同一种模型
|
||||
|
||||
对应代码:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
|
||||
|
||||
## 当前实现边界
|
||||
|
||||
这条边界需要继续保持:
|
||||
|
||||
- `Starlink` 的 footprint 参数和 shader 逻辑只服务于 Starlink
|
||||
- 非 Starlink 的能力判断属于“策略层 / 适配层”
|
||||
- 不要把不同星座的覆盖模型再混写进同一套参数里
|
||||
- `iridium-next` 已经切成独立 adapter,应继续沿这条边界演进,而不是给现有 Starlink bowtie 增加更多 if/else
|
||||
|
||||
## 后续建议
|
||||
|
||||
如果继续往前做,推荐顺序是:
|
||||
|
||||
1. 为 `iridium-next` 新建独立 footprint adapter
|
||||
2. 在 UI 上补一个只读提示,让用户知道当前卫星是否支持 footprint
|
||||
3. 如果未来拿到 GEO beam contour / operator metadata,再为 GEO 开 operator-specific footprint
|
||||
@@ -16,12 +16,23 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.37.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.41.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.41.0` | feature | `dev` | `pending` | Earth 图层顺序拆分、基座海陆色块、国界交互、高清材质/云图/地形层级与样式文档落地 |
|
||||
| `0.40.5` | improvement | `dev` | `pending` | 卫星 ribbon 拖尾、Iridium 覆盖球面投影填充+外圈、搜索自动聚焦修复 |
|
||||
| `0.40.4` | bugfix | `dev` | `pending` | 修复页面后台恢复后卫星轨迹跳变与位置错位,统一轨迹重置路径 |
|
||||
| `0.40.3` | improvement | `dev` | `pending` | 卫星点云升级 ShaderMaterial,修复锁定环 depthTest 与位置漂移,新增悬停态缩放 |
|
||||
| `0.40.2` | improvement | `dev` | `pending` | 卫星点大小随镜头缩放动态调整,调小默认基础尺寸 |
|
||||
| `0.40.1` | improvement | `dev` | `pending` | 卫星选中标记配色跟随图例,修复 footprint 遮蔽卫星渲染问题,修复选中海缆误触发卫星高亮 |
|
||||
| `0.40.0` | feature | `dev` | `pending` | Earth 卫星 footprint 按星座能力分层,Iridium 独立 coverage ring 落地,卫星详情卡补齐覆盖能力与当前显示说明 |
|
||||
| `0.39.0` | feature | `dev` | `pending` | 后端统一结构化日志地基落地,系统日志页重构为紧凑日志工作台,并修复 Earth 移动端态势抽屉与新闻详情同步问题 |
|
||||
| `0.38.0` | feature | `dev` | `pending` | Earth 新闻接入通用巡航与专用卡片链路,系统日志页升级为结构化时间/级别过滤与真正字符串检索 |
|
||||
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |
|
||||
| `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh` 在 `uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 |
|
||||
| `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector,统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
|
||||
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
|
||||
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.37.0",
|
||||
"version": "0.41.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -421,8 +421,10 @@
|
||||
|
||||
.earth-mobile-drawer-sheet {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(240px + var(--safe-bottom));
|
||||
max-height: min(78vh, calc(100vh - 72px - var(--safe-top)));
|
||||
height: min(78vh, calc(100vh - 72px - var(--safe-top)));
|
||||
padding: 10px 14px calc(14px + var(--safe-bottom)) 14px;
|
||||
border-top-left-radius: 28px;
|
||||
border-top-right-radius: 28px;
|
||||
@@ -438,6 +440,8 @@
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-header {
|
||||
order: 1;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 4px 8px;
|
||||
cursor: ns-resize;
|
||||
user-select: none;
|
||||
@@ -452,45 +456,173 @@
|
||||
background: rgba(225, 239, 255, 0.18);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs {
|
||||
.earth-mobile-drawer-nav {
|
||||
order: 3;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
margin-top: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(214, 231, 247, 0.08);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-nav-copy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-nav-kicker {
|
||||
color: rgba(167, 194, 223, 0.58);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-nav-title {
|
||||
color: var(--hud-title);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-shell {
|
||||
position: relative;
|
||||
padding: 8px 8px 2px;
|
||||
border: 1px solid rgba(207, 226, 245, 0.06);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)),
|
||||
rgba(6, 16, 31, 0.32);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 10px 22px rgba(2, 8, 18, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-fade {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 22px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-fade--left {
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0));
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-fade--right {
|
||||
right: 0;
|
||||
background: linear-gradient(270deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0));
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab {
|
||||
min-height: 40px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
flex: 0 0 auto;
|
||||
min-width: 68px;
|
||||
min-height: 58px;
|
||||
padding: 8px 11px 9px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.06);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015)),
|
||||
rgba(7, 18, 34, 0.38);
|
||||
color: var(--hud-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
scroll-snap-align: start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab.is-active {
|
||||
color: var(--hud-title);
|
||||
background: rgba(122, 180, 255, 0.14);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(111, 174, 255, 0.18), rgba(74, 126, 210, 0.08)),
|
||||
rgba(10, 26, 52, 0.58);
|
||||
border-color: rgba(122, 180, 255, 0.22);
|
||||
box-shadow:
|
||||
0 8px 18px rgba(11, 22, 40, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab:hover {
|
||||
color: var(--hud-text);
|
||||
border-color: rgba(201, 225, 247, 0.14);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font-size: 17px;
|
||||
color: rgba(221, 235, 248, 0.88);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab.is-active .earth-mobile-drawer-tab-icon {
|
||||
color: #f6fbff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(161, 207, 255, 0.26), rgba(95, 154, 237, 0.18)),
|
||||
rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-content {
|
||||
order: 2;
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
max-height: calc(78vh - 108px - var(--safe-bottom));
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-slot {
|
||||
display: none;
|
||||
min-height: 0;
|
||||
max-height: inherit;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-slot.is-active {
|
||||
@@ -502,6 +634,15 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-slot--situation,
|
||||
.earth-mobile-page,
|
||||
.earth-mobile-stats-grid,
|
||||
.earth-mobile-situation-card,
|
||||
.earth-mobile-situation-legend-list {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-slot--situation.is-active {
|
||||
display: grid;
|
||||
}
|
||||
@@ -615,6 +756,14 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-page--tv {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--tv .earth-mobile-page-intro {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.earth-mobile-page-intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -634,6 +783,11 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.earth-mobile-page--tv .earth-mobile-page-summary {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.earth-mobile-layer-list,
|
||||
.earth-mobile-news-board-list,
|
||||
.earth-mobile-search-results {
|
||||
@@ -673,6 +827,12 @@
|
||||
rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.earth-mobile-layer-card.is-disabled,
|
||||
.earth-mobile-layer-card:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.earth-mobile-layer-card-icon {
|
||||
font-size: 22px;
|
||||
color: var(--hud-accent-strong);
|
||||
@@ -765,6 +925,10 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stats-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-stat-card,
|
||||
.earth-mobile-situation-card,
|
||||
.earth-mobile-news-focus,
|
||||
@@ -785,12 +949,23 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-card {
|
||||
min-width: 0;
|
||||
padding: 12px 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.earth-mobile-stat-num {
|
||||
color: var(--hud-title);
|
||||
font-size: 1.26rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-num {
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.earth-mobile-stat-label,
|
||||
.earth-mobile-situation-card-subtitle,
|
||||
.earth-mobile-news-focus-kicker,
|
||||
@@ -802,6 +977,52 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-label {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-height: 540px) {
|
||||
.earth-mobile-page--situation {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-page-intro {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-page-kicker {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-page-summary {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stats-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-card {
|
||||
padding: 10px 10px;
|
||||
gap: 3px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-num {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-label {
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
}
|
||||
|
||||
.earth-mobile-situation-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -818,11 +1039,28 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-situation-status {
|
||||
color: var(--hud-text);
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.earth-mobile-situation-legend-list .legend-item,
|
||||
.earth-mobile-situation-legend-list .legend-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-situation-legend-list .legend-label {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.earth-mobile-news-focus,
|
||||
@@ -857,17 +1095,124 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-kicker {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-headline {
|
||||
color: var(--hud-title);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-summary {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tags {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tag--status {
|
||||
color: var(--hud-accent-strong);
|
||||
border-color: rgba(122, 180, 255, 0.2);
|
||||
background: rgba(122, 180, 255, 0.12);
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-bar:focus-visible {
|
||||
outline: 2px solid rgba(122, 180, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-meta-wrap {
|
||||
overflow: hidden;
|
||||
max-height: 240px;
|
||||
opacity: 1;
|
||||
margin-top: 10px;
|
||||
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-meta-wrap.is-collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-select {
|
||||
border: 1px solid rgba(201, 225, 247, 0.14);
|
||||
border-radius: 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--hud-text);
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-player {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(201, 225, 247, 0.1);
|
||||
@@ -912,6 +1257,28 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.earth-mobile-action-btn:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earth-mobile-action-btn--compact {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(212, 227, 244, 0.14);
|
||||
color: var(--hud-text);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.earth-mobile-action-btn--compact .material-symbols-rounded {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.earth-mobile-action-btn--ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(212, 227, 244, 0.1);
|
||||
@@ -961,6 +1328,43 @@
|
||||
background: rgba(122, 180, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip {
|
||||
border: 1px solid rgba(212, 227, 244, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--hud-text-soft);
|
||||
padding: 9px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%),
|
||||
linear-gradient(180deg, rgba(122, 180, 255, 0.16), rgba(82, 123, 186, 0.22));
|
||||
}
|
||||
|
||||
.earth-mobile-settings-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -1003,6 +1407,12 @@
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
label.is-disabled.earth-mobile-settings-card {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1042,6 +1452,81 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-kicker {
|
||||
color: rgba(255, 215, 122, 0.82);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-title {
|
||||
color: var(--hud-title);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary-shell {
|
||||
position: relative;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid rgba(255, 215, 122, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
|
||||
rgba(7, 15, 29, 0.42);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
|
||||
opacity: 0.42;
|
||||
transform: translateX(-100%);
|
||||
animation: infoCardNewsScan 3.2s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary-label {
|
||||
color: rgba(188, 212, 238, 0.72);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary {
|
||||
color: #d7e6f7;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.72;
|
||||
min-height: 5.2em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary.is-typing::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.58em;
|
||||
height: 1.05em;
|
||||
margin-left: 0.16em;
|
||||
vertical-align: -0.14em;
|
||||
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
|
||||
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
|
||||
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.earth-mobile-detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1065,14 +1550,28 @@
|
||||
|
||||
.layout-mode-mobile .earth-status-message,
|
||||
.layout-mode-mobile .earth-error-message {
|
||||
top: calc(var(--safe-top) + 130px);
|
||||
position: fixed;
|
||||
top: calc(var(--safe-top) + 10px);
|
||||
left: auto;
|
||||
right: calc(8px + var(--safe-right));
|
||||
transform: translate(0, -10px);
|
||||
right: calc(10px + var(--safe-right));
|
||||
transform: translate(12px, 0);
|
||||
min-width: 0;
|
||||
max-width: min(200px, 52vw);
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 14px 6px 10px;
|
||||
max-width: min(220px, 46vw);
|
||||
border-radius: 16px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.28;
|
||||
padding: 7px 12px 7px 10px;
|
||||
gap: 8px;
|
||||
border-color: rgba(214, 230, 247, 0.1);
|
||||
border-left-color: transparent;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.06), transparent 52%),
|
||||
linear-gradient(180deg, rgba(17, 29, 46, 0.92), rgba(7, 14, 24, 0.9));
|
||||
box-shadow:
|
||||
0 12px 28px rgba(0, 0, 0, 0.22),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-message.visible,
|
||||
@@ -1080,6 +1579,34 @@
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-error-message {
|
||||
top: calc(var(--safe-top) + 60px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-indicator {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
box-shadow:
|
||||
0 0 6px rgba(145, 186, 255, 0.48),
|
||||
0 0 14px rgba(145, 186, 255, 0.16);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-message.loading {
|
||||
max-width: min(240px, 52vw);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-message.loading .earth-status-text {
|
||||
color: rgba(232, 242, 252, 0.92);
|
||||
}
|
||||
|
||||
.hud-panel-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1754,6 +2281,50 @@
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.earth-settings-chip-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.earth-settings-chip {
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
color: var(--hud-text-soft);
|
||||
padding: calc(6px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-chip:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-chip.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.earth-settings-slider {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
@@ -1919,6 +2490,12 @@
|
||||
transform: translateX(calc(16px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
.earth-settings-item.is-disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.earth-settings-sheet {
|
||||
top: 24px;
|
||||
|
||||
@@ -381,6 +381,113 @@
|
||||
}
|
||||
.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); }
|
||||
|
||||
.info-card.news .info-card-header {
|
||||
background: rgba(255, 196, 92, 0.12);
|
||||
border-bottom-color: rgba(255, 196, 92, 0.16);
|
||||
}
|
||||
.info-card.news .info-card-header h3 { color: #ffd77a; }
|
||||
|
||||
.info-card.news .info-card-content {
|
||||
padding-top: calc(10px * var(--hud-scale));
|
||||
padding-bottom: calc(12px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-layout {
|
||||
display: grid;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-kicker {
|
||||
color: rgba(255, 215, 122, 0.82);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-card-news-title {
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.96rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
font-weight: 700;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.info-card-news-summary-shell {
|
||||
position: relative;
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border: 1px solid rgba(255, 215, 122, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
|
||||
rgba(7, 15, 29, 0.42);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-card-news-summary-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
|
||||
opacity: 0.42;
|
||||
transform: translateX(-100%);
|
||||
animation: infoCardNewsScan 3.2s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-card-news-summary-label {
|
||||
color: rgba(188, 212, 238, 0.72);
|
||||
font-size: calc(0.6rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: calc(6px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-summary {
|
||||
position: relative;
|
||||
color: #d7e6f7;
|
||||
font-size: calc(0.8rem * var(--hud-scale));
|
||||
line-height: 1.65;
|
||||
min-height: calc(4.8em * var(--hud-scale));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.info-card-news-summary.is-typing::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.58em;
|
||||
height: 1.05em;
|
||||
margin-left: 0.16em;
|
||||
vertical-align: -0.14em;
|
||||
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
|
||||
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
|
||||
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes infoCardNewsScan {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes infoCardNewsCaret {
|
||||
0%, 49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layout-expanded: slide left column off-screen ────────────── */
|
||||
|
||||
.earth-app.layout-expanded .earth-left-column {
|
||||
|
||||
@@ -271,6 +271,16 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layer-row-toggle.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-label,
|
||||
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.layer-row-toggle-track::after {
|
||||
content: "";
|
||||
|
||||
@@ -135,6 +135,15 @@
|
||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.news-story-card--cruise {
|
||||
border-color: rgba(255, 213, 128, 0.42);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08));
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 213, 128, 0.18) inset,
|
||||
0 0 18px rgba(255, 184, 77, 0.12);
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
|
||||
1
frontend/public/earth/data/countries-admin0.min.geojson
Normal file
1
frontend/public/earth/data/countries-admin0.min.geojson
Normal file
File diff suppressed because one or more lines are too long
@@ -108,23 +108,13 @@
|
||||
|
||||
<!-- Layer rows -->
|
||||
<div class="layer-panel-list" id="layer-panel-list">
|
||||
<div class="layer-row" data-layer-name="地形 terrain">
|
||||
<span class="material-symbols-rounded layer-row-icon">landscape</span>
|
||||
<div class="layer-row" data-layer-name="海缆 subsea cables">
|
||||
<span class="material-symbols-rounded layer-row-icon">cable</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
<span class="layer-row-label">海缆</span>
|
||||
<span class="layer-row-meta">Subsea Cables</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">卫星</span>
|
||||
<span class="layer-row-meta">Satellites</span>
|
||||
</div>
|
||||
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
|
||||
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -138,13 +128,13 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="海缆 subsea cables">
|
||||
<span class="material-symbols-rounded layer-row-icon">cable</span>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">海缆</span>
|
||||
<span class="layer-row-meta">Subsea Cables</span>
|
||||
<span class="layer-row-label">卫星</span>
|
||||
<span class="layer-row-meta">Satellites</span>
|
||||
</div>
|
||||
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
|
||||
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -168,6 +158,56 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="地形 terrain">
|
||||
<span class="material-symbols-rounded layer-row-icon">landscape</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="高清材质 纹理 texture hd earth">
|
||||
<span class="material-symbols-rounded layer-row-icon">globe</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">高清材质</span>
|
||||
<span class="layer-row-meta">High-Res Texture</span>
|
||||
</div>
|
||||
<button id="toggle-earth-high-res-texture" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换高清材质显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="大气 云图 云层 clouds atmosphere">
|
||||
<span class="material-symbols-rounded layer-row-icon">cloud</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">大气云图</span>
|
||||
<span class="layer-row-meta">Cloud Layer</span>
|
||||
</div>
|
||||
<button id="toggle-atmosphere-clouds" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换大气云图显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="国界 国家 borders countries boundary">
|
||||
<span class="material-symbols-rounded layer-row-icon">public</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">国界</span>
|
||||
<span class="layer-row-meta">Country Borders</span>
|
||||
</div>
|
||||
<button id="toggle-country-boundaries" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换国界显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
|
||||
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">经纬线</span>
|
||||
<span class="layer-row-meta">Graticule</span>
|
||||
</div>
|
||||
<button id="toggle-grid-lines" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换经纬线显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty search state -->
|
||||
@@ -465,13 +505,41 @@
|
||||
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
|
||||
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
|
||||
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
|
||||
<div class="earth-mobile-drawer-nav">
|
||||
<div class="earth-mobile-drawer-nav-copy">
|
||||
<span class="earth-mobile-drawer-nav-kicker">Earth Menu</span>
|
||||
<span class="earth-mobile-drawer-nav-title">模块切换</span>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-tabs-shell">
|
||||
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--left" aria-hidden="true"></div>
|
||||
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--right" aria-hidden="true"></div>
|
||||
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
|
||||
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">layers</span>
|
||||
<span class="earth-mobile-drawer-tab-label">图层</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">search</span>
|
||||
<span class="earth-mobile-drawer-tab-label">搜索</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">travel_explore</span>
|
||||
<span class="earth-mobile-drawer-tab-label">态势</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">article</span>
|
||||
<span class="earth-mobile-drawer-tab-label">新闻</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
|
||||
<span class="earth-mobile-drawer-tab-label">TV</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
|
||||
<span class="earth-mobile-drawer-tab-label">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-content">
|
||||
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
|
||||
@@ -579,13 +647,6 @@
|
||||
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
|
||||
</div>
|
||||
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
|
||||
<div class="earth-mobile-tv-meta">
|
||||
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
|
||||
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
|
||||
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
|
||||
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-player">
|
||||
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
@@ -598,9 +659,32 @@
|
||||
></iframe>
|
||||
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-actions">
|
||||
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
|
||||
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
|
||||
<div id="mobile-tv-overview" class="earth-mobile-tv-overview">
|
||||
<div id="mobile-tv-overview-bar" class="earth-mobile-tv-overview-bar" role="button" tabindex="0" aria-expanded="false" aria-controls="mobile-tv-meta-wrap">
|
||||
<div class="earth-mobile-tv-overview-copy">
|
||||
<span class="earth-mobile-tv-overview-kicker">频道信息</span>
|
||||
<span id="mobile-tv-overview-headline" class="earth-mobile-tv-overview-headline">暂无可用频道</span>
|
||||
<span id="mobile-tv-overview-summary" class="earth-mobile-tv-overview-summary">展开查看当前频道来源、目录和补充说明</span>
|
||||
<div id="mobile-tv-overview-tags" class="earth-mobile-tv-overview-tags" aria-label="频道摘要标签"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-overview-actions">
|
||||
<button id="mobile-tv-refresh" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="刷新频道列表" title="刷新频道列表">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">refresh</span>
|
||||
</button>
|
||||
<button id="mobile-tv-open-external" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="访问频道官网" title="访问频道官网">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-tv-meta-wrap" class="earth-mobile-tv-meta-wrap">
|
||||
<div class="earth-mobile-tv-meta">
|
||||
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
|
||||
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
|
||||
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
|
||||
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -615,13 +699,36 @@
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">旋转模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
<span class="earth-mobile-settings-subtitle">巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">巡航模块</span>
|
||||
<span class="earth-mobile-settings-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻可按需加入。</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-cruise-module-toggle="bgp" aria-pressed="true">BGP</button>
|
||||
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="news" aria-pressed="false">新闻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">卫星</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">卫星显示风格</span>
|
||||
<span class="earth-mobile-settings-subtitle">可选自身发光或真实地表覆盖两种选中表现</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择卫星显示风格">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="self_glow" aria-pressed="true">自身发光</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="ground_footprint" aria-pressed="false">真实地表覆盖</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">视图</div>
|
||||
@@ -766,7 +873,7 @@
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">旋转模式</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||
<button
|
||||
@@ -787,6 +894,54 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">巡航模块</span>
|
||||
<span class="earth-settings-item-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻会按发生地与时间加入巡航目标并显示新闻卡片。</span>
|
||||
</div>
|
||||
<div class="earth-settings-chip-group" role="group" aria-label="选择巡航模块">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-chip is-active"
|
||||
data-cruise-module-toggle="bgp"
|
||||
aria-pressed="true"
|
||||
>
|
||||
BGP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-chip"
|
||||
data-cruise-module-toggle="news"
|
||||
aria-pressed="false"
|
||||
>
|
||||
新闻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">卫星显示风格</span>
|
||||
<span class="earth-settings-item-subtitle">选择卫星锁定态使用自身发光,还是强调真实地表覆盖范围。</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn is-active"
|
||||
data-satellite-display-style="self_glow"
|
||||
aria-pressed="true"
|
||||
>
|
||||
自身发光
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn"
|
||||
data-satellite-display-style="ground_footprint"
|
||||
aria-pressed="false"
|
||||
>
|
||||
真实地表覆盖
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
|
||||
96
frontend/public/earth/js/client-logs.js
Normal file
96
frontend/public/earth/js/client-logs.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { PATHS } from "./constants.js";
|
||||
|
||||
const RECENT_EVENT_TTL_MS = 15_000;
|
||||
const recentEventMap = new Map();
|
||||
|
||||
function normalizeErrorDetail(detail) {
|
||||
if (!detail) return "";
|
||||
if (detail instanceof Error) {
|
||||
return detail.stack || detail.message || String(detail);
|
||||
}
|
||||
if (typeof detail === "string") {
|
||||
return detail;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(detail);
|
||||
} catch {
|
||||
return String(detail);
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeKey(level, message, detail, category) {
|
||||
return `${level}::${category || ""}::${message}::${detail}`;
|
||||
}
|
||||
|
||||
function shouldSkip(level, message, detail, category) {
|
||||
const key = dedupeKey(level, message, detail, category);
|
||||
const now = Date.now();
|
||||
const lastSeenAt = recentEventMap.get(key);
|
||||
recentEventMap.set(key, now);
|
||||
|
||||
for (const [entryKey, entryTime] of recentEventMap.entries()) {
|
||||
if (now - entryTime > RECENT_EVENT_TTL_MS) {
|
||||
recentEventMap.delete(entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
return lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS;
|
||||
}
|
||||
|
||||
export async function reportEarthClientLog({
|
||||
level = "error",
|
||||
message,
|
||||
category = "runtime",
|
||||
module = "earth",
|
||||
detail = "",
|
||||
}) {
|
||||
if (!message) return;
|
||||
const normalizedDetail = normalizeErrorDetail(detail);
|
||||
if (shouldSkip(level, message, normalizedDetail, category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(PATHS.earthClientLogsApi, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
category,
|
||||
module,
|
||||
url: window.location.href,
|
||||
detail: normalizedDetail.slice(0, 4000),
|
||||
}),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Swallow reporting failures to avoid recursive log noise.
|
||||
}
|
||||
}
|
||||
|
||||
export function registerEarthClientErrorHandlers() {
|
||||
window.addEventListener("error", (event) => {
|
||||
console.error("全局错误:", event.error);
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "window-error",
|
||||
module: "main",
|
||||
message: event.message || "Earth 页面发生未捕获错误",
|
||||
detail: event.error || `${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
console.error("未处理的 Promise 错误:", event.reason);
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "unhandledrejection",
|
||||
module: "main",
|
||||
message: "Earth 页面发生未处理 Promise 错误",
|
||||
detail: event.reason,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const computeCenterGroup = new THREE.Group();
|
||||
const computeCenterMarkers = [];
|
||||
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
||||
const textureCache = new Map();
|
||||
let showComputeCenters = true;
|
||||
let supercomputerCount = 0;
|
||||
@@ -196,7 +197,7 @@ function createComputeCenterMarker(markerData) {
|
||||
),
|
||||
);
|
||||
marker.scale.setScalar(baseScale);
|
||||
marker.renderOrder = 8;
|
||||
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER;
|
||||
marker.visible = showComputeCenters;
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
|
||||
@@ -18,6 +18,21 @@ export const ROTATION_MODE = {
|
||||
CRUISE: "cruise",
|
||||
};
|
||||
|
||||
export const CRUISE_MODULES = {
|
||||
BGP: "bgp",
|
||||
NEWS: "news",
|
||||
};
|
||||
|
||||
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];
|
||||
|
||||
export const SATELLITE_DISPLAY_STYLES = {
|
||||
SELF_GLOW: "self_glow",
|
||||
GROUND_FOOTPRINT: "ground_footprint",
|
||||
};
|
||||
|
||||
export const DEFAULT_SATELLITE_DISPLAY_STYLE =
|
||||
SATELLITE_DISPLAY_STYLES.SELF_GLOW;
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
dwellMs: 7_000,
|
||||
focusDurationMs: 1_400,
|
||||
@@ -140,7 +155,7 @@ export const TERRAIN_CONFIG = {
|
||||
baseZoom: 4,
|
||||
geometryWidthSegments: 320,
|
||||
geometryHeightSegments: 320,
|
||||
baseRadiusOffset: 0.04,
|
||||
baseRadiusOffset: 0.16,
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
@@ -153,6 +168,32 @@ export const TERRAIN_CONFIG = {
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
};
|
||||
|
||||
export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
dataPath: "/earth/data/countries-admin0.min.geojson",
|
||||
lineAltitudeOffset: 0.24,
|
||||
hoverAltitudeOffset: 0.32,
|
||||
lineColor: 0x7fc7ff,
|
||||
lineOpacity: 0.58,
|
||||
lineRenderOrder: 2.2,
|
||||
dimmedLineOpacity: 0.18,
|
||||
hoverLineColor: 0xff3b1f,
|
||||
hoverLineOpacity: 1.0,
|
||||
hoverLineRenderOrder: 2.3,
|
||||
hoverGlowOpacity: 0.38,
|
||||
hoverGlowLineWidth: 3,
|
||||
hoverGlowRenderOrderOffset: 0.01,
|
||||
hoverGlowRadiusOffset: 0.04,
|
||||
tintAltitudeOffset: 0.04,
|
||||
tintColor: 0x0b1830,
|
||||
tintRenderOrder: 0.2,
|
||||
landColor: 0x080f1b,
|
||||
landOpacity: 1.0,
|
||||
landAltitudeOffset: 0.08,
|
||||
landRenderOrder: 0.86,
|
||||
landMaskWidth: 2048,
|
||||
landMaskHeight: 1024,
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
@@ -160,6 +201,7 @@ export const PATHS = {
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||
earthClientLogsApi: '/api/v1/system/logs/earth-client',
|
||||
};
|
||||
|
||||
export const COMPUTE_CENTER_CONFIG = {
|
||||
@@ -261,13 +303,16 @@ export const CABLE_STATE = {
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
maxCount: -1,
|
||||
initialLoadCount: 2400,
|
||||
hydrateFullAfterInitialLoad: true,
|
||||
initialLoadCount: null,
|
||||
hydrateFullAfterInitialLoad: false,
|
||||
trailLength: 10,
|
||||
trailLineWidth: 3,
|
||||
displayAltitudeOffset: 8,
|
||||
frontFacingDotThreshold: 0.015,
|
||||
overlayRenderOrder: 12,
|
||||
dotSize: 4,
|
||||
dotBaseSize: 2.8,
|
||||
dotBackdropScale: 1.28,
|
||||
dotZoomScalePower: 1,
|
||||
ringSize: 0.07,
|
||||
apiPath: '/api/v1/visualization/geo/satellites',
|
||||
breathingSpeed: 0.08,
|
||||
@@ -374,19 +419,43 @@ export const PREDICTED_ORBIT_CONFIG = {
|
||||
};
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
latitudeStep: 10,
|
||||
radiusOffset: 0.14,
|
||||
color: 0xc0e0ff,
|
||||
opacity: 0.08,
|
||||
lineWidth: 1,
|
||||
renderOrder: 2.05,
|
||||
latitudeStep: 15,
|
||||
longitudeStep: 30,
|
||||
gridStep: 5
|
||||
segmentStep: 5,
|
||||
};
|
||||
|
||||
export const CLOUD_LAYER_CONFIG = {
|
||||
radiusOffset: 3,
|
||||
widthSegments: 64,
|
||||
heightSegments: 64,
|
||||
opacity: 0.15,
|
||||
textureUrl: "./assets/earth_clouds_1024.png",
|
||||
};
|
||||
|
||||
export const STARFIELD_CONFIG = {
|
||||
count: 8000,
|
||||
minRadius: 800,
|
||||
radiusJitter: 200,
|
||||
color: 0xffffff,
|
||||
size: 0.5,
|
||||
};
|
||||
|
||||
export const EARTH_MATERIAL_CONFIG = {
|
||||
// Diffuse color multiplies with texture — pure white = full saturation,
|
||||
// slightly grey-blue pulls perceived saturation down without a custom shader.
|
||||
color: 0xcdd8e6,
|
||||
// Base sphere sits below the country fill and high-res texture overlays.
|
||||
// Keep it dark so a delayed overlay never flashes or reads as a white layer.
|
||||
color: 0x010609,
|
||||
specular: 0x1a2d45,
|
||||
shininess: 12,
|
||||
emissive: 0x050a12,
|
||||
opacity: 0.96,
|
||||
emissive: 0x010609,
|
||||
opacity: 1,
|
||||
textureOverlayAltitudeOffset: 0.1,
|
||||
textureOverlayOpacity: 0.88,
|
||||
textureOverlayRenderOrder: 0.96,
|
||||
|
||||
// Depth-mask occluder keeps far-side objects hidden behind the earth
|
||||
occluderRadiusFactor: 0.999,
|
||||
|
||||
553
frontend/public/earth/js/controls.js
vendored
553
frontend/public/earth/js/controls.js
vendored
@@ -1,9 +1,22 @@
|
||||
// controls.js - Zoom, rotate and toggle controls
|
||||
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
|
||||
import {
|
||||
CONFIG,
|
||||
CRUISE_MODULES,
|
||||
DEFAULT_SATELLITE_DISPLAY_STYLE,
|
||||
DEFAULT_CRUISE_MODULES,
|
||||
EARTH_CONFIG,
|
||||
ROTATION_MODE,
|
||||
SATELLITE_DISPLAY_STYLES,
|
||||
} from "./constants.js";
|
||||
import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js";
|
||||
import { toggleTerrain, setDayNightEnabled } from "./earth.js";
|
||||
import {
|
||||
toggleTerrain,
|
||||
setDayNightEnabled,
|
||||
toggleGridLines,
|
||||
getShowGridLines,
|
||||
} from "./earth.js";
|
||||
import { setCelestialDayNightEnabled } from "./celestial.js";
|
||||
import {
|
||||
ensureTerrainReady,
|
||||
@@ -16,6 +29,11 @@ import {
|
||||
clearLockedObject,
|
||||
clearLockedObjectAndInfo,
|
||||
setCablesEnabled,
|
||||
setCountryBoundariesEnabled,
|
||||
setHighResTextureEnabled,
|
||||
getHighResTextureEnabled,
|
||||
setAtmosphereCloudsEnabled,
|
||||
getAtmosphereCloudsEnabled,
|
||||
setSatellitesEnabled,
|
||||
getSatellitesEnabled,
|
||||
} from "./main.js";
|
||||
@@ -23,9 +41,12 @@ import {
|
||||
toggleTrails,
|
||||
getShowTrails,
|
||||
getSatelliteCount,
|
||||
getSatelliteDisplayStyle,
|
||||
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
|
||||
} from "./satellites.js";
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { getShowCountryBoundaries } from "./country-boundaries.js";
|
||||
import {
|
||||
toggleComputeCenters,
|
||||
getShowComputeCenters,
|
||||
@@ -58,6 +79,7 @@ export let rotationMode = ROTATION_MODE.ROTATE;
|
||||
let dayNightEnabled = true;
|
||||
let defaultEarthZoom = CONFIG.defaultViewZoom;
|
||||
let activeCamera = null;
|
||||
let settingsApplyPromise = Promise.resolve();
|
||||
|
||||
let earthObj = null;
|
||||
let listeners = [];
|
||||
@@ -104,6 +126,10 @@ let activeMobileDrawerId = null;
|
||||
let mobileDrawerOpen = false;
|
||||
let mobileDrawerCard = "layers";
|
||||
let mobileDrawerHintTimer = null;
|
||||
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
|
||||
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
|
||||
Object.values(SATELLITE_DISPLAY_STYLES),
|
||||
);
|
||||
|
||||
function detectLayoutMode() {
|
||||
const width = window.innerWidth;
|
||||
@@ -312,9 +338,31 @@ function getMobileLayerButtons(layerId) {
|
||||
).filter((button) => button instanceof HTMLButtonElement);
|
||||
}
|
||||
|
||||
function getLayerDisabledState(layerId) {
|
||||
if (layerId === "trails" && !getSatellitesEnabled()) {
|
||||
return {
|
||||
disabled: true,
|
||||
statusText: "不可用",
|
||||
tooltip: "卫星关闭时不可用",
|
||||
};
|
||||
}
|
||||
if (layerId === "terrain" && !getHighResTextureEnabled()) {
|
||||
return {
|
||||
disabled: true,
|
||||
statusText: "不可用",
|
||||
tooltip: "高清材质关闭时不可用",
|
||||
};
|
||||
}
|
||||
return {
|
||||
disabled: false,
|
||||
statusText: null,
|
||||
tooltip: null,
|
||||
};
|
||||
}
|
||||
|
||||
function syncMobileLayerCards() {
|
||||
const summary = document.getElementById("mobile-layer-summary");
|
||||
const definitions = getSortedLayerDefinitions();
|
||||
const definitions = getDisplayLayerDefinitions();
|
||||
let activeCount = 0;
|
||||
|
||||
definitions.forEach((definition) => {
|
||||
@@ -323,11 +371,19 @@ function syncMobileLayerCards() {
|
||||
activeCount += 1;
|
||||
}
|
||||
getMobileLayerButtons(definition.id).forEach((button) => {
|
||||
const disabledState = getLayerDisabledState(definition.id);
|
||||
button.classList.toggle("is-active", visible);
|
||||
button.classList.toggle("is-disabled", disabledState.disabled);
|
||||
button.disabled = disabledState.disabled;
|
||||
button.setAttribute("aria-checked", visible ? "true" : "false");
|
||||
if (disabledState.tooltip) {
|
||||
button.title = disabledState.tooltip;
|
||||
} else {
|
||||
button.removeAttribute("title");
|
||||
}
|
||||
const status = button.querySelector("[data-mobile-layer-status]");
|
||||
if (status) {
|
||||
status.textContent = visible ? "开启" : "关闭";
|
||||
status.textContent = disabledState.statusText || (visible ? "开启" : "关闭");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -341,7 +397,7 @@ function renderMobileLayerCards() {
|
||||
const list = document.getElementById("mobile-layer-list");
|
||||
if (!(list instanceof HTMLElement)) return;
|
||||
|
||||
const definitions = getSortedLayerDefinitions();
|
||||
const definitions = getDisplayLayerDefinitions();
|
||||
list.innerHTML = definitions
|
||||
.map((definition) => `
|
||||
<button
|
||||
@@ -365,6 +421,7 @@ function renderMobileLayerCards() {
|
||||
bindListener(button, "click", async (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
if (target.disabled || target.classList.contains("is-disabled")) return;
|
||||
const layerId = target.dataset.mobileLayerButton;
|
||||
const definition = layerId ? getLayerDefinition(layerId) : null;
|
||||
if (!definition) return;
|
||||
@@ -575,6 +632,21 @@ function getSortedLayerDefinitions({ includeUnprioritized = true } = {}) {
|
||||
.sort(compareLayerDefinitionsByStartupPriority);
|
||||
}
|
||||
|
||||
function getDisplayLayerDefinitions() {
|
||||
return Array.from(layerRegistry.values()).sort((left, right) => {
|
||||
const leftOrder = Number.isFinite(left?.displayOrder)
|
||||
? left.displayOrder
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const rightOrder = Number.isFinite(right?.displayOrder)
|
||||
? right.displayOrder
|
||||
: Number.POSITIVE_INFINITY;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left?.id || "").localeCompare(String(right?.id || ""));
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIncludeLayerInStartupLoad(definition) {
|
||||
if (!Number.isFinite(definition?.startupPriority)) {
|
||||
return false;
|
||||
@@ -628,6 +700,8 @@ function getCurrentPanelVisibilitySnapshot() {
|
||||
function getCurrentSharedSettingsSnapshot() {
|
||||
return {
|
||||
rotationMode,
|
||||
cruiseModules: getCruiseModules(),
|
||||
satelliteDisplayStyle: getSatelliteDisplayStyle(),
|
||||
layerVisibility: Object.fromEntries(
|
||||
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
|
||||
),
|
||||
@@ -637,13 +711,22 @@ function getCurrentSharedSettingsSnapshot() {
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultLayerVisibilitySnapshot() {
|
||||
return Object.fromEntries(
|
||||
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.defaultActive)]),
|
||||
);
|
||||
}
|
||||
|
||||
function captureEarthSettingsDefaults() {
|
||||
if (!earthSettingsDefaults) {
|
||||
const panelVisibility = getCurrentPanelVisibilitySnapshot();
|
||||
const shared = getCurrentSharedSettingsSnapshot();
|
||||
earthSettingsDefaults = {
|
||||
version: 2,
|
||||
shared,
|
||||
shared: {
|
||||
...shared,
|
||||
layerVisibility: getDefaultLayerVisibilitySnapshot(),
|
||||
},
|
||||
views: {
|
||||
desktop: {
|
||||
panelVisibility: { ...panelVisibility },
|
||||
@@ -659,9 +742,12 @@ function captureEarthSettingsDefaults() {
|
||||
|
||||
function cloneEarthSettings(settings) {
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
shared: {
|
||||
rotationMode: settings.shared.rotationMode,
|
||||
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
|
||||
satelliteDisplayStyle:
|
||||
settings.shared.satelliteDisplayStyle || DEFAULT_SATELLITE_DISPLAY_STYLE,
|
||||
terrainOpacity: settings.shared.terrainOpacity,
|
||||
dayNightEnabled: settings.shared.dayNightEnabled,
|
||||
defaultEarthZoom: settings.shared.defaultEarthZoom,
|
||||
@@ -729,10 +815,27 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
}
|
||||
});
|
||||
|
||||
if ((rawSettings?.version || 0) < 3 && inputLayerVisibility.gridLines === true) {
|
||||
normalizedLayerVisibility.gridLines = defaults.shared.layerVisibility.gridLines;
|
||||
}
|
||||
|
||||
const nextRotationMode =
|
||||
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
|
||||
? ROTATION_MODE.CRUISE
|
||||
: defaults.shared.rotationMode;
|
||||
const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules)
|
||||
? sharedSettings.cruiseModules
|
||||
: defaults.shared.cruiseModules;
|
||||
const nextCruiseModules = Array.from(
|
||||
new Set(
|
||||
requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)),
|
||||
),
|
||||
);
|
||||
const nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
|
||||
sharedSettings?.satelliteDisplayStyle,
|
||||
)
|
||||
? sharedSettings.satelliteDisplayStyle
|
||||
: defaults.shared.satelliteDisplayStyle;
|
||||
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
|
||||
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
|
||||
? sharedSettings.dayNightEnabled
|
||||
@@ -742,9 +845,13 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
);
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
shared: {
|
||||
rotationMode: nextRotationMode,
|
||||
cruiseModules: nextCruiseModules.length > 0
|
||||
? nextCruiseModules
|
||||
: [...DEFAULT_CRUISE_MODULES],
|
||||
satelliteDisplayStyle: nextSatelliteDisplayStyle,
|
||||
layerVisibility: normalizedLayerVisibility,
|
||||
terrainOpacity: Number.isFinite(nextTerrainOpacity)
|
||||
? nextTerrainOpacity
|
||||
@@ -764,7 +871,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
}
|
||||
|
||||
function getPersistedLayers() {
|
||||
return getSortedLayerDefinitions().filter((layer) => layer.persist !== false);
|
||||
return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false);
|
||||
}
|
||||
|
||||
function getLayerDefinition(layerId) {
|
||||
@@ -827,6 +934,126 @@ function persistEarthSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchCruiseModulesChange() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("earth:cruise-modules-change", {
|
||||
detail: {
|
||||
modules: getCruiseModules(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCruiseModules(nextModules) {
|
||||
const sourceModules = Array.isArray(nextModules) ? nextModules : DEFAULT_CRUISE_MODULES;
|
||||
const normalizedModules = Array.from(
|
||||
new Set(sourceModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId))),
|
||||
);
|
||||
return normalizedModules.length > 0
|
||||
? normalizedModules
|
||||
: [...DEFAULT_CRUISE_MODULES];
|
||||
}
|
||||
|
||||
function syncCruiseModuleControls() {
|
||||
const enabledModules = new Set(getCruiseModules());
|
||||
document.querySelectorAll("[data-cruise-module-toggle]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const moduleId = button.dataset.cruiseModuleToggle || "";
|
||||
const active = enabledModules.has(moduleId);
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function syncSatelliteDisplayStyleControls() {
|
||||
const activeStyle = getSatelliteDisplayStyle();
|
||||
document.querySelectorAll("[data-satellite-display-style]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const styleId = button.dataset.satelliteDisplayStyle || "";
|
||||
const active = styleId === activeStyle;
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
export function getCruiseModules() {
|
||||
const configuredModules = earthSettingsState?.shared?.cruiseModules;
|
||||
return normalizeCruiseModules(configuredModules);
|
||||
}
|
||||
|
||||
export function isCruiseModuleEnabled(moduleId) {
|
||||
return getCruiseModules().includes(moduleId);
|
||||
}
|
||||
|
||||
export function setCruiseModules(nextModules, { persist = true, suppressStatus = false } = {}) {
|
||||
const normalizedModules = normalizeCruiseModules(nextModules);
|
||||
const previousModules = getCruiseModules();
|
||||
const changed =
|
||||
normalizedModules.length !== previousModules.length ||
|
||||
normalizedModules.some((moduleId, index) => previousModules[index] !== moduleId);
|
||||
|
||||
if (!changed) {
|
||||
syncCruiseModuleControls();
|
||||
return normalizedModules;
|
||||
}
|
||||
|
||||
earthSettingsState = cloneEarthSettings(
|
||||
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
|
||||
);
|
||||
earthSettingsState.shared.cruiseModules = [...normalizedModules];
|
||||
syncCruiseModuleControls();
|
||||
dispatchCruiseModulesChange();
|
||||
|
||||
if (persist) {
|
||||
persistEarthSettings();
|
||||
}
|
||||
|
||||
if (!suppressStatus) {
|
||||
const labels = normalizedModules.map((moduleId) =>
|
||||
moduleId === CRUISE_MODULES.NEWS ? "新闻" : "BGP",
|
||||
);
|
||||
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
|
||||
}
|
||||
|
||||
return normalizedModules;
|
||||
}
|
||||
|
||||
export function setSatelliteDisplayStyle(
|
||||
nextStyle,
|
||||
{ persist = true, suppressStatus = false } = {},
|
||||
) {
|
||||
const normalizedStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(nextStyle)
|
||||
? nextStyle
|
||||
: DEFAULT_SATELLITE_DISPLAY_STYLE;
|
||||
const previousStyle = getSatelliteDisplayStyle();
|
||||
|
||||
if (normalizedStyle === previousStyle) {
|
||||
syncSatelliteDisplayStyleControls();
|
||||
return normalizedStyle;
|
||||
}
|
||||
|
||||
earthSettingsState = cloneEarthSettings(
|
||||
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
|
||||
);
|
||||
earthSettingsState.shared.satelliteDisplayStyle = normalizedStyle;
|
||||
applySatelliteDisplayStyle(normalizedStyle);
|
||||
syncSatelliteDisplayStyleControls();
|
||||
|
||||
if (persist) {
|
||||
persistEarthSettings();
|
||||
}
|
||||
|
||||
if (!suppressStatus) {
|
||||
const nextLabel =
|
||||
normalizedStyle === SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT
|
||||
? "真实地表覆盖"
|
||||
: "自身发光";
|
||||
showStatusMessage(`卫星显示风格已切换为:${nextLabel}`, "info");
|
||||
}
|
||||
|
||||
return normalizedStyle;
|
||||
}
|
||||
|
||||
function syncDefaultEarthZoomUi(nextZoom) {
|
||||
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
|
||||
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
|
||||
@@ -889,6 +1116,11 @@ async function applyEarthSettings(settings) {
|
||||
});
|
||||
|
||||
setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
|
||||
setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true });
|
||||
setSatelliteDisplayStyle(settings.shared.satelliteDisplayStyle, {
|
||||
persist: false,
|
||||
suppressStatus: true,
|
||||
});
|
||||
|
||||
if (typeof settings.shared.dayNightEnabled === "boolean") {
|
||||
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
|
||||
@@ -983,6 +1215,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
|
||||
} else if (enabled) {
|
||||
setEarthStatValue("satellite-count", `${getSatelliteCount()} 颗`);
|
||||
}
|
||||
syncTrailsAvailability();
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
@@ -999,6 +1232,75 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
|
||||
}
|
||||
}
|
||||
|
||||
function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
toggleGridLines(enabled);
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
tooltip: enabled ? "隐藏经纬线" : "显示经纬线",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
async function setCountryBoundariesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
try {
|
||||
if (enabled) {
|
||||
setLayerButtonState(button, {
|
||||
active: false,
|
||||
loading: true,
|
||||
tooltip: "国界加载中...",
|
||||
});
|
||||
}
|
||||
await setCountryBoundariesEnabled(enabled, { suppressStatus: silent });
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏国界" : "显示国界",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
console.error("切换国界显示失败:", error);
|
||||
setLayerButtonState(button, {
|
||||
active: false,
|
||||
loading: false,
|
||||
tooltip: "显示国界",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
setHighResTextureEnabled(enabled, { suppressStatus: silent });
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏高清材质" : "显示高清材质",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent });
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏大气云图" : "显示大气云图",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
clearSelectionIfHiding(!enabled);
|
||||
toggleBGP(enabled);
|
||||
@@ -1036,9 +1338,11 @@ function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent
|
||||
|
||||
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
toggleTrails(enabled);
|
||||
const disabledState = getLayerDisabledState("trails");
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
|
||||
disabled: disabledState.disabled,
|
||||
tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"),
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
@@ -1048,6 +1352,17 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function syncTrailsAvailability() {
|
||||
const trailsEnabled = getShowTrails();
|
||||
const disabledState = getLayerDisabledState("trails");
|
||||
setLayerButtonState(getLayerButton("trails"), {
|
||||
active: trailsEnabled,
|
||||
disabled: disabledState.disabled,
|
||||
tooltip: disabledState.tooltip || (trailsEnabled ? "隐藏轨迹" : "显示轨迹"),
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
}
|
||||
|
||||
async function setCablesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
clearSelectionIfHiding(!enabled);
|
||||
try {
|
||||
@@ -1074,53 +1389,72 @@ async function applyLayerVisibilitySettings(layerVisibility = {}, options = {})
|
||||
function getBuiltinLayerDefinitions() {
|
||||
return [
|
||||
{
|
||||
id: "terrain",
|
||||
buttonId: "toggle-terrain",
|
||||
icon: "landscape",
|
||||
label: "地形",
|
||||
meta: "Terrain",
|
||||
keywords: "地形 terrain",
|
||||
id: "gridLines",
|
||||
buttonId: "toggle-grid-lines",
|
||||
icon: "grid_4x4",
|
||||
label: "经纬线",
|
||||
meta: "Graticule",
|
||||
keywords: "经纬线 graticule 经纬 latitude longitude",
|
||||
defaultActive: false,
|
||||
startupPriority: null,
|
||||
displayOrder: 100,
|
||||
startupPriority: 10,
|
||||
startupMode: "visible",
|
||||
startupLabel: "地形",
|
||||
startupMessage: "正在渲染地形...",
|
||||
statusTarget: "terrain-status",
|
||||
getVisible: () => showTerrain,
|
||||
startupLabel: "经纬线",
|
||||
startupMessage: "",
|
||||
getVisible: () => getShowGridLines(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTerrainEnabled(getLayerButton("terrain"), visible, options),
|
||||
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "satellites",
|
||||
buttonId: "toggle-satellites",
|
||||
icon: "satellite_alt",
|
||||
label: "卫星",
|
||||
meta: "Satellites",
|
||||
keywords: "卫星 satellites",
|
||||
defaultActive: false,
|
||||
id: "countryBoundaries",
|
||||
buttonId: "toggle-country-boundaries",
|
||||
icon: "public",
|
||||
label: "国界",
|
||||
meta: "Country Borders",
|
||||
keywords: "国界 国家 borders countries boundary",
|
||||
defaultActive: true,
|
||||
displayOrder: 90,
|
||||
startupPriority: 20,
|
||||
startupMode: "preload",
|
||||
startupLabel: "海陆基座",
|
||||
startupMessage: "正在加载海陆基座...",
|
||||
getVisible: () => getShowCountryBoundaries(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "earthHighResTexture",
|
||||
buttonId: "toggle-earth-high-res-texture",
|
||||
icon: "globe",
|
||||
label: "高清材质",
|
||||
meta: "High-Res Texture",
|
||||
keywords: "高清 材质 纹理 texture hd 地表 earth",
|
||||
defaultActive: true,
|
||||
displayOrder: 70,
|
||||
startupPriority: 30,
|
||||
startupMode: "visible",
|
||||
startupLabel: "卫星",
|
||||
startupMessage: "正在加载卫星...",
|
||||
getVisible: () => getSatellitesEnabled(),
|
||||
startupLabel: "高清材质",
|
||||
startupMessage: "正在启用高清材质...",
|
||||
getVisible: () => getHighResTextureEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
|
||||
setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "trails",
|
||||
buttonId: "toggle-trails",
|
||||
icon: "timeline",
|
||||
label: "轨迹",
|
||||
meta: "Trails",
|
||||
keywords: "轨迹 trails",
|
||||
id: "atmosphereClouds",
|
||||
buttonId: "toggle-atmosphere-clouds",
|
||||
icon: "cloud",
|
||||
label: "大气云图",
|
||||
meta: "Cloud Layer",
|
||||
keywords: "大气 云图 云层 clouds atmosphere",
|
||||
defaultActive: true,
|
||||
startupPriority: null,
|
||||
displayOrder: 80,
|
||||
startupPriority: 40,
|
||||
startupMode: "visible",
|
||||
startupLabel: "轨迹",
|
||||
startupLabel: "大气云图",
|
||||
startupMessage: "",
|
||||
getVisible: () => getShowTrails(),
|
||||
getVisible: () => getAtmosphereCloudsEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
|
||||
setAtmosphereCloudsLayerEnabled(getLayerButton("atmosphereClouds"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "cables",
|
||||
@@ -1130,7 +1464,8 @@ function getBuiltinLayerDefinitions() {
|
||||
meta: "Subsea Cables",
|
||||
keywords: "海缆 subsea cables",
|
||||
defaultActive: true,
|
||||
startupPriority: 20,
|
||||
displayOrder: 10,
|
||||
startupPriority: 50,
|
||||
startupMode: "visible",
|
||||
startupLabel: "海缆",
|
||||
startupMessage: {
|
||||
@@ -1149,7 +1484,8 @@ function getBuiltinLayerDefinitions() {
|
||||
meta: "Compute Centers",
|
||||
keywords: "算力中心 compute centers gpu 超算",
|
||||
defaultActive: true,
|
||||
startupPriority: 35,
|
||||
displayOrder: 40,
|
||||
startupPriority: 60,
|
||||
startupMode: "preload",
|
||||
startupLabel: "算力中心",
|
||||
startupMessage: "正在加载算力中心...",
|
||||
@@ -1165,7 +1501,8 @@ function getBuiltinLayerDefinitions() {
|
||||
meta: "Routing Signals",
|
||||
keywords: "bgp观测 routing signals",
|
||||
defaultActive: true,
|
||||
startupPriority: 40,
|
||||
displayOrder: 50,
|
||||
startupPriority: 70,
|
||||
startupMode: "preload",
|
||||
startupLabel: "BGP态势",
|
||||
startupMessage: "正在加载BGP态势...",
|
||||
@@ -1173,6 +1510,58 @@ function getBuiltinLayerDefinitions() {
|
||||
setVisible: (visible, options = {}) =>
|
||||
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "satellites",
|
||||
buttonId: "toggle-satellites",
|
||||
icon: "satellite_alt",
|
||||
label: "卫星",
|
||||
meta: "Satellites",
|
||||
keywords: "卫星 satellites",
|
||||
defaultActive: false,
|
||||
displayOrder: 30,
|
||||
startupPriority: 80,
|
||||
startupMode: "visible",
|
||||
startupLabel: "卫星",
|
||||
startupMessage: "正在加载卫星...",
|
||||
getVisible: () => getSatellitesEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "trails",
|
||||
buttonId: "toggle-trails",
|
||||
icon: "timeline",
|
||||
label: "轨迹",
|
||||
meta: "Trails",
|
||||
keywords: "轨迹 trails",
|
||||
defaultActive: true,
|
||||
displayOrder: 20,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "轨迹",
|
||||
startupMessage: "",
|
||||
getVisible: () => getShowTrails(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "terrain",
|
||||
buttonId: "toggle-terrain",
|
||||
icon: "landscape",
|
||||
label: "地形",
|
||||
meta: "Terrain",
|
||||
keywords: "地形 terrain",
|
||||
defaultActive: false,
|
||||
displayOrder: 60,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "地形",
|
||||
startupMessage: "正在渲染地形...",
|
||||
statusTarget: "terrain-status",
|
||||
getVisible: () => showTerrain,
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTerrainEnabled(getLayerButton("terrain"), visible, options),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1234,6 +1623,7 @@ function syncLayerRowDefinition(definition, { appendIfMissing = false } = {}) {
|
||||
function registerLayerDefinition(definition, options = {}) {
|
||||
const normalizedDefinition = {
|
||||
persist: true,
|
||||
displayOrder: null,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "",
|
||||
@@ -1622,6 +2012,31 @@ function applyDayNightEnabled(enabled, { persist = true } = {}) {
|
||||
if (persist) persistEarthSettings();
|
||||
}
|
||||
|
||||
export function setDayNightEnabledExternal(enabled, { persist = true } = {}) {
|
||||
applyDayNightEnabled(enabled, { persist });
|
||||
}
|
||||
|
||||
export function getDayNightEnabled() {
|
||||
return dayNightEnabled;
|
||||
}
|
||||
|
||||
export function setTerrainLayerInteractable(enabled) {
|
||||
const button = getLayerButton("terrain");
|
||||
setLayerButtonState(button, {
|
||||
disabled: !enabled,
|
||||
tooltip: enabled ? null : "高清材质关闭时不可用",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
}
|
||||
|
||||
export function setDayNightInteractable(enabled) {
|
||||
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
|
||||
input.disabled = !enabled;
|
||||
const label = input.closest("label");
|
||||
if (label) label.classList.toggle("is-disabled", !enabled);
|
||||
});
|
||||
}
|
||||
|
||||
function setupSettingsControls() {
|
||||
const settingsTrigger = document.getElementById("settings-trigger");
|
||||
const settingsClose = document.getElementById("settings-close");
|
||||
@@ -1667,6 +2082,8 @@ function setupSettingsControls() {
|
||||
const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]");
|
||||
const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
|
||||
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
|
||||
const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]");
|
||||
const satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]");
|
||||
const syncTerrainOpacityUi = (nextOpacity) => {
|
||||
const safeOpacity = Math.round(nextOpacity * 100);
|
||||
terrainOpacitySliders.forEach((slider) => {
|
||||
@@ -1721,6 +2138,34 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
cruiseModuleButtons.forEach((button) => {
|
||||
bindListener(button, "click", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
const moduleId = target.dataset.cruiseModuleToggle;
|
||||
if (!moduleId) return;
|
||||
|
||||
const currentModules = new Set(getCruiseModules());
|
||||
if (currentModules.has(moduleId)) {
|
||||
currentModules.delete(moduleId);
|
||||
} else {
|
||||
currentModules.add(moduleId);
|
||||
}
|
||||
|
||||
setCruiseModules(Array.from(currentModules));
|
||||
});
|
||||
});
|
||||
|
||||
satelliteDisplayStyleButtons.forEach((button) => {
|
||||
bindListener(button, "click", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
const nextStyle = target.dataset.satelliteDisplayStyle;
|
||||
if (!nextStyle) return;
|
||||
setSatelliteDisplayStyle(nextStyle);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
|
||||
if (!(dayNightToggle instanceof HTMLInputElement)) return;
|
||||
bindListener(dayNightToggle, "change", () => {
|
||||
@@ -1734,9 +2179,11 @@ function setupSettingsControls() {
|
||||
});
|
||||
|
||||
captureEarthSettingsDefaults();
|
||||
applyEarthSettings(loadEarthSettings());
|
||||
settingsApplyPromise = applyEarthSettings(loadEarthSettings());
|
||||
syncAllHudPanelToggles();
|
||||
syncRotationModeButtons();
|
||||
syncCruiseModuleControls();
|
||||
syncSatelliteDisplayStyleControls();
|
||||
syncDayNightToggle(dayNightEnabled);
|
||||
}
|
||||
|
||||
@@ -2055,7 +2502,7 @@ function resetCleanup() {
|
||||
listeners = [];
|
||||
}
|
||||
|
||||
export function setupControls(camera, renderer, scene, earth) {
|
||||
export async function setupControls(camera, renderer, scene, earth) {
|
||||
resetCleanup();
|
||||
activeCamera = camera;
|
||||
earthObj = earth;
|
||||
@@ -2064,6 +2511,8 @@ export function setupControls(camera, renderer, scene, earth) {
|
||||
setupWheelZoom(camera, renderer);
|
||||
setupRotateControls(camera, earth);
|
||||
setupTerrainControls();
|
||||
await settingsApplyPromise;
|
||||
syncTrailsAvailability();
|
||||
setupLiquidGlassInteractions();
|
||||
setupToolbarHubCluster();
|
||||
setupKeyboardControls();
|
||||
@@ -2394,7 +2843,11 @@ function bindLayerButton(row, definition) {
|
||||
if (button.dataset.layerBound === "true") return;
|
||||
|
||||
bindListener(button, "click", async function () {
|
||||
if (this.classList.contains("is-loading")) {
|
||||
if (
|
||||
this.disabled ||
|
||||
this.classList.contains("is-loading") ||
|
||||
this.classList.contains("is-disabled")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await definition.setVisible(!definition.getVisible());
|
||||
@@ -2410,6 +2863,7 @@ export function registerLayer({
|
||||
keywords = "",
|
||||
defaultActive = false,
|
||||
persist = true,
|
||||
displayOrder = null,
|
||||
startupPriority = null,
|
||||
startupMode = "visible",
|
||||
startupLabel = "",
|
||||
@@ -2430,6 +2884,7 @@ export function registerLayer({
|
||||
keywords,
|
||||
defaultActive,
|
||||
persist,
|
||||
displayOrder,
|
||||
startupPriority,
|
||||
startupMode,
|
||||
startupLabel,
|
||||
|
||||
500
frontend/public/earth/js/country-boundaries.js
Normal file
500
frontend/public/earth/js/country-boundaries.js
Normal file
@@ -0,0 +1,500 @@
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
// ─── Module state ──────────────────────────────────────────────────────────────
|
||||
let _earthObj = null;
|
||||
let _features = [];
|
||||
let _landMesh = null;
|
||||
let _tintMesh = null;
|
||||
let _boundaryLines = null;
|
||||
let _hoverGlowLines = null;
|
||||
let _hoverLines = null;
|
||||
let _hoveredFeature = null;
|
||||
let _hoveredGroupKey = null;
|
||||
let _visible = false;
|
||||
let _landFillEnabled = true;
|
||||
let _landFillSuppressed = false;
|
||||
let _tintEnabled = false;
|
||||
let _loaded = false;
|
||||
let _loadPromise = null;
|
||||
|
||||
const OCEAN_HEX = 0x010609;
|
||||
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
|
||||
|
||||
function hexToStyle(hex) {
|
||||
return `#${hex.toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
return [
|
||||
(hex >> 16) & 255,
|
||||
(hex >> 8) & 255,
|
||||
hex & 255,
|
||||
];
|
||||
}
|
||||
|
||||
function buildLandTexture(features) {
|
||||
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
||||
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const oceanRgb = hexToRgb(OCEAN_HEX);
|
||||
|
||||
if (!ctx) {
|
||||
const oceanData = new Uint8Array(width * height * 4);
|
||||
for (let i = 0; i < oceanData.length; i += 4) {
|
||||
oceanData[i] = oceanRgb[0];
|
||||
oceanData[i + 1] = oceanRgb[1];
|
||||
oceanData[i + 2] = oceanRgb[2];
|
||||
oceanData[i + 3] = 255;
|
||||
}
|
||||
const fallbackTexture = new THREE.DataTexture(
|
||||
oceanData,
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
fallbackTexture.needsUpdate = true;
|
||||
return fallbackTexture;
|
||||
}
|
||||
|
||||
// Ocean background
|
||||
ctx.fillStyle = hexToStyle(OCEAN_HEX);
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Land polygons using evenodd fill rule so holes (lakes, islands) work correctly
|
||||
ctx.fillStyle = hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor);
|
||||
|
||||
for (const feat of features) {
|
||||
const geom = feat.geometry;
|
||||
if (!geom) continue;
|
||||
const polys =
|
||||
geom.type === "Polygon" ? [geom.coordinates] :
|
||||
geom.type === "MultiPolygon" ? geom.coordinates : null;
|
||||
if (!polys) continue;
|
||||
|
||||
for (const rings of polys) {
|
||||
ctx.beginPath();
|
||||
for (const ring of rings) {
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
// equirectangular: x = (lon+180)/360*width, y = (90-lat)/180*height
|
||||
const px = ((ring[i][0] + 180) / 360) * width;
|
||||
const py = ((90 - ring[i][1]) / 180) * height;
|
||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.fill("evenodd");
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const tex = new THREE.DataTexture(
|
||||
new Uint8Array(imageData.data),
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
tex.flipY = true;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function makeLandMesh(tex) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 128, 64);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
map: tex,
|
||||
transparent: COUNTRY_BOUNDARY_CONFIG.landOpacity < 1,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.landOpacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-land-ocean";
|
||||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.landRenderOrder;
|
||||
mesh.visible = false;
|
||||
mesh.raycast = () => {};
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function makeTintMesh() {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 64, 32);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.tintColor, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-tint";
|
||||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.tintRenderOrder;
|
||||
mesh.visible = false;
|
||||
mesh.raycast = () => {};
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// ─── Boundary line geometry ────────────────────────────────────────────────────
|
||||
|
||||
function ringToSegments(ring, radius, out) {
|
||||
const n = ring.length;
|
||||
if (n < 2) return;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
out.push(latLonToVector3(ring[i][1], ring[i][0], radius));
|
||||
out.push(latLonToVector3(ring[i+1][1], ring[i+1][0], radius));
|
||||
}
|
||||
}
|
||||
|
||||
function featureToSegments(geom, radius) {
|
||||
const pts = [];
|
||||
if (!geom) return pts;
|
||||
if (geom.type === "Polygon") {
|
||||
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
|
||||
} else if (geom.type === "MultiPolygon") {
|
||||
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function buildBoundaryLines(features) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
||||
transparent: true,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const all = [];
|
||||
for (const feat of features) {
|
||||
const pts = featureToSegments(feat.geometry, r);
|
||||
all.push(...pts);
|
||||
}
|
||||
|
||||
const geo = all.length > 0
|
||||
? new THREE.BufferGeometry().setFromPoints(all)
|
||||
: new THREE.BufferGeometry();
|
||||
const lines = new THREE.LineSegments(geo, mat);
|
||||
lines.name = "country-boundary-all";
|
||||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildHoverLines() {
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
transparent: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity < 1,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||||
lines.name = "country-hover";
|
||||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildHoverGlowLines() {
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
transparent: true,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
linewidth: COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth,
|
||||
});
|
||||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||||
lines.name = "country-hover-glow";
|
||||
lines.renderOrder =
|
||||
COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder -
|
||||
COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function setBoundaryLinesDimmed(dimmed) {
|
||||
if (!_boundaryLines?.material) return;
|
||||
_boundaryLines.material.opacity = dimmed
|
||||
? COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity
|
||||
: COUNTRY_BOUNDARY_CONFIG.lineOpacity;
|
||||
_boundaryLines.material.needsUpdate = true;
|
||||
}
|
||||
|
||||
function clearHoverLineGeometries() {
|
||||
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
|
||||
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
|
||||
}
|
||||
|
||||
function featureListToSegments(features, radius) {
|
||||
return features.flatMap(f => featureToSegments(f.geometry, radius));
|
||||
}
|
||||
|
||||
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
|
||||
|
||||
function pointInRing(lat, lon, ring) {
|
||||
let inside = false;
|
||||
const n = ring.length;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
if ((yi > lat) !== (yj > lat) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function featureContains(lat, lon, feat) {
|
||||
const geom = feat.geometry;
|
||||
if (!geom) return false;
|
||||
if (geom.type === "Polygon") {
|
||||
if (!pointInRing(lat, lon, geom.coordinates[0])) return false;
|
||||
return geom.coordinates.slice(1).every(h => !pointInRing(lat, lon, h));
|
||||
}
|
||||
if (geom.type === "MultiPolygon") {
|
||||
return geom.coordinates.some(poly =>
|
||||
pointInRing(lat, lon, poly[0]) &&
|
||||
poly.slice(1).every(h => !pointInRing(lat, lon, h))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function makeCountryInfo(feat) {
|
||||
if (!feat) return null;
|
||||
const p = feat.properties || {};
|
||||
return {
|
||||
name: p.NAME_EN || p.NAME || p.ADMIN || "",
|
||||
nameZh: p.NAME_ZH || null,
|
||||
isoA3: p.ISO_A3 || p.ADM0_A3 || null,
|
||||
isoA2: p.ISO_A2 || null,
|
||||
continent: p.CONTINENT || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCountryHighlightGroupKey(feat) {
|
||||
const p = feat?.properties || {};
|
||||
const isoA3 = p.ISO_A3 || p.ADM0_A3 || "";
|
||||
if (isoA3 === "CHN" || isoA3 === "TWN") {
|
||||
return "CHN_TWN";
|
||||
}
|
||||
return isoA3 || p.ISO_A2 || p.NAME_EN || p.NAME || p.ADMIN || null;
|
||||
}
|
||||
|
||||
function getHighlightFeatures(feat) {
|
||||
const groupKey = getCountryHighlightGroupKey(feat);
|
||||
if (!groupKey) return feat ? [feat] : [];
|
||||
return _features.filter(f => getCountryHighlightGroupKey(f) === groupKey);
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Called during init (before data load). Creates the placeholder tint sphere. */
|
||||
export function createCountryBoundaryLayer(earthObj) {
|
||||
_earthObj = earthObj;
|
||||
_tintMesh = makeTintMesh();
|
||||
_earthObj.add(_tintMesh);
|
||||
}
|
||||
|
||||
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
|
||||
export async function loadCountryBoundaries() {
|
||||
if (_loaded) return _features.length;
|
||||
if (_loadPromise) return _loadPromise;
|
||||
|
||||
_loadPromise = (async () => {
|
||||
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
|
||||
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
|
||||
const geojson = await resp.json();
|
||||
_features = (geojson.features || []).filter(f => f.geometry);
|
||||
|
||||
const tex = buildLandTexture(_features);
|
||||
_landMesh = makeLandMesh(tex);
|
||||
_earthObj.add(_landMesh);
|
||||
|
||||
_boundaryLines = buildBoundaryLines(_features);
|
||||
_earthObj.add(_boundaryLines);
|
||||
|
||||
_hoverGlowLines = buildHoverGlowLines();
|
||||
_earthObj.add(_hoverGlowLines);
|
||||
|
||||
_hoverLines = buildHoverLines();
|
||||
_earthObj.add(_hoverLines);
|
||||
|
||||
_loaded = true;
|
||||
return _features.length;
|
||||
})();
|
||||
|
||||
return _loadPromise;
|
||||
}
|
||||
|
||||
/** Load if not yet loaded, then return feature count. */
|
||||
export async function ensureCountryBoundariesReady() {
|
||||
if (!_loaded) await loadCountryBoundaries();
|
||||
return _features.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the country boundary lines.
|
||||
* The land/ocean fill is the base earth map and stays independent from this
|
||||
* line visibility switch.
|
||||
* @param {boolean} visible
|
||||
* @param {{ showTint?: boolean, showLandFill?: boolean, suppressLandFill?: boolean }} [opts]
|
||||
* showLandFill – whether to show the base land/ocean fill.
|
||||
* Defaults to the current stored value so callers that only
|
||||
* care about visibility don't need to repeat it.
|
||||
* suppressLandFill – temporarily keep the fill below the high-res texture
|
||||
* without changing the layer's own fill state.
|
||||
*/
|
||||
export function toggleCountryBoundaries(
|
||||
visible,
|
||||
{ showTint = false, showLandFill = null, suppressLandFill = null } = {},
|
||||
) {
|
||||
_visible = Boolean(visible);
|
||||
|
||||
if (showLandFill !== null) _landFillEnabled = Boolean(showLandFill);
|
||||
if (suppressLandFill !== null) _landFillSuppressed = Boolean(suppressLandFill);
|
||||
|
||||
if (_landMesh) {
|
||||
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
if (_boundaryLines) _boundaryLines.visible = _visible;
|
||||
if (_hoverGlowLines) _hoverGlowLines.visible = _visible;
|
||||
if (_hoverLines) _hoverLines.visible = _visible;
|
||||
|
||||
if (!_visible) {
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
}
|
||||
|
||||
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the land/ocean canvas fill independently of boundary lines.
|
||||
*/
|
||||
export function setLandFillEnabled(enabled) {
|
||||
_landFillEnabled = Boolean(enabled);
|
||||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
|
||||
export function setLandFillSuppressed(enabled) {
|
||||
_landFillSuppressed = Boolean(enabled);
|
||||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable / disable the solid dark tint overlay (used when high-res texture is off).
|
||||
*/
|
||||
export function setSurfaceTintEnabled(enabled) {
|
||||
_tintEnabled = Boolean(enabled);
|
||||
if (_tintMesh) _tintMesh.visible = _visible && _tintEnabled;
|
||||
}
|
||||
|
||||
export function getShowCountryBoundaries() {
|
||||
return _visible;
|
||||
}
|
||||
|
||||
/** Clear the hover highlight without hiding the full layer. */
|
||||
export function clearCountryBoundaryHover() {
|
||||
if (!_hoveredFeature) return;
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hover highlight for the given lat/lon coords.
|
||||
* Returns a country-info object when hovering over land, or null over ocean.
|
||||
*/
|
||||
export function updateCountryBoundaryHover(coords) {
|
||||
if (!_loaded || !_visible) return null;
|
||||
const { lat, lon } = coords;
|
||||
|
||||
const found = _features.find(f => featureContains(lat, lon, f)) || null;
|
||||
const groupKey = getCountryHighlightGroupKey(found);
|
||||
|
||||
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
|
||||
_hoveredFeature = found;
|
||||
_hoveredGroupKey = groupKey;
|
||||
if (_hoverLines) {
|
||||
if (!found) {
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
} else {
|
||||
setBoundaryLinesDimmed(true);
|
||||
const highlightFeatures = getHighlightFeatures(found);
|
||||
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
|
||||
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
|
||||
if (_hoverGlowLines) {
|
||||
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
|
||||
_hoverGlowLines.geometry.setFromPoints(glowPts);
|
||||
}
|
||||
const corePts = featureListToSegments(highlightFeatures, coreRadius);
|
||||
_hoverLines.geometry.setFromPoints(corePts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found ? makeCountryInfo(found) : null;
|
||||
}
|
||||
|
||||
/** Dispose all Three.js objects and reset state. */
|
||||
export function clearCountryBoundaryData() {
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
|
||||
function disposeObj(obj) {
|
||||
if (!obj) return;
|
||||
if (_earthObj) _earthObj.remove(obj);
|
||||
obj.geometry?.dispose();
|
||||
if (obj.material) {
|
||||
if (obj.material.map) obj.material.map.dispose();
|
||||
obj.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
disposeObj(_hoverLines);
|
||||
disposeObj(_hoverGlowLines);
|
||||
disposeObj(_boundaryLines);
|
||||
disposeObj(_landMesh);
|
||||
disposeObj(_tintMesh);
|
||||
|
||||
_hoverLines = null;
|
||||
_hoverGlowLines = null;
|
||||
_boundaryLines = null;
|
||||
_landMesh = null;
|
||||
_tintMesh = null;
|
||||
_features = [];
|
||||
_loaded = false;
|
||||
_loadPromise = null;
|
||||
_visible = false;
|
||||
_landFillEnabled = true;
|
||||
_landFillSuppressed = false;
|
||||
_tintEnabled = false;
|
||||
}
|
||||
|
||||
export function getCountryBoundaryLegendItems() {
|
||||
return [
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线" },
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色" },
|
||||
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色" },
|
||||
];
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import {
|
||||
CLOUD_LAYER_CONFIG,
|
||||
CONFIG,
|
||||
EARTH_CONFIG,
|
||||
EARTH_MATERIAL_CONFIG,
|
||||
GRID_CONFIG,
|
||||
STARFIELD_CONFIG,
|
||||
TERRAIN_CONFIG,
|
||||
} from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
export let clouds = null;
|
||||
export let terrain = null;
|
||||
let showGridLines = false;
|
||||
let showClouds = true;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let _earthMaterial = null;
|
||||
let _earthTextureOverlay = null;
|
||||
let _earthTextureOverlayMaterial = null;
|
||||
let _earthShader = null;
|
||||
let _dayNightEnabled = true;
|
||||
let _loadedTexture = null;
|
||||
let _textureVisible = true;
|
||||
const _earthSunDirection = new THREE.Vector3(
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
|
||||
@@ -102,7 +116,7 @@ export function createEarth(scene) {
|
||||
specular: C.specular,
|
||||
shininess: C.shininess,
|
||||
emissive: C.emissive,
|
||||
transparent: true,
|
||||
transparent: C.opacity < 1,
|
||||
opacity: C.opacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: true,
|
||||
@@ -116,6 +130,30 @@ export function createEarth(scene) {
|
||||
earth.rotation.x = EARTH_CONFIG.tiltRad;
|
||||
scene.add(earth);
|
||||
|
||||
const textureOverlayGeometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + C.textureOverlayAltitudeOffset,
|
||||
128,
|
||||
128,
|
||||
);
|
||||
_earthTextureOverlayMaterial = new THREE.MeshPhongMaterial({
|
||||
color: 0xffffff,
|
||||
specular: C.specular,
|
||||
shininess: C.shininess,
|
||||
transparent: true,
|
||||
opacity: C.textureOverlayOpacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
});
|
||||
_earthTextureOverlay = new THREE.Mesh(
|
||||
textureOverlayGeometry,
|
||||
_earthTextureOverlayMaterial,
|
||||
);
|
||||
_earthTextureOverlay.name = "earth-high-res-texture-overlay";
|
||||
_earthTextureOverlay.renderOrder = C.textureOverlayRenderOrder;
|
||||
_earthTextureOverlay.visible = false;
|
||||
earth.add(_earthTextureOverlay);
|
||||
|
||||
// Depth-mask occluder — invisible sphere slightly inside the earth,
|
||||
// writes to the depth buffer so far-side cables/satellites are occluded.
|
||||
const occluderGeometry = new THREE.SphereGeometry(
|
||||
@@ -197,11 +235,14 @@ export function createEarth(scene) {
|
||||
}
|
||||
|
||||
export function createClouds(scene, earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + CLOUD_LAYER_CONFIG.radiusOffset,
|
||||
CLOUD_LAYER_CONFIG.widthSegments,
|
||||
CLOUD_LAYER_CONFIG.heightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
transparent: true,
|
||||
linewidth: 2,
|
||||
opacity: 0.15,
|
||||
opacity: CLOUD_LAYER_CONFIG.opacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
@@ -209,10 +250,12 @@ export function createClouds(scene, earthObj) {
|
||||
});
|
||||
|
||||
clouds = new THREE.Mesh(geometry, material);
|
||||
clouds.name = "earth-atmosphere-clouds";
|
||||
clouds.visible = showClouds;
|
||||
earthObj.add(clouds);
|
||||
|
||||
textureLoader.load(
|
||||
'./assets/earth_clouds_1024.png',
|
||||
CLOUD_LAYER_CONFIG.textureUrl,
|
||||
function(texture) {
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
@@ -226,6 +269,17 @@ export function createClouds(scene, earthObj) {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function toggleClouds(visible) {
|
||||
showClouds = Boolean(visible);
|
||||
if (clouds) {
|
||||
clouds.visible = showClouds;
|
||||
}
|
||||
}
|
||||
|
||||
export function getShowClouds() {
|
||||
return showClouds;
|
||||
}
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
@@ -238,7 +292,6 @@ export function createTerrain(earthObj) {
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
vertexAlphas: true,
|
||||
transparent: true,
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
@@ -252,7 +305,8 @@ export function createTerrain(earthObj) {
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
terrain.renderOrder = 1.2;
|
||||
terrain.raycast = () => {};
|
||||
earthObj.add(terrain);
|
||||
|
||||
return terrain;
|
||||
@@ -266,11 +320,11 @@ export function toggleTerrain(visible) {
|
||||
|
||||
export function createStars(scene) {
|
||||
const starGeometry = new THREE.BufferGeometry();
|
||||
const starCount = 8000;
|
||||
const starCount = STARFIELD_CONFIG.count;
|
||||
const starPositions = new Float32Array(starCount * 3);
|
||||
|
||||
for (let i = 0; i < starCount * 3; i += 3) {
|
||||
const r = 800 + Math.random() * 200;
|
||||
const r = STARFIELD_CONFIG.minRadius + Math.random() * STARFIELD_CONFIG.radiusJitter;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
|
||||
@@ -282,8 +336,8 @@ export function createStars(scene) {
|
||||
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
||||
|
||||
const starMaterial = new THREE.PointsMaterial({
|
||||
color: 0xffffff,
|
||||
size: 0.5,
|
||||
color: STARFIELD_CONFIG.color,
|
||||
size: STARFIELD_CONFIG.size,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
@@ -303,17 +357,19 @@ export function createGridLines(scene, earthObj) {
|
||||
latitudeLines = [];
|
||||
longitudeLines = [];
|
||||
|
||||
const earthRadius = 100.1;
|
||||
const earthRadius = CONFIG.earthRadius + GRID_CONFIG.radiusOffset;
|
||||
const gridMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x44aaff,
|
||||
color: GRID_CONFIG.color,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
linewidth: 1
|
||||
opacity: GRID_CONFIG.opacity,
|
||||
linewidth: GRID_CONFIG.lineWidth,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
for (let lat = -75; lat <= 75; lat += 15) {
|
||||
for (let lat = -75; lat <= 75; lat += GRID_CONFIG.latitudeStep) {
|
||||
const points = [];
|
||||
for (let lon = -180; lon <= 180; lon += 5) {
|
||||
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.segmentStep) {
|
||||
const point = latLonToVector3(lat, lon, earthRadius);
|
||||
points.push(point);
|
||||
}
|
||||
@@ -321,13 +377,15 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'latitude', value: lat };
|
||||
line.renderOrder = GRID_CONFIG.renderOrder;
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
latitudeLines.push(line);
|
||||
}
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += 30) {
|
||||
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.longitudeStep) {
|
||||
const points = [];
|
||||
for (let lat = -90; lat <= 90; lat += 5) {
|
||||
for (let lat = -90; lat <= 90; lat += GRID_CONFIG.segmentStep) {
|
||||
const point = latLonToVector3(lat, lon, earthRadius);
|
||||
points.push(point);
|
||||
}
|
||||
@@ -335,23 +393,48 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'longitude', value: lon };
|
||||
line.renderOrder = GRID_CONFIG.renderOrder;
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
longitudeLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleGridLines(visible) {
|
||||
showGridLines = visible;
|
||||
latitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
longitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowGridLines() {
|
||||
return showGridLines;
|
||||
}
|
||||
|
||||
export function getEarth() {
|
||||
return earth;
|
||||
}
|
||||
|
||||
export function getEarthSurfacePickTarget() {
|
||||
return _earthTextureOverlay?.visible ? _earthTextureOverlay : earth;
|
||||
}
|
||||
|
||||
export function getClouds() {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function clearEarthTexture() {
|
||||
if (!_earthMaterial) return;
|
||||
_earthMaterial.map = null;
|
||||
_earthMaterial.needsUpdate = true;
|
||||
_loadedTexture = null;
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = null;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
}
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setEarthSunDirection(direction) {
|
||||
@@ -375,10 +458,9 @@ export function setDayNightEnabled(enabled) {
|
||||
_earthMaterial.emissiveMap = null;
|
||||
} else {
|
||||
// Full bright: zero diffuse so directional light has no effect;
|
||||
// use original color as emissive map to show texture uniformly.
|
||||
_earthMaterial.color.setRGB(0, 0, 0);
|
||||
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||
_earthMaterial.emissiveMap = _earthMaterial.map;
|
||||
_earthMaterial.emissiveMap = null;
|
||||
}
|
||||
_earthMaterial.needsUpdate = true;
|
||||
}
|
||||
@@ -386,7 +468,7 @@ export function setDayNightEnabled(enabled) {
|
||||
|
||||
export function loadEarthTexture() {
|
||||
return new Promise((resolve) => {
|
||||
if (!_earthMaterial) { resolve(); return; }
|
||||
if (!_earthTextureOverlayMaterial) { resolve(); return; }
|
||||
|
||||
const urls = EARTH_MATERIAL_CONFIG.textureUrls;
|
||||
const tryLoad = (index) => {
|
||||
@@ -403,12 +485,12 @@ export function loadEarthTexture() {
|
||||
texture.anisotropy = 16;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
_earthMaterial.map = texture;
|
||||
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
|
||||
if (!_dayNightEnabled) {
|
||||
_earthMaterial.emissiveMap = texture;
|
||||
_loadedTexture = texture;
|
||||
_earthTextureOverlayMaterial.map = texture;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = _textureVisible;
|
||||
}
|
||||
_earthMaterial.needsUpdate = true;
|
||||
resolve();
|
||||
},
|
||||
null,
|
||||
@@ -418,3 +500,18 @@ export function loadEarthTexture() {
|
||||
tryLoad(0);
|
||||
});
|
||||
}
|
||||
|
||||
export function setEarthTextureVisible(visible) {
|
||||
_textureVisible = Boolean(visible);
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = _textureVisible && Boolean(_loadedTexture);
|
||||
}
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = _loadedTexture || null;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getEarthTextureVisible() {
|
||||
return _textureVisible;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,191 @@ import { showStatusMessage } from './ui.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
let typewriterTimerId = null;
|
||||
let typewriterToken = 0;
|
||||
let pendingMobileDetailState = null;
|
||||
let mobileDetailsListenerBound = false;
|
||||
let renderedMobileDetailKey = null;
|
||||
|
||||
function getNewsSummaryText(data) {
|
||||
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||
}
|
||||
|
||||
function getNewsSummaryPreview(data, maxLength = 34) {
|
||||
const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||
}
|
||||
|
||||
function stopTypewriterAnimation() {
|
||||
typewriterToken += 1;
|
||||
if (typewriterTimerId) {
|
||||
window.clearTimeout(typewriterTimerId);
|
||||
typewriterTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startTypewriterAnimation(target, text, options = {}) {
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
stopTypewriterAnimation();
|
||||
|
||||
const content = typeof text === 'string' ? text : '';
|
||||
const token = typewriterToken;
|
||||
const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22;
|
||||
const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90;
|
||||
|
||||
target.textContent = '';
|
||||
target.classList.add('is-typing');
|
||||
|
||||
let index = 0;
|
||||
const tick = () => {
|
||||
if (token !== typewriterToken) return;
|
||||
index += 1;
|
||||
target.textContent = content.slice(0, index);
|
||||
if (index < content.length) {
|
||||
typewriterTimerId = window.setTimeout(tick, stepMs);
|
||||
return;
|
||||
}
|
||||
target.classList.remove('is-typing');
|
||||
typewriterTimerId = null;
|
||||
};
|
||||
|
||||
typewriterTimerId = window.setTimeout(() => {
|
||||
if (token !== typewriterToken) return;
|
||||
if (!content) {
|
||||
target.classList.remove('is-typing');
|
||||
typewriterTimerId = null;
|
||||
return;
|
||||
}
|
||||
tick();
|
||||
}, startDelayMs);
|
||||
}
|
||||
|
||||
function renderNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
content.innerHTML = `
|
||||
<div class="info-card-news-layout">
|
||||
<div class="info-card-news-kicker">NEWS SIGNAL</div>
|
||||
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="info-card-news-summary-shell">
|
||||
<div class="info-card-news-summary-label">SUMMARY</div>
|
||||
<div class="info-card-news-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const summaryEl = content.querySelector('[data-news-summary]');
|
||||
startTypewriterAnimation(summaryEl, summary);
|
||||
}
|
||||
|
||||
function renderMobileNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
content.innerHTML = `
|
||||
<div class="earth-mobile-news-detail">
|
||||
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
|
||||
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="earth-mobile-news-detail-summary-shell">
|
||||
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
|
||||
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const summaryEl = content.querySelector('[data-news-summary]');
|
||||
startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 });
|
||||
}
|
||||
|
||||
function renderMobileDetailContent(type, config, data) {
|
||||
const content = document.getElementById('mobile-info-card-content');
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
|
||||
stopTypewriterAnimation();
|
||||
if (type === 'news') {
|
||||
renderMobileNewsCardContent(content, data);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
function getMobileDetailRenderKey(type, data) {
|
||||
if (type !== 'news') return null;
|
||||
return [
|
||||
type,
|
||||
data?.id ?? '',
|
||||
data?.url ?? '',
|
||||
data?.published_at ?? '',
|
||||
data?.title ?? '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function isMobileDetailsDrawerActive() {
|
||||
const detailsSlot = document.querySelector('[data-drawer-slot="details"]');
|
||||
return detailsSlot instanceof HTMLElement && detailsSlot.classList.contains('is-active');
|
||||
}
|
||||
|
||||
function ensureMobileDetailsListener() {
|
||||
if (mobileDetailsListenerBound) return;
|
||||
mobileDetailsListenerBound = true;
|
||||
|
||||
window.addEventListener('earth:open-details-tab', () => {
|
||||
if (!document.body.classList.contains('layout-mode-mobile')) return;
|
||||
if (!pendingMobileDetailState) return;
|
||||
const nextKey = getMobileDetailRenderKey(
|
||||
pendingMobileDetailState.type,
|
||||
pendingMobileDetailState.data,
|
||||
);
|
||||
if (nextKey && nextKey === renderedMobileDetailKey) return;
|
||||
renderMobileDetailContent(
|
||||
pendingMobileDetailState.type,
|
||||
pendingMobileDetailState.config,
|
||||
pendingMobileDetailState.data,
|
||||
);
|
||||
renderedMobileDetailKey = nextKey;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDefaultCardContent(content, config, data) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Mobile popup ─────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +197,7 @@ function getMobilePopupTitle(type, data) {
|
||||
case 'landing_point': return data.name || '登陆点';
|
||||
case 'satellite': return data.name || '卫星';
|
||||
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||
case 'news': return data.title || '新闻事件';
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||
@@ -25,6 +211,7 @@ function getMobilePopupSubtitle(type, data) {
|
||||
case 'landing_point': return data.country || '登陆点';
|
||||
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
|
||||
case 'bgp': return data.severity || 'BGP路由异常';
|
||||
case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻';
|
||||
case 'bgp_collector': return data.location || 'BGP观测站';
|
||||
case 'supercomputer': return data.country || '超级计算机';
|
||||
case 'gpu_cluster': return data.country || 'GPU集群';
|
||||
@@ -250,6 +437,10 @@ const CARD_CONFIG = {
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'norad_id', label: 'NORAD ID' },
|
||||
{ key: 'constellation', label: '星座/分组' },
|
||||
{ key: 'footprint_capability', label: '覆盖能力' },
|
||||
{ key: 'current_display', label: '当前显示' },
|
||||
{ key: 'footprint_model', label: '覆盖模型' },
|
||||
{ key: 'inclination', label: '倾角', unit: '°' },
|
||||
{ key: 'period', label: '周期', unit: '分钟' },
|
||||
{ key: 'perigee', label: '近地点', unit: 'km' },
|
||||
@@ -280,6 +471,20 @@ const CARD_CONFIG = {
|
||||
{ key: 'summary', label: '摘要' }
|
||||
]
|
||||
},
|
||||
news: {
|
||||
icon: '📰',
|
||||
title: '新闻事件详情',
|
||||
className: 'news',
|
||||
fields: [
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'published_at_display', label: '发布时间' },
|
||||
{ key: 'location_label', label: '发生地' },
|
||||
{ key: 'region_label', label: '区域' },
|
||||
{ key: 'feed_name', label: '聚合源' },
|
||||
{ key: 'summary', label: '摘要' },
|
||||
{ key: 'url', label: '原文链接' }
|
||||
]
|
||||
},
|
||||
bgp_collector: {
|
||||
icon: '📍',
|
||||
title: 'BGP观测站详情',
|
||||
@@ -616,6 +821,8 @@ export function showInfoCard(type, data, options = {}) {
|
||||
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
currentType = type;
|
||||
pendingMobileDetailState = { type, config, data };
|
||||
ensureMobileDetailsListener();
|
||||
|
||||
// Fill drawer details slot (accessible when user taps popup → opens details tab)
|
||||
const icon = document.getElementById('mobile-info-card-icon');
|
||||
@@ -624,27 +831,23 @@ export function showInfoCard(type, data, options = {}) {
|
||||
const content = document.getElementById('mobile-info-card-content');
|
||||
|
||||
if (icon) icon.textContent = config.icon;
|
||||
if (title) title.textContent = config.title;
|
||||
if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' ');
|
||||
if (title) {
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
: config.title;
|
||||
}
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent = type === 'news'
|
||||
? 'news signal'
|
||||
: type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
if (content) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
if (content && type !== 'news') {
|
||||
renderMobileDetailContent(type, config, data);
|
||||
renderedMobileDetailKey = null;
|
||||
} else if (content && type === 'news' && isMobileDetailsDrawerActive()) {
|
||||
renderMobileNewsCardContent(content, data);
|
||||
renderedMobileDetailKey = getMobileDetailRenderKey(type, data);
|
||||
}
|
||||
|
||||
// Show the floating mini popup near the touch point (requires coordinates)
|
||||
@@ -667,37 +870,23 @@ export function showInfoCard(type, data, options = {}) {
|
||||
const title = document.getElementById('info-card-title');
|
||||
const content = document.getElementById('info-card-content');
|
||||
|
||||
stopTypewriterAnimation();
|
||||
card.className = 'info-card ' + config.className;
|
||||
icon.textContent = config.icon;
|
||||
title.textContent = config.title;
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
: config.title;
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
if (type === 'news') {
|
||||
renderNewsCardContent(content, data);
|
||||
} else {
|
||||
renderDefaultCardContent(content, config, data);
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
showPanel(options.x, options.y, options);
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
stopTypewriterAnimation();
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
hideMobilePopup();
|
||||
document.body.classList.remove('earth-info-open');
|
||||
@@ -705,6 +894,8 @@ export function hideInfoCard() {
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||
);
|
||||
currentType = null;
|
||||
pendingMobileDetailState = null;
|
||||
renderedMobileDetailKey = null;
|
||||
return;
|
||||
}
|
||||
hidePanel();
|
||||
|
||||
252
frontend/public/earth/js/iridium-footprint-adapter.js
Normal file
252
frontend/public/earth/js/iridium-footprint-adapter.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
const EARTH_RADIUS_KM = 6378.137;
|
||||
const SURFACE_SCALE = 1.003;
|
||||
const SURFACE_OFFSET = 0.72;
|
||||
const CLUSTER_DIAMETER_KM_APPROX = 4500;
|
||||
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
|
||||
const IRIDIUM_OVERLAY_COLOR = 0x5faeff;
|
||||
const IRIDIUM_REFERENCE_ALTITUDE_KM = 780;
|
||||
const FILL_RINGS = 12;
|
||||
const FILL_SEGMENTS = 48;
|
||||
const RING_SEGMENTS = 72;
|
||||
|
||||
function disposeMaterial(material) {
|
||||
if (!material) return;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach(disposeMaterial);
|
||||
return;
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeObjectTree(object) {
|
||||
if (!object) return;
|
||||
object.traverse((child) => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) disposeMaterial(child.material);
|
||||
});
|
||||
}
|
||||
|
||||
function createIridiumFillMaterial() {
|
||||
return new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color(IRIDIUM_OVERLAY_COLOR) },
|
||||
uOpacity: { value: 0.55 },
|
||||
},
|
||||
vertexShader: `
|
||||
attribute vec2 aUv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = aUv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float r2 = dot(vUv, vUv);
|
||||
float glow = exp(-r2 * 1.4) * (1.0 - smoothstep(0.72, 1.0, r2));
|
||||
float alpha = glow * uOpacity;
|
||||
if (alpha <= 0.001) discard;
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
function createIridiumRingMaterial() {
|
||||
return new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(IRIDIUM_OVERLAY_COLOR),
|
||||
transparent: true,
|
||||
opacity: 0.75,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
function projectOffsetToSurface(
|
||||
centerNormal,
|
||||
alongTrack,
|
||||
crossTrack,
|
||||
alongKm,
|
||||
crossKm,
|
||||
earthRadiusWorld,
|
||||
) {
|
||||
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
|
||||
const surfaceRadius = earthRadiusWorld * SURFACE_SCALE + SURFACE_OFFSET;
|
||||
return centerNormal
|
||||
.clone()
|
||||
.multiplyScalar(earthRadiusWorld)
|
||||
.addScaledVector(alongTrack, alongKm * worldUnitsPerKm)
|
||||
.addScaledVector(crossTrack, crossKm * worldUnitsPerKm)
|
||||
.normalize()
|
||||
.multiplyScalar(surfaceRadius);
|
||||
}
|
||||
|
||||
function computeClusterRadiusKm(altitudeKm) {
|
||||
const altitudeScale = THREE.MathUtils.clamp(
|
||||
(Number(altitudeKm) || IRIDIUM_REFERENCE_ALTITUDE_KM) / IRIDIUM_REFERENCE_ALTITUDE_KM,
|
||||
0.88,
|
||||
1.18,
|
||||
);
|
||||
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
|
||||
}
|
||||
|
||||
function buildFillGeometry() {
|
||||
// Radial grid: center + FILL_RINGS rings × FILL_SEGMENTS points each.
|
||||
// Positions are updated in world space each frame; indices are static.
|
||||
const vertexCount = 1 + FILL_RINGS * FILL_SEGMENTS;
|
||||
const positions = new Float32Array(vertexCount * 3);
|
||||
const uvs = new Float32Array(vertexCount * 2);
|
||||
|
||||
// Center vertex: uv = (0,0)
|
||||
// Edge vertices: uv on unit circle, r = ring/FILL_RINGS
|
||||
|
||||
const indices = [];
|
||||
// Center to first ring: triangle fan
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const a = 1 + s;
|
||||
const b = 1 + (s + 1) % FILL_SEGMENTS;
|
||||
indices.push(0, a, b);
|
||||
}
|
||||
// Ring to ring
|
||||
for (let r = 0; r < FILL_RINGS - 1; r++) {
|
||||
const ringBase = 1 + r * FILL_SEGMENTS;
|
||||
const nextBase = ringBase + FILL_SEGMENTS;
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const s1 = (s + 1) % FILL_SEGMENTS;
|
||||
indices.push(ringBase + s, nextBase + s, ringBase + s1);
|
||||
indices.push(nextBase + s, nextBase + s1, ringBase + s1);
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("aUv", new THREE.BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function buildRingGeometry() {
|
||||
const positions = new Float32Array(RING_SEGMENTS * 3);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function createIridiumFootprintAdapter({
|
||||
earthObj,
|
||||
earthRadiusWorld,
|
||||
renderOrder,
|
||||
}) {
|
||||
if (!earthObj) return null;
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.name = "iridium-footprint-overlay";
|
||||
group.renderOrder = renderOrder;
|
||||
group.userData = { earthRadiusWorld, fill: null, outerRing: null };
|
||||
|
||||
const fill = new THREE.Mesh(buildFillGeometry(), createIridiumFillMaterial());
|
||||
fill.name = "iridium-cluster-fill";
|
||||
fill.renderOrder = renderOrder;
|
||||
fill.frustumCulled = false;
|
||||
group.add(fill);
|
||||
group.userData.fill = fill;
|
||||
|
||||
const outerRing = new THREE.LineLoop(buildRingGeometry(), createIridiumRingMaterial());
|
||||
outerRing.name = "iridium-outer-ring";
|
||||
outerRing.renderOrder = renderOrder;
|
||||
outerRing.frustumCulled = false;
|
||||
group.add(outerRing);
|
||||
group.userData.outerRing = outerRing;
|
||||
|
||||
earthObj.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function updateIridiumFootprintAdapter(
|
||||
group,
|
||||
{ position, alongTrack, crossTrack, altitudeKm },
|
||||
) {
|
||||
if (!group || !position || !alongTrack || !crossTrack) return;
|
||||
|
||||
const earthRadiusWorld = group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
|
||||
const centerNormal = position.clone().normalize();
|
||||
const clusterRadiusKm = computeClusterRadiusKm(altitudeKm);
|
||||
const alongRadiusKm = clusterRadiusKm * 1.18;
|
||||
const crossRadiusKm = clusterRadiusKm * 0.96;
|
||||
|
||||
const fill = group.userData?.fill;
|
||||
if (fill) {
|
||||
const posAttr = fill.geometry.attributes.position;
|
||||
const uvAttr = fill.geometry.attributes.aUv;
|
||||
|
||||
// Center vertex
|
||||
const center = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack, 0, 0, earthRadiusWorld,
|
||||
);
|
||||
posAttr.setXYZ(0, center.x, center.y, center.z);
|
||||
uvAttr.setXY(0, 0, 0);
|
||||
|
||||
// Ring vertices
|
||||
for (let r = 1; r <= FILL_RINGS; r++) {
|
||||
const t = r / FILL_RINGS;
|
||||
const aKm = alongRadiusKm * t;
|
||||
const cKm = crossRadiusKm * t;
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const angle = (s / FILL_SEGMENTS) * Math.PI * 2;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
const pt = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack,
|
||||
aKm * cosA,
|
||||
cKm * sinA,
|
||||
earthRadiusWorld,
|
||||
);
|
||||
const vi = 1 + (r - 1) * FILL_SEGMENTS + s;
|
||||
posAttr.setXYZ(vi, pt.x, pt.y, pt.z);
|
||||
uvAttr.setXY(vi, t * cosA, t * sinA);
|
||||
}
|
||||
}
|
||||
|
||||
posAttr.needsUpdate = true;
|
||||
uvAttr.needsUpdate = true;
|
||||
fill.geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
const outerRing = group.userData?.outerRing;
|
||||
if (outerRing) {
|
||||
const posAttr = outerRing.geometry.attributes.position;
|
||||
for (let k = 0; k < RING_SEGMENTS; k++) {
|
||||
const angle = (k / RING_SEGMENTS) * Math.PI * 2;
|
||||
const pt = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack,
|
||||
alongRadiusKm * Math.cos(angle),
|
||||
crossRadiusKm * Math.sin(angle),
|
||||
earthRadiusWorld,
|
||||
);
|
||||
posAttr.setXYZ(k, pt.x, pt.y, pt.z);
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
outerRing.geometry.computeBoundingSphere();
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeIridiumFootprintAdapter(group, earthObj) {
|
||||
if (!group) return;
|
||||
if (earthObj) {
|
||||
earthObj.remove(group);
|
||||
} else if (group.parent) {
|
||||
group.parent.remove(group);
|
||||
}
|
||||
disposeObjectTree(group);
|
||||
}
|
||||
@@ -23,12 +23,14 @@ export function setLayerButtonState(button, options = {}) {
|
||||
const {
|
||||
active = null,
|
||||
loading = false,
|
||||
disabled = null,
|
||||
tooltip = null,
|
||||
statusText = null,
|
||||
} = options;
|
||||
button.classList.toggle("is-loading", loading);
|
||||
button.toggleAttribute("aria-busy", loading);
|
||||
button.disabled = loading;
|
||||
button.disabled = loading || (disabled === true);
|
||||
button.classList.toggle("is-disabled", disabled === true);
|
||||
if (typeof active === "boolean") {
|
||||
updateLayerButtonState(button, active);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
loadComputeCenters,
|
||||
toggleComputeCenters,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
getCountryBoundaryLegendItems,
|
||||
loadCountryBoundaries,
|
||||
toggleCountryBoundaries,
|
||||
} from "./country-boundaries.js";
|
||||
|
||||
/**
|
||||
* Layer startup task registry.
|
||||
@@ -72,10 +77,11 @@ export function registerLayerStartupTask(id, taskFactory) {
|
||||
|
||||
function registerBuiltinLayerStartupTasks() {
|
||||
startupTaskRegistry.clear();
|
||||
registerCountryBoundaryStartupTask();
|
||||
registerCableStartupTask();
|
||||
registerSatelliteStartupTask();
|
||||
registerComputeCenterStartupTask();
|
||||
registerBGPStartupTask();
|
||||
registerSatelliteStartupTask();
|
||||
}
|
||||
|
||||
function registerCableStartupTask() {
|
||||
@@ -176,6 +182,32 @@ function registerBGPStartupTask() {
|
||||
});
|
||||
}
|
||||
|
||||
function registerCountryBoundaryStartupTask() {
|
||||
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载海陆基座..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
await loadCountryBoundaries();
|
||||
if (!context.isCancelled()) {
|
||||
const textureOn = context.isEarthTextureVisible();
|
||||
toggleCountryBoundaries(context.getShowCountryBoundaries(), {
|
||||
showTint: !textureOn,
|
||||
showLandFill: true,
|
||||
suppressLandFill: false,
|
||||
});
|
||||
context.setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
context.refreshLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "国界", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
function registerComputeCenterStartupTask() {
|
||||
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHUDPanel } from "./hud-panels.js";
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
countryBoundaries: { title: "国界" },
|
||||
computeCenters: { title: "算力" },
|
||||
bgp: { title: "BGP" },
|
||||
};
|
||||
@@ -12,6 +13,7 @@ let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
countryBoundaries: [],
|
||||
computeCenters: [],
|
||||
bgp: [],
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
357
frontend/public/earth/js/news-cruise-adapter.js
Normal file
357
frontend/public/earth/js/news-cruise-adapter.js
Normal file
@@ -0,0 +1,357 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
|
||||
import { showInfoCard, hideInfoCard } from "./info-card.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import {
|
||||
createConnectorPath,
|
||||
resolveConnectorAnchor,
|
||||
} from "./callout-connector.js";
|
||||
import {
|
||||
ensureNewsPanelReady,
|
||||
getNewsPayload,
|
||||
selectNewsItem,
|
||||
clearSelectedNewsItem,
|
||||
} from "./news.js";
|
||||
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||
const MOBILE_CARD_MARGIN_PX = 14;
|
||||
const MOBILE_CARD_TOP_RATIO = 0.16;
|
||||
const MOBILE_CARD_WIDTH_PX = 220;
|
||||
const scratchNewsWorldPosition = new THREE.Vector3();
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
function getItemTimestamp(item) {
|
||||
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatPublishedAt(rawValue) {
|
||||
if (!rawValue) return "刚刚同步";
|
||||
const parsed = new Date(rawValue);
|
||||
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
|
||||
return parsed.toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getCardPlacement() {
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const safeBottom =
|
||||
parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||
) || 0;
|
||||
const width = Math.min(MOBILE_CARD_WIDTH_PX, window.innerWidth - MOBILE_CARD_MARGIN_PX * 2);
|
||||
return {
|
||||
x: Math.max(MOBILE_CARD_MARGIN_PX, window.innerWidth - width - MOBILE_CARD_MARGIN_PX),
|
||||
y: Math.max(
|
||||
MOBILE_CARD_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerHeight * MOBILE_CARD_TOP_RATIO,
|
||||
window.innerHeight - safeBottom - 120,
|
||||
),
|
||||
),
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
const hudScale =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
) || 1;
|
||||
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
|
||||
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
|
||||
|
||||
return {
|
||||
x: window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5,
|
||||
y: window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function mapNewsItemToCruiseEvent(item) {
|
||||
const latitude = Number(item?.latitude);
|
||||
const longitude = Number(item?.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `news:${item.id}`,
|
||||
sourceId: item.id,
|
||||
type: "news",
|
||||
title: item.title || "新闻事件",
|
||||
summary: item.summary || "",
|
||||
source: item.source || "",
|
||||
feedName: item.feed_name || "",
|
||||
region: item.region || "global",
|
||||
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
|
||||
url: item.url || "",
|
||||
publishedAt: item.published_at || null,
|
||||
publishedAtDisplay: formatPublishedAt(item.published_at),
|
||||
latitude,
|
||||
longitude,
|
||||
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
sortTimestamp: getItemTimestamp(item),
|
||||
};
|
||||
}
|
||||
|
||||
export function createNewsCruiseAdapter({ camera, earth, connector, focusView }) {
|
||||
let currentItemId = null;
|
||||
let knownEventIds = new Set();
|
||||
let cardPlacement = null;
|
||||
|
||||
function getVisibleMobilePopup() {
|
||||
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||
? mobilePopup
|
||||
: null;
|
||||
}
|
||||
|
||||
function getVisibleInfoPanel() {
|
||||
const infoPanel = document.getElementById("info-panel");
|
||||
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||
? infoPanel
|
||||
: null;
|
||||
}
|
||||
|
||||
function getItemWorldPosition(item) {
|
||||
const earthObj = earth?.();
|
||||
if (!earthObj || !item) return null;
|
||||
scratchNewsWorldPosition.copy(
|
||||
latLonToVector3(item.latitude, item.longitude, CONFIG.earthRadius + 0.3),
|
||||
);
|
||||
earthObj.localToWorld(scratchNewsWorldPosition);
|
||||
return scratchNewsWorldPosition;
|
||||
}
|
||||
|
||||
function getItemScreenCoords(item) {
|
||||
if (!camera || !item) return null;
|
||||
const worldPosition = getItemWorldPosition(item);
|
||||
if (!worldPosition) return null;
|
||||
const projected = worldPosition.clone().project(camera);
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardAnchorTarget() {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||
return {
|
||||
element: mobilePopup,
|
||||
side: mobilePopup.dataset.dockSide || "left",
|
||||
alignRatio: 0.5,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||
return { element: mobilePopup };
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
if (infoPanel) {
|
||||
return { element: infoPanel };
|
||||
}
|
||||
|
||||
if (fallbackPlacement) {
|
||||
return {
|
||||
x: fallbackPlacement.x,
|
||||
y: fallbackPlacement.y,
|
||||
width: fallbackPlacement.width ?? 0,
|
||||
height: fallbackPlacement.height ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectorPath(item) {
|
||||
const itemCoords = getItemScreenCoords(item);
|
||||
if (!itemCoords) return null;
|
||||
|
||||
const targetPlacement = cardPlacement || getCardPlacement();
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const anchorTarget = getCardAnchorTarget();
|
||||
const anchorCoords = resolveConnectorAnchor(anchorTarget);
|
||||
if (!anchorCoords) return null;
|
||||
return createConnectorPath(itemCoords, anchorTarget ?? anchorCoords, {
|
||||
routingMode: "adaptive",
|
||||
obstacles: getCardObstacleTarget(targetPlacement),
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
const target =
|
||||
infoPanel ||
|
||||
{
|
||||
x: targetPlacement.x,
|
||||
y: targetPlacement.y,
|
||||
width: targetPlacement.width,
|
||||
height: targetPlacement.height,
|
||||
};
|
||||
|
||||
return createConnectorPath(itemCoords, target, {
|
||||
routingMode: "adaptive",
|
||||
obstacles: getCardObstacleTarget(targetPlacement),
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
function renderConnector(item, { animate = false } = {}) {
|
||||
const path = getConnectorPath(item);
|
||||
if (!path) return false;
|
||||
return connector?.render(path, { animate }) === true;
|
||||
}
|
||||
|
||||
function getSortedItems() {
|
||||
const items = Array.isArray(getNewsPayload()?.items) ? getNewsPayload().items : [];
|
||||
return items
|
||||
.map(mapNewsItemToCruiseEvent)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureItemsLoaded() {
|
||||
await ensureNewsPanelReady();
|
||||
},
|
||||
getSortedItems,
|
||||
clearCurrentHighlight() {
|
||||
currentItemId = null;
|
||||
clearSelectedNewsItem();
|
||||
},
|
||||
async focusItem(item, { interrupt = false } = {}) {
|
||||
if (!item) return;
|
||||
currentItemId = item.id;
|
||||
await ensureNewsPanelReady();
|
||||
await focusView({
|
||||
lat: item.latitude,
|
||||
lon: item.longitude,
|
||||
rotLon: item.longitude - 270,
|
||||
duration: interrupt
|
||||
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
|
||||
: CRUISE_CONFIG.focusDurationMs,
|
||||
suppressStatus: true,
|
||||
});
|
||||
cardPlacement = getCardPlacement();
|
||||
},
|
||||
async presentItem(item, { context }) {
|
||||
if (!item) return false;
|
||||
|
||||
selectNewsItem(item.sourceId);
|
||||
const placement = cardPlacement || getCardPlacement();
|
||||
cardPlacement = placement;
|
||||
showInfoCard("news", {
|
||||
title: item.title,
|
||||
summary: item.summary || item.title || "",
|
||||
}, {
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
reveal: false,
|
||||
});
|
||||
|
||||
await context.nextFrame();
|
||||
if (!context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let connectorReady = false;
|
||||
while (context.isCurrent()) {
|
||||
connectorReady = renderConnector(item, { animate: !connectorReady });
|
||||
if (connectorReady) break;
|
||||
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
||||
break;
|
||||
}
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
if (!connectorReady || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
|
||||
if (!connectorDelayCompleted || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
showInfoCard("news", {
|
||||
title: item.title,
|
||||
summary: item.summary || item.title || "",
|
||||
}, {
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
});
|
||||
await context.nextFrame();
|
||||
return context.isCurrent();
|
||||
},
|
||||
async hidePresentation({ context }) {
|
||||
clearSelectedNewsItem();
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true });
|
||||
cardPlacement = null;
|
||||
},
|
||||
repositionConnector(item) {
|
||||
if (!cardPlacement || !item || !connector?.isVisible?.() || connector.isAnimating?.()) {
|
||||
return;
|
||||
}
|
||||
renderConnector(item, { animate: false });
|
||||
},
|
||||
resetPresentation() {
|
||||
clearSelectedNewsItem();
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
},
|
||||
syncKnownEventIds() {
|
||||
knownEventIds = new Set(getSortedItems().map((item) => item.id));
|
||||
return knownEventIds;
|
||||
},
|
||||
diffNewEventIds(itemIds = []) {
|
||||
return itemIds
|
||||
.map((itemId) => `news:${itemId}`)
|
||||
.filter((itemId) => !knownEventIds.has(itemId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { isTVPanelVisible } from "./tv.js";
|
||||
import { getActiveTVTab, isTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
@@ -17,6 +17,7 @@ let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
let selectedCruiseStoryId = null;
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
return {
|
||||
@@ -154,7 +155,7 @@ function renderPayload(nextPayload) {
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
@@ -169,6 +170,14 @@ function renderPayload(nextPayload) {
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
applyCruiseStorySelection();
|
||||
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
|
||||
detail: {
|
||||
payload: nextPayload,
|
||||
itemIds: items.map((item) => item.id),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
@@ -287,6 +296,44 @@ export async function ensureNewsPanelReady() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function getNewsPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
|
||||
if (!(board instanceof HTMLElement)) return;
|
||||
const cards = board.querySelectorAll("[data-news-id]");
|
||||
cards.forEach((card) => {
|
||||
const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId;
|
||||
card.classList.toggle("news-story-card--cruise", matches);
|
||||
if (matches && scrollIntoView) {
|
||||
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyCruiseStorySelection(options = {}) {
|
||||
const desktopBoard = document.getElementById("news-board-list");
|
||||
const mobileBoard = document.getElementById("mobile-news-board-list");
|
||||
updateBoardSelection(desktopBoard, options);
|
||||
updateBoardSelection(mobileBoard, options);
|
||||
}
|
||||
|
||||
function revealSelectedCruiseStory() {
|
||||
if (!selectedCruiseStoryId) return;
|
||||
applyCruiseStorySelection({ scrollIntoView: true });
|
||||
}
|
||||
|
||||
export function selectNewsItem(itemId, options = {}) {
|
||||
selectedCruiseStoryId = itemId || null;
|
||||
applyCruiseStorySelection(options);
|
||||
}
|
||||
|
||||
export function clearSelectedNewsItem() {
|
||||
selectedCruiseStoryId = null;
|
||||
applyCruiseStorySelection();
|
||||
}
|
||||
|
||||
export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
@@ -296,9 +343,15 @@ export function initNewsPanel() {
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
if (getActiveTVTab() === "news") {
|
||||
revealSelectedCruiseStory();
|
||||
}
|
||||
});
|
||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
if (event.detail?.visible && getActiveTVTab() === "news") {
|
||||
revealSelectedCruiseStory();
|
||||
}
|
||||
});
|
||||
|
||||
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -242,13 +242,15 @@ export function openSearchPanel() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
|
||||
);
|
||||
window.setTimeout(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Running search failed:", error);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Running search failed:", error);
|
||||
});
|
||||
});
|
||||
}, 16);
|
||||
});
|
||||
}
|
||||
|
||||
export function focusSearchInput({ select = false } = {}) {
|
||||
|
||||
@@ -36,6 +36,7 @@ const tabPanelState = {
|
||||
live: null,
|
||||
news: null,
|
||||
};
|
||||
let mobileMetaCollapsed = false;
|
||||
|
||||
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
||||
@@ -72,8 +73,8 @@ function getElements() {
|
||||
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
|
||||
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
|
||||
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
|
||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
|
||||
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
|
||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||
newsHeaderControls: document.getElementById("tv-header-controls-news"),
|
||||
liveTabBtn: document.getElementById("tv-tab-live"),
|
||||
@@ -84,8 +85,43 @@ function getElements() {
|
||||
};
|
||||
}
|
||||
|
||||
function isMobileLayout() {
|
||||
return document.body.classList.contains("layout-mode-mobile");
|
||||
}
|
||||
|
||||
function syncMetaToggleState(collapsed) {
|
||||
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||
if (mobileOverviewBar instanceof HTMLElement) {
|
||||
mobileOverviewBar.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
}
|
||||
const desktopToggle = document.getElementById("tv-meta-toggle");
|
||||
if (desktopToggle instanceof HTMLButtonElement) {
|
||||
desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
desktopToggle.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? "展开新闻直播内容" : "折叠新闻直播内容",
|
||||
);
|
||||
desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容";
|
||||
}
|
||||
}
|
||||
|
||||
function isMetaCollapsed() {
|
||||
if (isMobileLayout()) {
|
||||
return mobileMetaCollapsed;
|
||||
}
|
||||
return mediaPanel?.isCollapsed() ?? false;
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
if (isMobileLayout()) {
|
||||
const { metaWrap } = getElements();
|
||||
mobileMetaCollapsed = Boolean(collapsed);
|
||||
metaWrap?.classList.toggle("is-collapsed", mobileMetaCollapsed);
|
||||
syncMetaToggleState(mobileMetaCollapsed);
|
||||
return;
|
||||
}
|
||||
mediaPanel?.setCollapsed(collapsed);
|
||||
syncMetaToggleState(Boolean(collapsed));
|
||||
}
|
||||
|
||||
function syncPanelActiveTab(tab = activeTab) {
|
||||
@@ -120,6 +156,10 @@ function syncNewsDefaultMaxHeight() {
|
||||
}
|
||||
|
||||
function autoExpandMeta() {
|
||||
if (isMobileLayout()) {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
return;
|
||||
}
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
setMetaCollapsed(false);
|
||||
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
||||
@@ -154,7 +194,7 @@ function captureTabState(tab = activeTab) {
|
||||
tabPanelState[tab] = {
|
||||
layout: readPanelLayoutState(panel),
|
||||
metaCollapsed:
|
||||
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
|
||||
tab === "live" ? isMetaCollapsed() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -749,15 +789,59 @@ function getExternalUrl(source) {
|
||||
}
|
||||
|
||||
function updateOpenButton(source) {
|
||||
const { openBtn } = getElements();
|
||||
if (!openBtn) return;
|
||||
const targetUrl = getExternalUrl(source);
|
||||
openBtn.disabled = !targetUrl;
|
||||
openBtn.onclick = targetUrl
|
||||
? () => {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
: null;
|
||||
[
|
||||
document.getElementById("mobile-tv-open-external"),
|
||||
document.getElementById("tv-open-external"),
|
||||
].forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.disabled = !targetUrl;
|
||||
button.onclick = targetUrl
|
||||
? () => {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
function syncMobileOverviewSummary(source) {
|
||||
const headline = document.getElementById("mobile-tv-overview-headline");
|
||||
const summary = document.getElementById("mobile-tv-overview-summary");
|
||||
const tags = document.getElementById("mobile-tv-overview-tags");
|
||||
if (headline instanceof HTMLElement) {
|
||||
headline.textContent = source?.name || "暂无可用频道";
|
||||
}
|
||||
if (summary instanceof HTMLElement) {
|
||||
if (!source) {
|
||||
summary.textContent = "点击查看当前频道来源、目录和补充说明";
|
||||
} else {
|
||||
const parts = [
|
||||
source.provider,
|
||||
source.region,
|
||||
source.language,
|
||||
].filter(Boolean);
|
||||
summary.textContent = parts.length
|
||||
? parts.join(" · ")
|
||||
: "点击查看完整频道信息";
|
||||
}
|
||||
}
|
||||
if (tags instanceof HTMLElement) {
|
||||
const tagValues = source
|
||||
? [
|
||||
{ label: source.source_type || "频道", kind: "status" },
|
||||
source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" },
|
||||
source.region ? { label: source.region, kind: "" } : null,
|
||||
].filter(Boolean).slice(0, 3)
|
||||
: [{ label: "待加载", kind: "status" }];
|
||||
tags.replaceChildren(
|
||||
...tagValues.map(({ label, kind }) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = `earth-mobile-tv-overview-tag${kind ? ` earth-mobile-tv-overview-tag--${kind}` : ""}`;
|
||||
chip.textContent = label;
|
||||
return chip;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function findSourceById(sourceId) {
|
||||
@@ -926,6 +1010,7 @@ function renderSource(source) {
|
||||
if (notes) {
|
||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||
}
|
||||
syncMobileOverviewSummary(source);
|
||||
|
||||
if (!source || (!embeddedUrl && !videoUrl)) {
|
||||
destroyHlsPlayer();
|
||||
@@ -1031,6 +1116,7 @@ export function initTVPanel() {
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
} = getElements();
|
||||
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||
|
||||
if (panel && metaToggle) {
|
||||
mediaPanel = createHUDPanel({
|
||||
@@ -1045,6 +1131,10 @@ export function initTVPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
mobileMetaCollapsed = true;
|
||||
syncMetaToggleState(isMetaCollapsed());
|
||||
setMetaCollapsed(isMetaCollapsed());
|
||||
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
@@ -1084,10 +1174,31 @@ export function initTVPanel() {
|
||||
});
|
||||
});
|
||||
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
[metaToggle, document.getElementById("tv-meta-toggle")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((toggleEl) => {
|
||||
toggleEl?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !isMetaCollapsed();
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
});
|
||||
|
||||
const toggleMobileMeta = (event) => {
|
||||
const interactiveTarget = event.target instanceof Element
|
||||
? event.target.closest("button, a, select, input, textarea, video, iframe")
|
||||
: null;
|
||||
if (interactiveTarget) return;
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
|
||||
const isNowCollapsed = !isMetaCollapsed();
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
};
|
||||
|
||||
mobileOverviewBar?.addEventListener("click", toggleMobileMeta);
|
||||
mobileOverviewBar?.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
toggleMobileMeta(event);
|
||||
});
|
||||
|
||||
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
|
||||
|
||||
@@ -17,6 +17,7 @@ const Earth = lazy(() => import('./pages/Earth/Earth'))
|
||||
const Settings = lazy(() => import('./pages/Settings/Settings'))
|
||||
const BGP = lazy(() => import('./pages/BGP/BGP'))
|
||||
const Playground = lazy(() => import('./pages/Playground/Playground'))
|
||||
const Logs = lazy(() => import('./pages/Logs/Logs'))
|
||||
|
||||
function App() {
|
||||
const { token } = useAuthStore()
|
||||
@@ -48,6 +49,7 @@ function App() {
|
||||
<Route path="/alerts/situational" element={<SituationalAlerts />} />
|
||||
<Route path="/bgp" element={<BGP />} />
|
||||
<Route path="/playground" element={<Playground />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AppstoreOutlined,
|
||||
ToolOutlined,
|
||||
InboxOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||
@@ -39,6 +40,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
|
||||
const showBanner = true
|
||||
const appVersion = `v${packageJson.version}`
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
|
||||
const menuItems: ItemType<MenuItemType>[] = [
|
||||
{
|
||||
@@ -83,6 +85,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
label: '运维与配置',
|
||||
children: [
|
||||
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
|
||||
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
],
|
||||
|
||||
@@ -3414,8 +3414,365 @@ body {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.system-log-console {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
background: #020617;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.system-log-console__actions {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.system-log-console__actions .ant-btn {
|
||||
color: rgba(226, 232, 240, 0.78) !important;
|
||||
}
|
||||
|
||||
.system-log-console__actions .ant-btn:hover {
|
||||
color: #f8fafc !important;
|
||||
background: rgba(148, 163, 184, 0.16) !important;
|
||||
}
|
||||
|
||||
.system-log-console__scroll,
|
||||
.system-log-console__scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.system-log-console__scroll,
|
||||
.system-log-console__scroll .scrollbar__viewport,
|
||||
.system-log-console__content,
|
||||
.system-log-console__placeholder {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.system-log-console__content {
|
||||
margin: 0;
|
||||
padding: 18px 124px 18px 20px;
|
||||
color: #e2e8f0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.system-log-console__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.logs-page {
|
||||
--logs-filter-toggle-size: 32px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logs-page__header-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-page .page-shell__header {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logs-page__header-desc {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.logs-page__card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-page__card .ant-card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.logs-page__card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-page__console-shell {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-page__toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 249, 252, 0.98) 100%);
|
||||
border: 1px solid rgba(5, 5, 5, 0.07);
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--primary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 220px) minmax(220px, 280px) minmax(280px, 1fr) var(--logs-filter-toggle-size);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logs-page__source-select {
|
||||
width: 240px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.logs-page__search-input {
|
||||
width: 100%;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.logs-page__level-select {
|
||||
width: 100%;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.logs-page__date-range {
|
||||
width: 248px;
|
||||
}
|
||||
|
||||
.logs-page__line-limit-select {
|
||||
width: 156px;
|
||||
}
|
||||
|
||||
.logs-page__filter-toggle {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
width: var(--logs-filter-toggle-size);
|
||||
height: var(--logs-filter-toggle-size);
|
||||
padding: 0;
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.logs-page__filter-toggle:hover {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
border-color: rgba(5, 5, 5, 0.16);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.logs-page__filter-toggle.is-expanded {
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.28);
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
.logs-page__filters-panel {
|
||||
border-top: 1px solid rgba(5, 5, 5, 0.06);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--secondary {
|
||||
display: grid;
|
||||
grid-template-columns: 156px minmax(260px, 320px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.logs-page__preset-group {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.logs-page__preset-group .ant-space-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logs-page__preset-group .ant-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.logs-page__calendar-cell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.logs-page__calendar-cell .ant-picker-cell-inner {
|
||||
position: relative;
|
||||
transition: background 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.logs-page__calendar-cell--error .ant-picker-cell-inner {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.16);
|
||||
}
|
||||
|
||||
.logs-page__calendar-cell--warning .ant-picker-cell-inner {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.16);
|
||||
}
|
||||
|
||||
.logs-page__calendar-cell--info .ant-picker-cell-inner {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.16);
|
||||
}
|
||||
|
||||
.logs-page__calendar-cell--debug .ant-picker-cell-inner {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.logs-page__calendar-cell:hover .ant-picker-cell-inner {
|
||||
filter: saturate(1.04);
|
||||
}
|
||||
|
||||
.logs-page__line-limit-customizer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 1440px), (max-height: 900px) {
|
||||
.logs-page {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logs-page__header-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logs-page__card .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.logs-page__toolbar {
|
||||
padding: 8px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--primary {
|
||||
grid-template-columns: minmax(160px, 200px) minmax(180px, 220px) minmax(0, 1fr) var(--logs-filter-toggle-size);
|
||||
grid-template-areas:
|
||||
"source level search toggle";
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.logs-page__source-select,
|
||||
.logs-page__line-limit-select,
|
||||
.logs-page__level-select,
|
||||
.logs-page__search-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-page__source-select {
|
||||
grid-area: source;
|
||||
}
|
||||
|
||||
.logs-page__level-select {
|
||||
grid-area: level;
|
||||
}
|
||||
|
||||
.logs-page__search-input {
|
||||
grid-area: search;
|
||||
}
|
||||
|
||||
.logs-page__filter-toggle {
|
||||
grid-area: toggle;
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--secondary {
|
||||
grid-template-columns: 140px minmax(240px, 280px) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logs-page__date-range {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-page__console-shell {
|
||||
min-height: clamp(340px, 58vh, 760px);
|
||||
}
|
||||
|
||||
.system-log-console__content {
|
||||
padding: 16px 112px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-restart-toolbar__meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.logs-page__toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.logs-page__card .ant-card-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--primary,
|
||||
.logs-page__toolbar-row--secondary {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.logs-page__source-select,
|
||||
.logs-page__search-input,
|
||||
.logs-page__level-select,
|
||||
.logs-page__date-range,
|
||||
.logs-page__line-limit-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs-page__console-shell {
|
||||
min-height: clamp(280px, 50vh, 620px);
|
||||
}
|
||||
}
|
||||
|
||||
533
frontend/src/pages/Logs/Logs.tsx
Normal file
533
frontend/src/pages/Logs/Logs.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Card, DatePicker, Empty, Input, InputNumber, Select, Space, Spin, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { CopyOutlined, DownOutlined, InfoCircleOutlined, ReloadOutlined, UpOutlined } from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
const { RangePicker } = DatePicker
|
||||
const LOG_FILTER_STORAGE_KEY = 'planet.logs.filters'
|
||||
const DATE_PRESET_OPTIONS = [
|
||||
{ key: 'today', label: 'Today', days: 0 },
|
||||
{ key: 'last3', label: '3 Days', days: 2 },
|
||||
{ key: 'last7', label: '7 Days', days: 6 },
|
||||
] as const
|
||||
|
||||
interface LogSourceSummary {
|
||||
source_id: string
|
||||
name: string
|
||||
kind: string
|
||||
location: string
|
||||
description: string
|
||||
category: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface LogSourcesResponse {
|
||||
items: LogSourceSummary[]
|
||||
}
|
||||
|
||||
interface LogSnapshot {
|
||||
source_id: string
|
||||
name: string
|
||||
kind: string
|
||||
location: string
|
||||
description: string
|
||||
category: string
|
||||
status: string
|
||||
level: string
|
||||
selected_levels: string[]
|
||||
search_query: string
|
||||
available_levels: string[]
|
||||
daily_markers: Array<{
|
||||
date_token: string
|
||||
total: number
|
||||
dominant_level: 'error' | 'warning' | 'info' | 'debug'
|
||||
}>
|
||||
line_limit: number
|
||||
line_count: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
interface DailyLogMarker {
|
||||
total: number
|
||||
dominantLevel: 'error' | 'warning' | 'info' | 'debug'
|
||||
}
|
||||
|
||||
const LOG_LIMIT_OPTIONS = [100, 200, 400, 800]
|
||||
const LOG_LEVEL_OPTIONS = [
|
||||
{ value: 'error', label: 'ERROR' },
|
||||
{ value: 'warning', label: 'WARNING' },
|
||||
{ value: 'info', label: 'INFO' },
|
||||
{ value: 'debug', label: 'DEBUG' },
|
||||
]
|
||||
|
||||
function isDayjsValue(value: unknown): value is Dayjs {
|
||||
return dayjs.isDayjs(value)
|
||||
}
|
||||
|
||||
function normalizeSelectedLevels(levels: string[] | null | undefined): string[] {
|
||||
const allowedLevels = new Set(LOG_LEVEL_OPTIONS.map((item) => item.value))
|
||||
return Array.from(new Set((levels || []).filter((level) => allowedLevels.has(level))))
|
||||
}
|
||||
|
||||
function getLogLevelTagColor(level: string): string {
|
||||
if (level === 'error') return 'error'
|
||||
if (level === 'warning') return 'warning'
|
||||
if (level === 'info') return 'success'
|
||||
if (level === 'debug') return 'default'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function readStoredFilters() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(LOG_FILTER_STORAGE_KEY)
|
||||
if (!rawValue) return null
|
||||
const parsed = JSON.parse(rawValue) as {
|
||||
selectedSource?: string
|
||||
lineLimit?: number
|
||||
selectedLevels?: string[]
|
||||
selectedDateRange?: [string, string] | null
|
||||
searchQuery?: string
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
if (status === 'ok') return '可用'
|
||||
if (status === 'missing') return '暂无日志'
|
||||
if (status === 'empty') return '暂无上报'
|
||||
if (status === 'docker_unavailable') return 'Docker 不可用'
|
||||
if (status === 'source_unavailable') return '日志源不可用'
|
||||
return status
|
||||
}
|
||||
|
||||
function getStatusHelp(status: string): string | null {
|
||||
if (status === 'missing') return '当前日志文件尚未生成,通常需要先启动对应服务。'
|
||||
if (status === 'empty') return '当前日志源还没有收到任何上报事件。'
|
||||
if (status === 'docker_unavailable') return '当前环境没有可用的 docker 命令,暂时无法读取容器日志。'
|
||||
if (status === 'source_unavailable') return '日志源当前不可读取,请检查服务是否已启动。'
|
||||
return null
|
||||
}
|
||||
|
||||
function resolvePresetRange(days: number): [Dayjs, Dayjs] {
|
||||
const end = dayjs().endOf('day')
|
||||
const start = dayjs().subtract(days, 'day').startOf('day')
|
||||
return [start, end]
|
||||
}
|
||||
|
||||
function normalizeDateRange(
|
||||
range: [Dayjs | null, Dayjs | null] | null,
|
||||
): [Dayjs | null, Dayjs | null] | null {
|
||||
if (!range?.[0] || !range?.[1]) return null
|
||||
return [range[0].startOf('day'), range[1].endOf('day')]
|
||||
}
|
||||
|
||||
function getActiveDatePreset(range: [Dayjs | null, Dayjs | null] | null): string | null {
|
||||
if (!range?.[0] || !range?.[1]) return null
|
||||
|
||||
for (const option of DATE_PRESET_OPTIONS) {
|
||||
const [presetStart, presetEnd] = resolvePresetRange(option.days)
|
||||
if (range[0].isSame(presetStart, 'day') && range[1].isSame(presetEnd, 'day')) {
|
||||
return option.key
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Logs() {
|
||||
const storedFilters = readStoredFilters()
|
||||
const { user } = useAuthStore()
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const [sources, setSources] = useState<LogSourceSummary[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState<string>(storedFilters?.selectedSource || 'backend')
|
||||
const [lineLimit, setLineLimit] = useState<number>(storedFilters?.lineLimit || 200)
|
||||
const [selectedLevels, setSelectedLevels] = useState<string[]>(
|
||||
normalizeSelectedLevels(storedFilters?.selectedLevels),
|
||||
)
|
||||
const [selectedDateRange, setSelectedDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(
|
||||
storedFilters?.selectedDateRange
|
||||
? normalizeDateRange([dayjs(storedFilters.selectedDateRange[0]), dayjs(storedFilters.selectedDateRange[1])])
|
||||
: null,
|
||||
)
|
||||
const [searchQuery, setSearchQuery] = useState<string>(typeof storedFilters?.searchQuery === 'string' ? storedFilters.searchQuery : '')
|
||||
const [snapshot, setSnapshot] = useState<LogSnapshot | null>(null)
|
||||
const [sourcesLoading, setSourcesLoading] = useState(false)
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(
|
||||
Boolean(
|
||||
storedFilters?.selectedLevels?.length
|
||||
|| (storedFilters?.selectedDateRange?.[0] && storedFilters?.selectedDateRange?.[1]),
|
||||
),
|
||||
)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const fetchSources = async () => {
|
||||
setSourcesLoading(true)
|
||||
try {
|
||||
const res = await axios.get<LogSourcesResponse>('/api/v1/system/logs/sources')
|
||||
setSources(res.data.items)
|
||||
setErrorMessage(null)
|
||||
if (res.data.items.length > 0 && !res.data.items.some((item) => item.source_id === selectedSource)) {
|
||||
setSelectedSource(res.data.items[0].source_id)
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||
setErrorMessage(typeof detail === 'string' ? detail : '加载日志源失败')
|
||||
} finally {
|
||||
setSourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSnapshot = async (
|
||||
sourceId: string,
|
||||
limit: number,
|
||||
levels: string[],
|
||||
dateRange: [Dayjs | null, Dayjs | null] | null,
|
||||
searchValue: string,
|
||||
) => {
|
||||
setLogLoading(true)
|
||||
try {
|
||||
const res = await axios.get<LogSnapshot>(`/api/v1/system/logs/${sourceId}`, {
|
||||
params: {
|
||||
limit,
|
||||
level: levels.length === 1 ? levels[0] : 'all',
|
||||
levels: levels.length > 0 ? levels.join(',') : undefined,
|
||||
start_date: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||
end_date: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||
search: searchValue.trim() || undefined,
|
||||
},
|
||||
})
|
||||
setSnapshot(res.data)
|
||||
setErrorMessage(null)
|
||||
} catch (error) {
|
||||
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||
setSnapshot(null)
|
||||
setErrorMessage(typeof detail === 'string' ? detail : '加载日志内容失败')
|
||||
} finally {
|
||||
setLogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) return
|
||||
fetchSources()
|
||||
}, [isSuperAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin || !selectedSource) return
|
||||
fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
|
||||
}, [isSuperAdmin, selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(
|
||||
LOG_FILTER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
selectedSource,
|
||||
lineLimit,
|
||||
selectedLevels,
|
||||
selectedDateRange:
|
||||
selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||
? [
|
||||
selectedDateRange[0].format('YYYY-MM-DD'),
|
||||
selectedDateRange[1].format('YYYY-MM-DD'),
|
||||
]
|
||||
: null,
|
||||
searchQuery,
|
||||
}),
|
||||
)
|
||||
}, [lineLimit, searchQuery, selectedLevels, selectedDateRange, selectedSource])
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<Alert type="warning" showIcon message="仅超级管理员可查看系统日志" />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedMeta = sources.find((item) => item.source_id === selectedSource)
|
||||
const statusHelp = getStatusHelp(snapshot?.status || selectedMeta?.status || '')
|
||||
const activeDatePreset = getActiveDatePreset(selectedDateRange)
|
||||
const dailyLogMarkers = useMemo(
|
||||
() =>
|
||||
new Map<string, DailyLogMarker>(
|
||||
(snapshot?.daily_markers || []).map((marker) => [
|
||||
marker.date_token,
|
||||
{
|
||||
total: marker.total,
|
||||
dominantLevel: marker.dominant_level,
|
||||
},
|
||||
]),
|
||||
),
|
||||
[snapshot?.daily_markers],
|
||||
)
|
||||
const currentResultLines = snapshot?.lines || []
|
||||
const lineCountLabel = currentResultLines.length
|
||||
const hasDateFilter = Boolean(selectedDateRange?.[0] && selectedDateRange?.[1])
|
||||
const hasAdvancedFilters = selectedLevels.length > 0 || hasDateFilter
|
||||
const effectiveLevelLabels = selectedLevels.length === 0
|
||||
? ['ALL']
|
||||
: normalizeSelectedLevels(selectedLevels).map(
|
||||
(level) => LOG_LEVEL_OPTIONS.find((item) => item.value === level)?.label || level.toUpperCase(),
|
||||
)
|
||||
|
||||
const applyDatePreset = (days: number) => {
|
||||
setSelectedDateRange(resolvePresetRange(days))
|
||||
}
|
||||
|
||||
const renderLevelTag = (props: CustomTagProps) => {
|
||||
const { label, value, closable, onClose } = props
|
||||
return (
|
||||
<Tag
|
||||
color={getLogLevelTagColor(String(value))}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
style={{ marginInlineEnd: 4 }}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
{contextHolder}
|
||||
<div className="page-shell logs-page">
|
||||
<div className="page-shell__header">
|
||||
<div className="logs-page__header-copy">
|
||||
<Title level={3} style={{ marginBottom: 2 }}>系统日志</Title>
|
||||
<Paragraph type="secondary" className="logs-page__header-desc" style={{ marginBottom: 0 }}>
|
||||
统一查看 Planet 当前关键服务日志,并串联 Earth 浏览器端错误、后端异常与服务输出。
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-shell__body">
|
||||
<Card className="logs-page__card">
|
||||
<div className="logs-page__card-body">
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<div className="logs-page__toolbar">
|
||||
<div className="logs-page__toolbar-row logs-page__toolbar-row--primary">
|
||||
<Select
|
||||
value={selectedSource}
|
||||
onChange={(value) => setSelectedSource(value)}
|
||||
loading={sourcesLoading}
|
||||
className="logs-page__source-select"
|
||||
options={sources.map((item) => ({
|
||||
value: item.source_id,
|
||||
label: item.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={selectedLevels}
|
||||
onChange={(value) => setSelectedLevels(normalizeSelectedLevels(value))}
|
||||
options={LOG_LEVEL_OPTIONS}
|
||||
className="logs-page__level-select"
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
tagRender={renderLevelTag}
|
||||
placeholder="全部级别"
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onSearch={(value) => setSearchQuery(value)}
|
||||
placeholder="搜索日志内容、模块名、错误关键字"
|
||||
className="logs-page__search-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`logs-page__filter-toggle ${filtersExpanded ? 'is-expanded' : ''}`}
|
||||
onClick={() => setFiltersExpanded((value) => !value)}
|
||||
aria-expanded={filtersExpanded}
|
||||
aria-label={filtersExpanded ? '收起筛选' : '展开筛选'}
|
||||
title={hasAdvancedFilters ? '筛选已启用' : '更多筛选'}
|
||||
>
|
||||
{filtersExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtersExpanded ? (
|
||||
<div className="logs-page__filters-panel">
|
||||
<div className="logs-page__toolbar-row logs-page__toolbar-row--secondary">
|
||||
<Select
|
||||
value={lineLimit}
|
||||
onChange={(value) => setLineLimit(Number(value))}
|
||||
options={LOG_LIMIT_OPTIONS.map((value) => ({ value, label: `最近 ${value} 行` }))}
|
||||
className="logs-page__line-limit-select"
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<div className="logs-page__line-limit-customizer">
|
||||
<Text type="secondary">自定义行数</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={1000}
|
||||
value={lineLimit}
|
||||
onChange={(value) => setLineLimit(Number(value) || 200)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<RangePicker
|
||||
allowClear
|
||||
value={selectedDateRange}
|
||||
onChange={(value) => setSelectedDateRange(normalizeDateRange(value as [Dayjs | null, Dayjs | null] | null))}
|
||||
cellRender={(current, info) => {
|
||||
if (info.type !== 'date' || !isDayjsValue(current)) return info.originNode
|
||||
|
||||
const marker = dailyLogMarkers.get(current.format('YYYY-MM-DD'))
|
||||
if (!marker) return info.originNode
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`logs-page__calendar-cell logs-page__calendar-cell--${marker.dominantLevel}`}
|
||||
title={`${current.format('YYYY-MM-DD')} · ${marker.total} lines`}
|
||||
>
|
||||
{info.originNode}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
className="logs-page__date-range"
|
||||
/>
|
||||
<Space size={6} className="logs-page__preset-group">
|
||||
{DATE_PRESET_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
size="small"
|
||||
type={activeDatePreset === option.key ? 'primary' : 'default'}
|
||||
onClick={() => applyDatePreset(option.days)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSelectedDateRange(null)}
|
||||
disabled={!hasDateFilter}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="logs-page__console-shell">
|
||||
<div className="system-log-console">
|
||||
<div className="system-log-console__actions">
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
<div><strong>{snapshot?.name || selectedMeta?.name || '未选择日志源'}</strong></div>
|
||||
<div>状态: {getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')}</div>
|
||||
<div>类型: {(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()}</div>
|
||||
<div>当前显示: {lineCountLabel} 行</div>
|
||||
{selectedLevels.length > 0 ? <div>级别: {effectiveLevelLabels.join(' / ')}</div> : null}
|
||||
{selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||
? <div>日期: {selectedDateRange[0].format('YYYY-MM-DD')} ~ {selectedDateRange[1].format('YYYY-MM-DD')}</div>
|
||||
: null}
|
||||
{searchQuery.trim() ? <div>检索: {searchQuery.trim()}</div> : null}
|
||||
<div>{snapshot?.description || selectedMeta?.description || '-'}</div>
|
||||
<div>位置: {snapshot?.location || selectedMeta?.location || '-'}</div>
|
||||
{statusHelp ? <div>{statusHelp}</div> : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<InfoCircleOutlined />}
|
||||
className="playground-message__actions-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新日志">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<ReloadOutlined />}
|
||||
className="playground-message__actions-btn"
|
||||
onClick={() => {
|
||||
void fetchSources()
|
||||
if (selectedSource) {
|
||||
void fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="复制日志">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<CopyOutlined />}
|
||||
className="playground-message__actions-btn"
|
||||
onClick={async () => {
|
||||
const content = currentResultLines.join('\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
messageApi.success('日志内容已复制')
|
||||
} catch {
|
||||
messageApi.error('复制失败,请检查浏览器剪贴板权限')
|
||||
}
|
||||
}}
|
||||
disabled={currentResultLines.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{logLoading ? (
|
||||
<div className="system-log-console__placeholder">
|
||||
<Spin />
|
||||
</div>
|
||||
) : currentResultLines.length > 0 ? (
|
||||
<Scrollbar className="system-log-console__scroll">
|
||||
<pre className="system-log-console__content">{currentResultLines.join('\n')}</pre>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<div className="system-log-console__placeholder">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||
? '当前日期范围没有匹配的日志内容'
|
||||
: '当前没有可显示的日志内容'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logs
|
||||
49
planet.sh
49
planet.sh
@@ -1025,7 +1025,14 @@ start_postgres_service() {
|
||||
|
||||
# Backend lifecycle helpers
|
||||
cleanup_backend_processes() {
|
||||
pkill -f "uvicorn" 2>/dev/null || true
|
||||
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
|
||||
terminate_backend_processes TERM "$backend_port"
|
||||
|
||||
if ! wait_for_port_release "$backend_port"; then
|
||||
terminate_backend_processes KILL "$backend_port"
|
||||
|
||||
wait_for_port_release "$backend_port" || true
|
||||
fi
|
||||
}
|
||||
|
||||
start_backend_with_retry() {
|
||||
@@ -1033,8 +1040,9 @@ start_backend_with_retry() {
|
||||
local retry=1
|
||||
|
||||
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
|
||||
cleanup_backend_processes
|
||||
cleanup_backend_processes "$backend_port"
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
: > /tmp/planet_backend.log
|
||||
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
@@ -1242,6 +1250,39 @@ collect_port_pids() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminate_process_group() {
|
||||
local signal="$1"
|
||||
local pid="$2"
|
||||
local pgid=""
|
||||
|
||||
[ -n "$pid" ] || return 0
|
||||
kill -0 "$pid" 2>/dev/null || return 0
|
||||
|
||||
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
|
||||
[ -n "$pgid" ] || return 0
|
||||
|
||||
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
terminate_backend_processes() {
|
||||
local signal="$1"
|
||||
local backend_port="$2"
|
||||
local pids=""
|
||||
local pid=""
|
||||
|
||||
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
|
||||
for pid in $pids; do
|
||||
terminate_process_group "$signal" "$pid"
|
||||
terminate_process_tree "$signal" "$pid"
|
||||
done
|
||||
|
||||
pids="$(collect_port_pids "$backend_port" || true)"
|
||||
for pid in $pids; do
|
||||
terminate_process_group "$signal" "$pid"
|
||||
terminate_process_tree "$signal" "$pid"
|
||||
done
|
||||
}
|
||||
|
||||
terminate_process_tree() {
|
||||
local signal="$1"
|
||||
local pid="$2"
|
||||
@@ -1521,8 +1562,8 @@ stop_container_if_running() {
|
||||
}
|
||||
|
||||
stop_backend_service() {
|
||||
if pgrep -f "uvicorn" >/dev/null 2>&1; then
|
||||
cleanup_backend_processes
|
||||
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
|
||||
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
|
||||
log_halt "后端服务已停止"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.37.0"
|
||||
version = "0.41.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
12
rules.md
12
rules.md
@@ -280,6 +280,18 @@ class BaseCollector:
|
||||
|
||||
---
|
||||
|
||||
## Country Data Validation - MANDATORY
|
||||
|
||||
- **ALL** data sources that carry a country, region, or territory field (API responses, GeoJSON, CSVs, scraped data, third-party enrichment) **MUST** have their country values validated against the project's canonical country dictionary at `backend/app/core/countries.py` before being stored or displayed
|
||||
- Use `normalize_country(value)` from `countries.py` as the single gate. If it returns `None`, the value is unrecognized and must be logged and rejected or flagged — **NEVER** silently pass it through
|
||||
- The dictionary encodes official political positions (e.g., Taiwan → 中国(台湾), Kosovo → 塞尔维亚, Gaza → 巴勒斯坦). Do **NOT** override these with raw source data labels
|
||||
- When integrating a new data source, run a pre-flight check: extract all distinct country values from the source and verify each one resolves via `normalize_country`. Fix unresolved values before wiring up the collector
|
||||
- Geographic boundary data (GeoJSON, shapefiles, tilesets) must be post-processed to align feature names and hover labels with the dictionary. The Natural Earth `ne_110m_admin_0_countries` dataset downloaded from GitHub was used as the base for the frontend boundary layer; political corrections were applied manually
|
||||
- If a new country alias needs to be added to the dictionary, add it to `COUNTRY_ENTRIES` in `countries.py` — **NEVER** scatter aliases across individual collectors or API handlers
|
||||
- Frontend hover tooltips and info cards that display country names must source the name from the canonical dictionary (via `NAME_ZH` after normalization), not raw source strings
|
||||
|
||||
---
|
||||
|
||||
## Frontend Layout - MANDATORY
|
||||
|
||||
- Backend/admin pages must be designed as a `single-screen workspace` first, not as a long vertically stacked document
|
||||
|
||||
Reference in New Issue
Block a user