diff --git a/backend/Dockerfile b/backend/Dockerfile index bf28c37c..75a98ce8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,4 +16,4 @@ COPY . . EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 296887a1..51c04353 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -120,6 +120,20 @@ COLLECTOR_INFO = { "priority": "P1", "frequency_hours": 168, }, + "spacetrack_tle": { + "id": 19, + "name": "Space-Track TLE", + "module": "L3", + "priority": "P2", + "frequency_hours": 24, + }, + "celestrak_tle": { + "id": 20, + "name": "CelesTrak TLE", + "module": "L3", + "priority": "P2", + "frequency_hours": 24, + }, } ID_TO_COLLECTOR = {info["id"]: name for name, info in COLLECTOR_INFO.items()} diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index df791995..bd3786c1 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -1,8 +1,13 @@ -"""Visualization API - GeoJSON endpoints for 3D Earth display""" +"""Visualization API - GeoJSON endpoints for 3D Earth display +Unified API for all visualization data sources. +Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium. +""" + +from datetime import datetime from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func from typing import List, Dict, Any, Optional from app.db.session import get_db @@ -12,6 +17,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph router = APIRouter() +# ============== Converter Functions ============== + + def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: """Convert cable records to GeoJSON FeatureCollection""" features = [] @@ -122,6 +130,117 @@ def convert_landing_point_to_geojson(records: List[CollectedData]) -> Dict[str, return {"type": "FeatureCollection", "features": features} +def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: + """Convert satellite TLE records to GeoJSON""" + features = [] + + for record in records: + metadata = record.extra_data or {} + norad_id = metadata.get("norad_cat_id") + + if not norad_id: + continue + + features.append( + { + "type": "Feature", + "id": norad_id, + "geometry": {"type": "Point", "coordinates": [0, 0, 0]}, + "properties": { + "id": record.id, + "norad_cat_id": norad_id, + "name": record.name, + "international_designator": metadata.get("international_designator"), + "epoch": metadata.get("epoch"), + "inclination": metadata.get("inclination"), + "raan": metadata.get("raan"), + "eccentricity": metadata.get("eccentricity"), + "arg_of_perigee": metadata.get("arg_of_perigee"), + "mean_anomaly": metadata.get("mean_anomaly"), + "mean_motion": metadata.get("mean_motion"), + "bstar": metadata.get("bstar"), + "classification_type": metadata.get("classification_type"), + "data_type": "satellite_tle", + }, + } + ) + + return {"type": "FeatureCollection", "features": features} + + +def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: + """Convert TOP500 supercomputer records to GeoJSON""" + features = [] + + for record in records: + try: + lat = float(record.latitude) if record.latitude and record.latitude != "0.0" else None + lon = ( + float(record.longitude) if record.longitude and record.longitude != "0.0" else None + ) + except (ValueError, TypeError): + lat, lon = None, None + + metadata = record.extra_data or {} + + features.append( + { + "type": "Feature", + "id": record.id, + "geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]}, + "properties": { + "id": record.id, + "name": record.name, + "rank": metadata.get("rank"), + "r_max": record.value, + "r_peak": metadata.get("r_peak"), + "cores": metadata.get("cores"), + "power": metadata.get("power"), + "country": record.country, + "city": record.city, + "data_type": "supercomputer", + }, + } + ) + + return {"type": "FeatureCollection", "features": features} + + +def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: + """Convert GPU cluster records to GeoJSON""" + features = [] + + for record in records: + try: + lat = float(record.latitude) if record.latitude else None + lon = float(record.longitude) if record.longitude else None + except (ValueError, TypeError): + lat, lon = None, None + + metadata = record.extra_data or {} + + features.append( + { + "type": "Feature", + "id": record.id, + "geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]}, + "properties": { + "id": record.id, + "name": record.name, + "country": record.country, + "city": record.city, + "metadata": metadata, + "data_type": "gpu_cluster", + }, + } + ) + + return {"type": "FeatureCollection", "features": features} + + +# ============== API Endpoints ============== + + @router.get("/geo/cables") async def get_cables_geojson(db: AsyncSession = Depends(get_db)): """获取海底电缆 GeoJSON 数据 (LineString)""" @@ -196,6 +315,178 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)): } +@router.get("/geo/satellites") +async def get_satellites_geojson( + limit: int = 10000, + db: AsyncSession = Depends(get_db), +): + """获取卫星 TLE GeoJSON 数据""" + stmt = ( + select(CollectedData) + .where(CollectedData.source == "celestrak_tle") + .where(CollectedData.name != "Unknown") + .order_by(CollectedData.id.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + records = result.scalars().all() + + if not records: + return {"type": "FeatureCollection", "features": [], "count": 0} + + geojson = convert_satellite_to_geojson(list(records)) + return { + **geojson, + "count": len(geojson.get("features", [])), + } + + +@router.get("/geo/supercomputers") +async def get_supercomputers_geojson( + limit: int = 500, + db: AsyncSession = Depends(get_db), +): + """获取 TOP500 超算中心 GeoJSON 数据""" + stmt = ( + select(CollectedData) + .where(CollectedData.source == "top500") + .where(CollectedData.name != "Unknown") + .limit(limit) + ) + result = await db.execute(stmt) + records = result.scalars().all() + + if not records: + return {"type": "FeatureCollection", "features": [], "count": 0} + + geojson = convert_supercomputer_to_geojson(list(records)) + return { + **geojson, + "count": len(geojson.get("features", [])), + } + + +@router.get("/geo/gpu-clusters") +async def get_gpu_clusters_geojson( + limit: int = 100, + db: AsyncSession = Depends(get_db), +): + """获取 GPU 集群 GeoJSON 数据""" + stmt = ( + select(CollectedData) + .where(CollectedData.source == "epoch_ai_gpu") + .where(CollectedData.name != "Unknown") + .limit(limit) + ) + result = await db.execute(stmt) + records = result.scalars().all() + + if not records: + return {"type": "FeatureCollection", "features": [], "count": 0} + + geojson = convert_gpu_cluster_to_geojson(list(records)) + return { + **geojson, + "count": len(geojson.get("features", [])), + } + + +@router.get("/all") +async def get_all_visualization_data(db: AsyncSession = Depends(get_db)): + """获取所有可视化数据的统一端点 + + Returns GeoJSON FeatureCollections for all data types: + - satellites: 卫星 TLE 数据 + - cables: 海底电缆 + - landing_points: 登陆点 + - supercomputers: TOP500 超算 + - gpu_clusters: GPU 集群 + """ + cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") + cables_result = await db.execute(cables_stmt) + cables_records = list(cables_result.scalars().all()) + + points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") + points_result = await db.execute(points_stmt) + points_records = list(points_result.scalars().all()) + + satellites_stmt = ( + select(CollectedData) + .where(CollectedData.source == "celestrak_tle") + .where(CollectedData.name != "Unknown") + ) + satellites_result = await db.execute(satellites_stmt) + satellites_records = list(satellites_result.scalars().all()) + + supercomputers_stmt = ( + select(CollectedData) + .where(CollectedData.source == "top500") + .where(CollectedData.name != "Unknown") + ) + supercomputers_result = await db.execute(supercomputers_stmt) + supercomputers_records = list(supercomputers_result.scalars().all()) + + gpu_stmt = ( + select(CollectedData) + .where(CollectedData.source == "epoch_ai_gpu") + .where(CollectedData.name != "Unknown") + ) + gpu_result = await db.execute(gpu_stmt) + gpu_records = list(gpu_result.scalars().all()) + + cables = ( + convert_cable_to_geojson(cables_records) + if cables_records + else {"type": "FeatureCollection", "features": []} + ) + landing_points = ( + convert_landing_point_to_geojson(points_records) + if points_records + else {"type": "FeatureCollection", "features": []} + ) + satellites = ( + convert_satellite_to_geojson(satellites_records) + if satellites_records + else {"type": "FeatureCollection", "features": []} + ) + supercomputers = ( + convert_supercomputer_to_geojson(supercomputers_records) + if supercomputers_records + else {"type": "FeatureCollection", "features": []} + ) + gpu_clusters = ( + convert_gpu_cluster_to_geojson(gpu_records) + if gpu_records + else {"type": "FeatureCollection", "features": []} + ) + + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "version": "1.0", + "data": { + "satellites": satellites, + "cables": cables, + "landing_points": landing_points, + "supercomputers": supercomputers, + "gpu_clusters": gpu_clusters, + }, + "stats": { + "total_features": ( + len(satellites.get("features", [])) + + len(cables.get("features", [])) + + len(landing_points.get("features", [])) + + len(supercomputers.get("features", [])) + + len(gpu_clusters.get("features", [])) + ), + "satellites": len(satellites.get("features", [])), + "cables": len(cables.get("features", [])), + "landing_points": len(landing_points.get("features", [])), + "supercomputers": len(supercomputers.get("features", [])), + "gpu_clusters": len(gpu_clusters.get("features", [])), + }, + } + + # Cache for cable graph _cable_graph: Optional[CableGraph] = None diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 71c194a3..d7bbb0c2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,6 +27,9 @@ class Settings(BaseSettings): CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"] + SPACETRACK_USERNAME: str = "" + SPACETRACK_PASSWORD: str = "" + @property def REDIS_URL(self) -> str: return os.getenv( @@ -34,7 +37,7 @@ class Settings(BaseSettings): ) class Config: - env_file = ".env" + env_file = Path(__file__).parent.parent.parent / ".env" case_sensitive = True diff --git a/backend/app/core/data_sources.py b/backend/app/core/data_sources.py index 3abca4d6..13f078a0 100644 --- a/backend/app/core/data_sources.py +++ b/backend/app/core/data_sources.py @@ -22,6 +22,7 @@ COLLECTOR_URL_KEYS = { "peeringdb_facility": "peeringdb.facility_url", "top500": "top500.url", "epoch_ai_gpu": "epoch_ai.gpu_clusters_url", + "spacetrack_tle": "spacetrack.tle_query_url", } diff --git a/backend/app/core/data_sources.yaml b/backend/app/core/data_sources.yaml index 99095689..7e97d335 100644 --- a/backend/app/core/data_sources.yaml +++ b/backend/app/core/data_sources.yaml @@ -33,3 +33,7 @@ top500: epoch_ai: gpu_clusters_url: "https://epoch.ai/data/gpu-clusters" + +spacetrack: + base_url: "https://www.space-track.org" + tle_query_url: "https://www.space-track.org/basicspacedata/query/class/gp/orderby/EPOCH%20desc/limit/1000/format/json" diff --git a/backend/app/services/collectors/__init__.py b/backend/app/services/collectors/__init__.py index 4422c334..69a2b11b 100644 --- a/backend/app/services/collectors/__init__.py +++ b/backend/app/services/collectors/__init__.py @@ -28,6 +28,8 @@ from app.services.collectors.arcgis_cables import ArcGISCableCollector from app.services.collectors.fao_landing import FAOLandingPointCollector from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector +from app.services.collectors.spacetrack import SpaceTrackTLECollector +from app.services.collectors.celestrak import CelesTrakTLECollector collector_registry.register(TOP500Collector()) collector_registry.register(EpochAIGPUCollector()) @@ -47,3 +49,5 @@ collector_registry.register(ArcGISCableCollector()) collector_registry.register(FAOLandingPointCollector()) collector_registry.register(ArcGISLandingPointCollector()) collector_registry.register(ArcGISCableLandingRelationCollector()) +collector_registry.register(SpaceTrackTLECollector()) +collector_registry.register(CelesTrakTLECollector()) diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index 8a22232b..77a1948c 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -119,6 +119,9 @@ class BaseCollector(ABC): records_added = 0 for i, item in enumerate(data): + print( + f"DEBUG: Saving item {i}: name={item.get('name')}, metadata={item.get('metadata', 'NOT FOUND')}" + ) record = CollectedData( source=self.name, source_id=item.get("source_id") or item.get("id"), diff --git a/backend/app/services/collectors/celestrak.py b/backend/app/services/collectors/celestrak.py new file mode 100644 index 00000000..a0e91d43 --- /dev/null +++ b/backend/app/services/collectors/celestrak.py @@ -0,0 +1,99 @@ +"""CelesTrak TLE Collector + +Collects satellite TLE (Two-Line Element) data from CelesTrak.org. +Free, no authentication required. +""" + +import json +from typing import Dict, Any, List +import httpx + +from app.services.collectors.base import BaseCollector + + +class CelesTrakTLECollector(BaseCollector): + name = "celestrak_tle" + priority = "P2" + module = "L3" + frequency_hours = 24 + data_type = "satellite_tle" + + @property + def base_url(self) -> str: + return "https://celestrak.org/NORAD/elements/gp.php" + + async def fetch(self) -> List[Dict[str, Any]]: + satellite_groups = [ + "starlink", + "gps-ops", + "galileo", + "glonass", + "beidou", + "leo", + "geo", + "iridium-next", + ] + + all_satellites = [] + + async with httpx.AsyncClient(timeout=120.0) as client: + for group in satellite_groups: + try: + url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json" + response = await client.get(url) + + if response.status_code == 200: + data = response.json() + if isinstance(data, list): + all_satellites.extend(data) + print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'") + except Exception as e: + print(f"CelesTrak: Error fetching group '{group}': {e}") + + if not all_satellites: + return self._get_sample_data() + + print(f"CelesTrak: Total satellites fetched: {len(all_satellites)}") + + # Return raw data - base.run() will call transform() + return all_satellites + + def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + transformed = [] + for item in raw_data: + transformed.append( + { + "name": item.get("OBJECT_NAME", "Unknown"), + "reference_date": item.get("EPOCH", ""), + "metadata": { + "norad_cat_id": item.get("NORAD_CAT_ID"), + "international_designator": item.get("OBJECT_ID"), + "epoch": item.get("EPOCH"), + "mean_motion": item.get("MEAN_MOTION"), + "eccentricity": item.get("ECCENTRICITY"), + "inclination": item.get("INCLINATION"), + "raan": item.get("RA_OF_ASC_NODE"), + "arg_of_perigee": item.get("ARG_OF_PERICENTER"), + "mean_anomaly": item.get("MEAN_ANOMALY"), + "classification_type": item.get("CLASSIFICATION_TYPE"), + "bstar": item.get("BSTAR"), + "mean_motion_dot": item.get("MEAN_MOTION_DOT"), + "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), + "ephemeris_type": item.get("EPHEMERIS_TYPE"), + }, + } + ) + return transformed + + def _get_sample_data(self) -> List[Dict[str, Any]]: + return [ + { + "name": "STARLINK-1000", + "norad_cat_id": 44720, + "international_designator": "2019-029AZ", + "epoch": "2026-03-13T00:00:00Z", + "mean_motion": 15.79234567, + "eccentricity": 0.0001234, + "inclination": 53.0, + }, + ] diff --git a/backend/app/services/collectors/spacetrack.py b/backend/app/services/collectors/spacetrack.py new file mode 100644 index 00000000..4f66c97d --- /dev/null +++ b/backend/app/services/collectors/spacetrack.py @@ -0,0 +1,222 @@ +"""Space-Track TLE Collector + +Collects satellite TLE (Two-Line Element) data from Space-Track.org. +API documentation: https://www.space-track.org/documentation +""" + +import json +from typing import Dict, Any, List +import httpx + +from app.services.collectors.base import BaseCollector +from app.core.data_sources import get_data_sources_config + + +class SpaceTrackTLECollector(BaseCollector): + name = "spacetrack_tle" + priority = "P2" + module = "L3" + frequency_hours = 24 + data_type = "satellite_tle" + + @property + def base_url(self) -> str: + config = get_data_sources_config() + if self._resolved_url: + return self._resolved_url + return config.get_yaml_url("spacetrack_tle") + + async def fetch(self) -> List[Dict[str, Any]]: + from app.core.config import settings + + username = settings.SPACETRACK_USERNAME + password = settings.SPACETRACK_PASSWORD + + if not username or not password: + print("SPACETRACK: No credentials configured, using sample data") + return self._get_sample_data() + + print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}") + + try: + async with httpx.AsyncClient( + timeout=120.0, + follow_redirects=True, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "application/json, text/html, */*", + "Accept-Language": "en-US,en;q=0.9", + "Referer": "https://www.space-track.org/", + }, + ) as client: + await client.get("https://www.space-track.org/") + + login_response = await client.post( + "https://www.space-track.org/ajaxauth/login", + data={ + "identity": username, + "password": password, + }, + ) + print(f"SPACETRACK: Login response status: {login_response.status_code}") + print(f"SPACETRACK: Login response URL: {login_response.url}") + + if login_response.status_code == 403: + print("SPACETRACK: Trying alternate login method...") + + async with httpx.AsyncClient( + timeout=120.0, + follow_redirects=True, + ) as alt_client: + await alt_client.get("https://www.space-track.org/") + + form_data = { + "username": username, + "password": password, + "query": "class/gp/NORAD_CAT_ID/25544/format/json", + } + alt_login = await alt_client.post( + "https://www.space-track.org/ajaxauth/login", + data={ + "identity": username, + "password": password, + }, + ) + print(f"SPACETRACK: Alt login status: {alt_login.status_code}") + + if alt_login.status_code == 200: + tle_response = await alt_client.get( + "https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json" + ) + if tle_response.status_code == 200: + data = tle_response.json() + print(f"SPACETRACK: Received {len(data)} records via alt method") + return data + + if login_response.status_code != 200: + print(f"SPACETRACK: Login failed, using sample data") + return self._get_sample_data() + + tle_response = await client.get( + "https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json" + ) + print(f"SPACETRACK: TLE query status: {tle_response.status_code}") + + if tle_response.status_code != 200: + print(f"SPACETRACK: Query failed, using sample data") + return self._get_sample_data() + + data = tle_response.json() + print(f"SPACETRACK: Received {len(data)} records") + return data + except Exception as e: + print(f"SPACETRACK: Error - {e}, using sample data") + return self._get_sample_data() + + print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}") + + try: + async with httpx.AsyncClient( + timeout=120.0, + follow_redirects=True, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json, text/html, */*", + "Accept-Language": "en-US,en;q=0.9", + }, + ) as client: + # First, visit the main page to get any cookies + await client.get("https://www.space-track.org/") + + # Login to get session cookie + login_response = await client.post( + "https://www.space-track.org/ajaxauth/login", + data={ + "identity": username, + "password": password, + }, + ) + print(f"SPACETRACK: Login response status: {login_response.status_code}") + print(f"SPACETRACK: Login response URL: {login_response.url}") + print(f"SPACETRACK: Login response body: {login_response.text[:500]}") + + if login_response.status_code != 200: + print(f"SPACETRACK: Login failed, using sample data") + return self._get_sample_data() + + # Query for TLE data (get first 1000 satellites) + tle_response = await client.get( + "https://www.space-track.org/basicspacedata/query" + "/class/gp" + "/orderby/EPOCH%20desc" + "/limit/1000" + "/format/json" + ) + print(f"SPACETRACK: TLE query status: {tle_response.status_code}") + + if tle_response.status_code != 200: + print(f"SPACETRACK: Query failed, using sample data") + return self._get_sample_data() + + data = tle_response.json() + print(f"SPACETRACK: Received {len(data)} records") + return data + except Exception as e: + print(f"SPACETRACK: Error - {e}, using sample data") + return self._get_sample_data() + + def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Transform TLE data to internal format""" + transformed = [] + for item in raw_data: + transformed.append( + { + "name": item.get("OBJECT_NAME", "Unknown"), + "norad_cat_id": item.get("NORAD_CAT_ID"), + "international_designator": item.get("INTL_DESIGNATOR"), + "epoch": item.get("EPOCH"), + "mean_motion": item.get("MEAN_MOTION"), + "eccentricity": item.get("ECCENTRICITY"), + "inclination": item.get("INCLINATION"), + "raan": item.get("RAAN"), + "arg_of_perigee": item.get("ARG_OF_PERIGEE"), + "mean_anomaly": item.get("MEAN_ANOMALY"), + "ephemeris_type": item.get("EPHEMERIS_TYPE"), + "classification_type": item.get("CLASSIFICATION_TYPE"), + "element_set_no": item.get("ELEMENT_SET_NO"), + "rev_at_epoch": item.get("REV_AT_EPOCH"), + "bstar": item.get("BSTAR"), + "mean_motion_dot": item.get("MEAN_MOTION_DOT"), + "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), + } + ) + return transformed + + def _get_sample_data(self) -> List[Dict[str, Any]]: + """Return sample TLE data for testing""" + return [ + { + "name": "ISS (ZARYA)", + "norad_cat_id": 25544, + "international_designator": "1998-067A", + "epoch": "2026-03-13T00:00:00Z", + "mean_motion": 15.49872723, + "eccentricity": 0.0006292, + "inclination": 51.6400, + "raan": 315.0000, + "arg_of_perigee": 100.0000, + "mean_anomaly": 260.0000, + }, + { + "name": "STARLINK-1000", + "norad_cat_id": 44720, + "international_designator": "2019-029AZ", + "epoch": "2026-03-13T00:00:00Z", + "mean_motion": 15.79234567, + "eccentricity": 0.0001234, + "inclination": 53.0000, + "raan": 120.0000, + "arg_of_perigee": 90.0000, + "mean_anomaly": 270.0000, + }, + ] diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 8990cfda..58762e11 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -33,6 +33,8 @@ COLLECTOR_TO_ID = { "arcgis_landing_points": 16, "arcgis_cable_landing_relation": 17, "fao_landing_points": 18, + "spacetrack_tle": 19, + "celestrak_tle": 20, } diff --git a/backend/requirements.txt b/backend/requirements.txt index 72d77848..00d9865f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,3 +16,4 @@ email-validator apscheduler>=3.10.4 pytest>=7.4.0 pytest-asyncio>=0.23.0 +networkx>=3.0 diff --git a/docker-compose.yml b/docker-compose.yml index a4c3d297..0df7ca19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,8 @@ services: container_name: planet_backend ports: - "8000:8000" + env_file: + - .env environment: - DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/planet_db - REDIS_URL=redis://redis:6379/0 @@ -48,6 +50,8 @@ services: condition: service_healthy redis: condition: service_healthy + volumes: + - ./backend:/app healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s @@ -64,6 +68,8 @@ services: environment: - VITE_API_URL=http://backend:8000/api/v1 - VITE_WS_URL=ws://backend:8000/ws + volumes: + - ./frontend/public:/app/public depends_on: backend: condition: service_healthy diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css index d08ac3da..589c1feb 100644 --- a/frontend/public/earth/css/base.css +++ b/frontend/public/earth/css/base.css @@ -17,14 +17,76 @@ body { position: relative; width: 100vw; height: 100vh; - /* user-select: none; - -webkit-user-select: none; */ } #container.dragging { cursor: grabbing; } +/* Zoom Toolbar - Top Center */ +#zoom-toolbar { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 15px; + background: rgba(10, 10, 30, 0.9); + padding: 10px 20px; + border-radius: 30px; + border: 1px solid rgba(77, 184, 255, 0.3); + box-shadow: 0 0 20px rgba(77, 184, 255, 0.2); + z-index: 200; +} + +#zoom-toolbar .zoom-percent { + font-size: 1.2rem; + font-weight: 600; + color: #4db8ff; + min-width: 60px; + text-align: center; + cursor: pointer; + padding: 5px 10px; + border-radius: 5px; + transition: all 0.2s ease; +} + +#zoom-toolbar .zoom-percent:hover { + background: rgba(77, 184, 255, 0.2); + box-shadow: 0 0 10px rgba(77, 184, 255, 0.3); +} + +#zoom-toolbar .zoom-btn { + width: 36px; + height: 36px; + min-width: 36px; + border: none; + border-radius: 50%; + background: rgba(77, 184, 255, 0.2); + color: #4db8ff; + font-size: 22px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + padding: 0; + flex: 0 0 auto; +} + +#zoom-toolbar .zoom-btn:hover { + background: rgba(77, 184, 255, 0.4); + transform: scale(1.1); + box-shadow: 0 0 10px rgba(77, 184, 255, 0.5); +} + +#zoom-toolbar #zoom-slider { + width: 100px; + margin-left: 10px; +} + #loading { position: absolute; top: 50%; @@ -147,3 +209,128 @@ input[type="range"]::-webkit-slider-thumb { display: none; user-select: none; } + +/* Control Toolbar - Stellarium/Star Walk style */ +#control-toolbar { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + background: rgba(10, 10, 30, 0.9); + border-radius: 30px; + padding: 8px 12px; + border: 1px solid rgba(77, 184, 255, 0.3); + box-shadow: 0 0 20px rgba(77, 184, 255, 0.2); + z-index: 200; + transition: all 0.3s ease; +} + +#control-toolbar.collapsed { + padding: 8px 8px 8px 12px; +} + +#control-toolbar.collapsed .toolbar-items { + width: 0; + padding: 0; + overflow: hidden; + opacity: 0; +} + +#toolbar-toggle { + font-size: 0; + min-width: 32px; + line-height: 1; + transition: all 0.3s ease; +} + +.toggle-circle { + width: 12px; + height: 12px; + border-radius: 50%; + background: rgba(77, 184, 255, 0.8); + box-shadow: 0 0 8px rgba(77, 184, 255, 0.6); +} + +#control-toolbar:not(.collapsed) #toolbar-toggle { + background: rgba(77, 184, 255, 0.3); +} + +.toolbar-items { + display: flex; + gap: 4px; + width: auto; + padding: 0 8px 0 4px; + overflow: visible; + opacity: 1; + transition: all 0.3s ease; + border-right: 1px solid rgba(77, 184, 255, 0.3); + margin-right: 4px; +} + +.toolbar-btn { + position: relative; + width: 40px; + height: 40px; + border: none; + border-radius: 50%; + background: rgba(77, 184, 255, 0.15); + color: #4db8ff; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.toolbar-btn:hover { + background: rgba(77, 184, 255, 0.35); + transform: scale(1.1); + box-shadow: 0 0 15px rgba(77, 184, 255, 0.5); +} + +.toolbar-btn:active { + transform: scale(0.95); +} + +.toolbar-btn.active { + background: rgba(77, 184, 255, 0.4); + box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset; +} + +.toolbar-btn .tooltip { + position: absolute; + bottom: 50px; + left: 50%; + transform: translateX(-50%); + background: rgba(10, 10, 30, 0.95); + color: #fff; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.2s ease; + border: 1px solid rgba(77, 184, 255, 0.4); + pointer-events: none; + z-index: 100; +} + +.toolbar-btn:hover .tooltip { + opacity: 1; + visibility: visible; + bottom: 52px; +} + +.toolbar-btn .tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: rgba(77, 184, 255, 0.4); +} diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css index 67bd0f7b..d7a1458a 100644 --- a/frontend/public/earth/css/earth-stats.css +++ b/frontend/public/earth/css/earth-stats.css @@ -29,3 +29,33 @@ color: #4db8ff; font-weight: 500; } + +#satellite-info { + position: absolute; + bottom: 20px; + right: 290px; + background-color: rgba(10, 10, 30, 0.9); + border-radius: 10px; + padding: 15px; + width: 220px; + z-index: 10; + box-shadow: 0 0 20px rgba(0, 229, 255, 0.3); + border: 1px solid rgba(0, 229, 255, 0.3); + font-size: 0.85rem; + backdrop-filter: blur(5px); +} + +#satellite-info .stats-item { + margin-bottom: 6px; + display: flex; + justify-content: space-between; +} + +#satellite-info .stats-label { + color: #aaa; +} + +#satellite-info .stats-value { + color: #00e5ff; + font-weight: 500; +} diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index 183d0f97..a358db03 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -95,11 +95,153 @@ #info-panel .zoom-buttons { display: flex; - gap: 10px; + align-items: center; + justify-content: center; + gap: 15px; margin-top: 10px; + width: 100%; +} + +#info-panel .zoom-percent-container { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; +} + +#info-panel .zoom-percent { + font-size: 1.4rem; + font-weight: 600; + color: #4db8ff; + min-width: 70px; + text-align: center; + cursor: pointer; + padding: 5px 10px; + border-radius: 5px; + transition: all 0.2s ease; +} + +#info-panel .zoom-percent:hover { + background: rgba(77, 184, 255, 0.2); + box-shadow: 0 0 10px rgba(77, 184, 255, 0.3); +} + +#info-panel .zoom-buttons .zoom-btn { + width: 36px; + height: 36px; + min-width: 36px; + border: none; + border-radius: 50%; + background: rgba(77, 184, 255, 0.2); + color: #4db8ff; + font-size: 22px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + padding: 0; + flex: 0 0 auto; +} + +#info-panel .zoom-buttons .zoom-btn:hover { + background: rgba(77, 184, 255, 0.4); + transform: scale(1.1); + box-shadow: 0 0 10px rgba(77, 184, 255, 0.5); } #info-panel .zoom-buttons button { flex: 1; min-width: 60px; } + +/* Info Card - Unified details panel (inside info-panel) */ +.info-card { + margin-top: 15px; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + padding: 0; + overflow: hidden; +} + +.info-card.no-border { + background: transparent; + border: none; +} + +.info-card-header { + display: flex; + align-items: center; + padding: 10px 12px; + background: rgba(77, 184, 255, 0.1); + gap: 8px; +} + +.info-card-icon { + font-size: 18px; +} + +.info-card-header h3 { + flex: 1; + margin: 0; + font-size: 1rem; + color: #4db8ff; +} + +.info-card-content { + padding: 10px 12px; + max-height: 200px; + overflow-y: auto; +} + +.info-card-property { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.info-card-property:last-child { + border-bottom: none; +} + +.info-card-label { + color: #aaa; + font-size: 0.85rem; +} + +.info-card-value { + color: #4db8ff; + font-weight: 500; + font-size: 0.9rem; + text-align: right; + max-width: 180px; + word-break: break-word; +} + +/* Cable type */ +.info-card.cable { + border-color: rgba(255, 200, 0, 0.4); +} + +.info-card.cable .info-card-header { + background: rgba(255, 200, 0, 0.15); +} + +.info-card.cable .info-card-header h3 { + color: #ffc800; +} + +/* Satellite type */ +.info-card.satellite { + border-color: rgba(0, 229, 255, 0.4); +} + +.info-card.satellite .info-card-header { + background: rgba(0, 229, 255, 0.15); +} + +.info-card.satellite .info-card-header h3 { + color: #00e5ff; +} diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index d116193a..3645c7b4 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -3,12 +3,13 @@ - 3D球形地图 - 海底电缆系统 + 智能星球计划 - 现实层宇宙全息感知 @@ -20,58 +21,40 @@
+
+ + 100% + + +
+
-

全球海底电缆系统

-
3D地形球形地图可视化 | 高分辨率卫星图
-
-
-

缩放控制

-
- - - -
-
-
- 缩放级别: - 1.0x -
- -
+

智能星球计划

+
现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施
+ + -
-

点击电缆查看详情

-
- 所有者: - - -
-
- 状态: - - -
-
- 长度: - - -
-
- 经纬度: - - -
-
- 投入使用时间: - - -
-
-
- - - - -
+
+
+
+ + + + + + +
+ +
+

坐标信息

@@ -124,6 +107,10 @@ 地形: 开启
+
+ 卫星: + 0 颗 +
视角距离: 300 km diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index ea49b3eb..f6450d2a 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -4,7 +4,8 @@ import * as THREE from 'three'; import { CONFIG, CABLE_COLORS, PATHS } from './constants.js'; import { latLonToVector3 } from './utils.js'; -import { updateCableDetails, updateEarthStats, showStatusMessage } from './ui.js'; +import { updateEarthStats, showStatusMessage } from './ui.js'; +import { showInfoCard } from './info-card.js'; export let cableLines = []; export let landingPoints = []; @@ -312,8 +313,7 @@ export function handleCableClick(cable) { lockedCable = cable; const data = cable.userData; - // console.log(data) - updateCableDetails({ + showInfoCard('cable', { name: data.name, owner: data.owner, status: data.status, @@ -327,14 +327,6 @@ export function handleCableClick(cable) { export function clearCableSelection() { lockedCable = null; - updateCableDetails({ - name: '点击电缆查看详情', - owner: '-', - status: '-', - length: '-', - coords: '-', - rfs: '-' - }); } export function getCableLines() { diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 97cf206d..77e5b2d7 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -3,6 +3,8 @@ import { CONFIG } from './constants.js'; import { updateZoomDisplay, showStatusMessage } from './ui.js'; import { toggleTerrain } from './earth.js'; +import { reloadData } from './main.js'; +import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount } from './satellites.js'; export let autoRotate = true; export let zoomLevel = 1.0; @@ -20,20 +22,86 @@ export function setupControls(camera, renderer, scene, earth) { } function setupZoomControls(camera) { - document.getElementById('zoom-in').addEventListener('click', () => { - zoomLevel = Math.min(zoomLevel + 0.5, CONFIG.maxZoom); - applyZoom(camera); - }); + let zoomInterval = null; + let lastDirection = 0; + let isSnapped = false; - document.getElementById('zoom-out').addEventListener('click', () => { - zoomLevel = Math.max(zoomLevel - 0.5, CONFIG.minZoom); - applyZoom(camera); - }); + const MIN_PERCENT = CONFIG.minZoom * 100; + const MAX_PERCENT = CONFIG.maxZoom * 100; - document.getElementById('zoom-reset').addEventListener('click', () => { - zoomLevel = 1.0; + function adjustZoom(direction) { + const currentPercent = Math.round(zoomLevel * 100); + let newPercent; + + if (direction > 0) { + if (!isSnapped || currentPercent % 10 !== 0) { + newPercent = Math.ceil(currentPercent / 10) * 10; + if (newPercent <= currentPercent) newPercent = currentPercent + 10; + isSnapped = true; + } else { + newPercent = currentPercent + 10; + } + } else { + if (!isSnapped || currentPercent % 10 !== 0) { + newPercent = Math.floor(currentPercent / 10) * 10; + if (newPercent >= currentPercent) newPercent = currentPercent - 10; + isSnapped = true; + } else { + newPercent = currentPercent - 10; + } + } + + if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT; + if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT; + + zoomLevel = newPercent / 100; applyZoom(camera); - showStatusMessage('缩放已重置', 'info'); + } + + function startZoom(direction) { + isSnapped = false; + lastDirection = direction; + adjustZoom(direction); + zoomInterval = setInterval(() => { + adjustZoom(direction); + }, 150); + } + + function stopZoom() { + if (zoomInterval) { + clearInterval(zoomInterval); + zoomInterval = null; + } + } + + document.getElementById('zoom-in').addEventListener('mousedown', () => startZoom(1)); + document.getElementById('zoom-in').addEventListener('mouseup', stopZoom); + document.getElementById('zoom-in').addEventListener('mouseleave', stopZoom); + document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); startZoom(1); }); + document.getElementById('zoom-in').addEventListener('touchend', stopZoom); + + document.getElementById('zoom-out').addEventListener('mousedown', () => startZoom(-1)); + document.getElementById('zoom-out').addEventListener('mouseup', stopZoom); + document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom); + document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); startZoom(-1); }); + document.getElementById('zoom-out').addEventListener('touchend', stopZoom); + + document.getElementById('zoom-value').addEventListener('click', function() { + const startZoom = zoomLevel; + const targetZoom = 1.0; + const startDistance = CONFIG.defaultCameraZ / startZoom; + const targetDistance = CONFIG.defaultCameraZ / targetZoom; + + animateValue(0, 1, 600, (progress) => { + const ease = 1 - Math.pow(1 - progress, 3); + zoomLevel = startZoom + (targetZoom - startZoom) * ease; + camera.position.z = CONFIG.defaultCameraZ / zoomLevel; + const distance = startDistance + (targetDistance - startDistance) * ease; + updateZoomDisplay(zoomLevel, distance.toFixed(0)); + }, () => { + zoomLevel = 1.0; + showStatusMessage('缩放已重置到100%', 'info'); + }); }); const slider = document.getElementById('zoom-slider'); @@ -111,13 +179,12 @@ export function resetView(camera) { } function setupRotateControls(camera, earth) { - document.getElementById('rotate-toggle').addEventListener('click', () => { + document.getElementById('rotate-toggle').addEventListener('click', function() { toggleAutoRotate(); - const isOn = autoRotate; - showStatusMessage(isOn ? '自动旋转已开启' : '自动旋转已暂停', 'info'); + showStatusMessage(autoRotate ? '自动旋转已开启' : '自动旋转已暂停', 'info'); }); - document.getElementById('reset-view').addEventListener('click', () => { + document.getElementById('reset-view').addEventListener('click', function() { if (!earthObj) return; const startRotX = earthObj.rotation.x; @@ -143,18 +210,45 @@ function setupRotateControls(camera, earth) { } function setupTerrainControls() { - document.getElementById('toggle-terrain').addEventListener('click', () => { + document.getElementById('toggle-terrain').addEventListener('click', function() { showTerrain = !showTerrain; toggleTerrain(showTerrain); - const btn = document.getElementById('toggle-terrain'); - btn.textContent = showTerrain ? '隐藏地形' : '显示地形'; + this.classList.toggle('active', showTerrain); + this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形'; + document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭'; showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info'); }); - document.getElementById('reload-data').addEventListener('click', () => { - showStatusMessage('重新加载数据...', 'info'); - window.location.reload(); + document.getElementById('toggle-satellites').addEventListener('click', function() { + const showSats = !getShowSatellites(); + toggleSatellites(showSats); + this.classList.toggle('active', showSats); + this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星'; + document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗'; + showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info'); }); + + document.getElementById('toggle-trails').addEventListener('click', function() { + const isActive = this.classList.contains('active'); + const showTrails = !isActive; + toggleTrails(showTrails); + this.classList.toggle('active', showTrails); + this.querySelector('.tooltip').textContent = showTrails ? '隐藏轨迹' : '显示轨迹'; + showStatusMessage(showTrails ? '轨迹已显示' : '轨迹已隐藏', 'info'); + }); + + document.getElementById('reload-data').addEventListener('click', async () => { + await reloadData(); + showStatusMessage('数据已重新加载', 'success'); + }); + + const toolbarToggle = document.getElementById('toolbar-toggle'); + const toolbar = document.getElementById('control-toolbar'); + if (toolbarToggle && toolbar) { + toolbarToggle.addEventListener('click', () => { + toolbar.classList.toggle('collapsed'); + }); + } } function setupMouseControls(camera, renderer) { @@ -192,7 +286,9 @@ export function setAutoRotate(value) { autoRotate = value; const btn = document.getElementById('rotate-toggle'); if (btn) { - btn.textContent = autoRotate ? '暂停旋转' : '开始旋转'; + btn.classList.toggle('active', value); + const tooltip = btn.querySelector('.tooltip'); + if (tooltip) tooltip.textContent = value ? '暂停旋转' : '自动旋转'; } } @@ -200,7 +296,9 @@ export function toggleAutoRotate() { autoRotate = !autoRotate; const btn = document.getElementById('rotate-toggle'); if (btn) { - btn.textContent = autoRotate ? '暂停旋转' : '开始旋转'; + btn.classList.toggle('active', autoRotate); + const tooltip = btn.querySelector('.tooltip'); + if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '自动旋转'; } if (window.clearLockedCable) { window.clearLockedCable(); diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js new file mode 100644 index 00000000..e7281862 --- /dev/null +++ b/frontend/public/earth/js/info-card.js @@ -0,0 +1,121 @@ +// info-card.js - Unified info card module + +let currentType = null; + +const CARD_CONFIG = { + cable: { + icon: '🛥️', + title: '电缆详情', + className: 'cable', + fields: [ + { key: 'name', label: '名称' }, + { key: 'owner', label: '所有者' }, + { key: 'status', label: '状态' }, + { key: 'length', label: '长度' }, + { key: 'coords', label: '经纬度' }, + { key: 'rfs', label: '投入使用' } + ] + }, + satellite: { + icon: '🛰️', + title: '卫星详情', + className: 'satellite', + fields: [ + { key: 'name', label: '名称' }, + { key: 'norad_id', label: 'NORAD ID' }, + { key: 'inclination', label: '倾角', unit: '°' }, + { key: 'period', label: '周期', unit: '分钟' }, + { key: 'perigee', label: '近地点', unit: 'km' }, + { key: 'apogee', label: '远地点', unit: 'km' } + ] + }, + supercomputer: { + icon: '🖥️', + title: '超算详情', + className: 'supercomputer', + fields: [ + { key: 'name', label: '名称' }, + { key: 'rank', label: '排名' }, + { key: 'r_max', label: 'Rmax', unit: 'GFlops' }, + { key: 'r_peak', label: 'Rpeak', unit: 'GFlops' }, + { key: 'country', label: '国家' }, + { key: 'city', label: '城市' } + ] + }, + gpu_cluster: { + icon: '🎮', + title: 'GPU集群详情', + className: 'gpu_cluster', + fields: [ + { key: 'name', label: '名称' }, + { key: 'country', label: '国家' }, + { key: 'city', label: '城市' } + ] + } +}; + +export function initInfoCard() { + // Close button removed - now uses external clear button +} + +export function setInfoCardNoBorder(noBorder = true) { + const card = document.getElementById('info-card'); + if (card) { + card.classList.toggle('no-border', noBorder); + } +} + +export function showInfoCard(type, data) { + const config = CARD_CONFIG[type]; + if (!config) { + console.warn('Unknown info card type:', type); + return; + } + + currentType = type; + const card = document.getElementById('info-card'); + const icon = document.getElementById('info-card-icon'); + const title = document.getElementById('info-card-title'); + const content = document.getElementById('info-card-content'); + + card.className = 'info-card ' + config.className; + icon.textContent = config.icon; + title.textContent = 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 += ` +
+ ${field.label} + ${value} +
+ `; + } + + content.innerHTML = html; + card.style.display = 'block'; +} + +export function hideInfoCard() { + const card = document.getElementById('info-card'); + if (card) { + card.style.display = 'none'; + } + currentType = null; +} + +export function getCurrentType() { + return currentType; +} diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 4bf389f1..75c7a156 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -8,14 +8,15 @@ import { updateCoordinatesDisplay, updateZoomDisplay, updateEarthStats, - updateCableDetails, setLoading, showTooltip, hideTooltip } from './ui.js'; import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js'; import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById } from './cables.js'; +import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, selectSatellite, getSatelliteData, getSatellitePoints } from './satellites.js'; import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate } from './controls.js'; +import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js'; export let scene, camera, renderer; let simplex; @@ -49,11 +50,13 @@ export function init() { document.getElementById('container').appendChild(renderer.domElement); addLights(); + initInfoCard(); const earthObj = createEarth(scene); createClouds(scene, earthObj); createTerrain(scene, earthObj, simplex); createStars(scene); createGridLines(scene, earthObj); + createSatellites(scene, earthObj); setupControls(camera, renderer, scene, earthObj); setupEventListeners(camera, renderer); @@ -80,7 +83,19 @@ function addLights() { scene.add(pointLight); } -async function loadData() { +let earthTexture = null; + +async function loadData(showWhiteSphere = false) { + if (showWhiteSphere) { + const earth = getEarth(); + if (earth && earth.material) { + earthTexture = earth.material.map; + earth.material.map = null; + earth.material.color.setHex(0xffffff); + earth.material.needsUpdate = true; + } + } + setLoading(true); try { console.log('开始加载电缆数据...'); @@ -88,11 +103,29 @@ async function loadData() { console.log('电缆数据加载完成'); await loadLandingPoints(scene, getEarth()); console.log('登陆点数据加载完成'); + + const satCount = await loadSatellites(); + console.log(`卫星数据加载完成: ${satCount} 颗`); + updateSatellitePositions(); + console.log('卫星位置已更新'); } catch (error) { console.error('加载数据失败:', error); showStatusMessage('加载数据失败: ' + error.message, 'error'); } setLoading(false); + + if (showWhiteSphere) { + const earth = getEarth(); + if (earth && earth.material) { + earth.material.map = earthTexture; + earth.material.color.setHex(0xffffff); + earth.material.needsUpdate = true; + } + } +} + +export async function reloadData() { + await loadData(true); } function setupEventListeners(camera, renderer) { @@ -159,7 +192,7 @@ function onMouseMove(event, camera) { }); hoveredCable = null; } - + if (intersects.length > 0) { const cable = intersects[0].object; const cableId = cable.userData.cableId; @@ -171,34 +204,25 @@ function onMouseMove(event, camera) { c.material.opacity = 1; }); hoveredCable = cable; + + showInfoCard('cable', { + name: cable.userData.name, + owner: cable.userData.owner, + status: cable.userData.status, + length: cable.userData.length, + coords: cable.userData.coords, + rfs: cable.userData.rfs + }); + setInfoCardNoBorder(true); } const userData = cable.userData; - document.getElementById('cable-name').textContent = - userData.name || userData.shortname || '未命名电缆'; - document.getElementById('cable-owner').textContent = userData.owner || '-'; - document.getElementById('cable-status').textContent = userData.status || '-'; - document.getElementById('cable-length').textContent = userData.length || '-'; - document.getElementById('cable-coords').textContent = '-'; - document.getElementById('cable-rfs').textContent = userData.rfs || '-'; - hideTooltip(); } else { - if (lockedCable && lockedCableData) { - document.getElementById('cable-name').textContent = - lockedCableData.name || lockedCableData.shortname || '未命名电缆'; - document.getElementById('cable-owner').textContent = lockedCableData.owner || '-'; - document.getElementById('cable-status').textContent = lockedCableData.status || '-'; - document.getElementById('cable-length').textContent = lockedCableData.length || '-'; - document.getElementById('cable-coords').textContent = '-'; - document.getElementById('cable-rfs').textContent = lockedCableData.rfs || '-'; + if (lockedCable) { + handleCableClick(lockedCable); } else { - document.getElementById('cable-name').textContent = '点击电缆查看详情'; - document.getElementById('cable-owner').textContent = '-'; - document.getElementById('cable-status').textContent = '-'; - document.getElementById('cable-length').textContent = '-'; - document.getElementById('cable-coords').textContent = '-'; - document.getElementById('cable-rfs').textContent = '-'; + hideInfoCard(); } const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth); @@ -278,6 +302,45 @@ function onClick(event, camera, renderer) { setAutoRotate(false); handleCableClick(clickedCable); + + showInfoCard('cable', { + name: clickedCable.userData.name, + owner: clickedCable.userData.owner, + status: clickedCable.userData.status, + length: clickedCable.userData.length, + coords: clickedCable.userData.coords, + rfs: clickedCable.userData.rfs + }); + } else if (getShowSatellites()) { + const satIntersects = raycaster.intersectObject(getSatellitePoints()); + + if (satIntersects.length > 0) { + const index = satIntersects[0].index; + const sat = selectSatellite(index); + + if (sat && sat.properties) { + const props = sat.properties; + + const meanMotion = props.mean_motion || 0; + const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-'; + + const ecc = props.eccentricity || 0; + const earthRadius = 6371; + const perigee = (earthRadius * (1 - ecc)).toFixed(0); + const apogee = (earthRadius * (1 + ecc)).toFixed(0); + + showInfoCard('satellite', { + name: props.name, + norad_id: props.norad_cat_id, + inclination: props.inclination ? props.inclination.toFixed(2) : '-', + period: period, + perigee: perigee, + apogee: apogee + }); + + showStatusMessage('已选择: ' + props.name, 'info'); + } + } } else { if (lockedCable) { const prevCableId = lockedCable.userData.cableId; @@ -315,6 +378,8 @@ function animate() { }); } + updateSatellitePositions(16); + renderer.render(scene, camera); } @@ -334,4 +399,9 @@ window.clearLockedCable = function() { clearCableSelection(); }; +window.clearSelection = function() { + hideInfoCard(); + window.clearLockedCable(); +}; + document.addEventListener('DOMContentLoaded', init); diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js new file mode 100644 index 00000000..df2c68cb --- /dev/null +++ b/frontend/public/earth/js/satellites.js @@ -0,0 +1,321 @@ +// satellites.js - Satellite visualization module with real SGP4 positions and animations + +import * as THREE from 'three'; +import { twoline2satrec, sgp4, propagate, degreesToRadians, radiansToDegrees, eciToGeodetic } from 'satellite.js'; +import { CONFIG } from './constants.js'; + +let satellitePoints = null; +let satelliteTrails = null; +let satelliteData = []; +let showSatellites = false; +let showTrails = true; +let animationTime = 0; +let selectedSatellite = null; +let satellitePositions = []; + +const SATELLITE_API = '/api/v1/visualization/geo/satellites?limit=2000'; +const MAX_SATELLITES = 500; +const TRAIL_LENGTH = 30; + +export function createSatellites(scene, earthObj) { + const positions = new Float32Array(MAX_SATELLITES * 3); + const colors = new Float32Array(MAX_SATELLITES * 3); + + const pointsGeometry = new THREE.BufferGeometry(); + pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + const pointsMaterial = new THREE.PointsMaterial({ + size: 3, + vertexColors: true, + transparent: true, + opacity: 0.9, + sizeAttenuation: true + }); + + satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial); + satellitePoints.visible = false; + satellitePoints.userData = { type: 'satellitePoints' }; + earthObj.add(satellitePoints); + + const trailPositions = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); + const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); + + const trailGeometry = new THREE.BufferGeometry(); + trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3)); + trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3)); + + const trailMaterial = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.3, + blending: THREE.AdditiveBlending + }); + + satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial); + satelliteTrails.visible = false; + satelliteTrails.userData = { type: 'satelliteTrails' }; + earthObj.add(satelliteTrails); + + satellitePositions = []; + for (let i = 0; i < MAX_SATELLITES; i++) { + satellitePositions.push({ + current: new THREE.Vector3(), + trail: [] + }); + } + + return satellitePoints; +} + +function computeSatellitePosition(satellite, time) { + try { + const props = satellite.properties; + if (!props || !props.norad_cat_id) { + return null; + } + + const noradId = props.norad_cat_id; + const inclination = props.inclination || 53; + const raan = props.raan || 0; + const eccentricity = props.eccentricity || 0.0001; + const argOfPerigee = props.arg_of_perigee || 0; + const meanAnomaly = props.mean_anomaly || 0; + const meanMotion = props.mean_motion || 15; + const epoch = props.epoch || ''; + + const year = epoch && epoch.length >= 4 ? parseInt(epoch.substring(0, 4)) : time.getUTCFullYear(); + const month = epoch && epoch.length >= 7 ? parseInt(epoch.substring(5, 7)) : time.getUTCMonth() + 1; + const day = epoch && epoch.length >= 10 ? parseInt(epoch.substring(8, 10)) : time.getUTCDate(); + + const tleLine1 = `1 ${String(noradId).padStart(5, '0')}U 00001A ${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}.00000000 .00000000 00000-0 00000-0 0 9999`; + const tleLine2 = `2 ${String(noradId).padStart(5, '0')} ${String(raan.toFixed(4)).padStart(8, ' ')} ${String(inclination.toFixed(4)).padStart(8, ' ')} ${String(eccentricity.toFixed(7)).replace('0.', '.')} ${String(argOfPerigee.toFixed(4)).padStart(8, ' ')} ${String(meanAnomaly.toFixed(4)).padStart(8, ' ')} ${String(meanMotion.toFixed(8)).padStart(11, ' ')} 0 9999`; + + const satrec = twoline2satrec(tleLine1, tleLine2); + if (!satrec || satrec.error) { + return null; + } + + const positionAndVelocity = propagate(satrec, time); + if (!positionAndVelocity || !positionAndVelocity.position) { + return null; + } + + const x = positionAndVelocity.position.x; + const y = positionAndVelocity.position.y; + const z = positionAndVelocity.position.z; + + if (!x || !y || !z) { + return null; + } + + const r = Math.sqrt(x * x + y * y + z * z); + const earthRadius = 6371; + const displayRadius = CONFIG.earthRadius * (earthRadius / 6371) * 1.05; + + const scale = displayRadius / r; + + return new THREE.Vector3(x * scale, y * scale, z * scale); + } catch (e) { + return null; + } +} + +function generateFallbackPosition(satellite, index, total) { + const radius = CONFIG.earthRadius + 5; + + const noradId = satellite.properties?.norad_cat_id || index; + const inclination = satellite.properties?.inclination || 53; + const raan = satellite.properties?.raan || 0; + const meanAnomaly = satellite.properties?.mean_anomaly || 0; + + const hash = String(noradId).split('').reduce((a, b) => a + b.charCodeAt(0), 0); + const randomOffset = (hash % 1000) / 1000; + + const normalizedIndex = index / total; + const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI / 180); + const phi = (inclination * Math.PI / 180) + (meanAnomaly * Math.PI / 180 * 0.1); + + const adjustedPhi = Math.abs(phi % Math.PI); + const adjustedTheta = theta + randomOffset * Math.PI * 2; + + const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta); + const y = radius * Math.cos(adjustedPhi); + const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta); + + return new THREE.Vector3(x, y, z); +} + +export async function loadSatellites() { + try { + const response = await fetch(SATELLITE_API); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + satelliteData = data.features || []; + + console.log(`Loaded ${satelliteData.length} satellites`); + return satelliteData; + } catch (error) { + console.error('Failed to load satellites:', error); + return []; + } +} + +export function updateSatellitePositions(deltaTime = 0) { + if (!satellitePoints || satelliteData.length === 0) return; + + animationTime += deltaTime * 0.001; + + const positions = satellitePoints.geometry.attributes.position.array; + const colors = satellitePoints.geometry.attributes.color.array; + + const trailPositions = satelliteTrails.geometry.attributes.position.array; + const trailColors = satelliteTrails.geometry.attributes.color.array; + + const baseTime = new Date(); + const count = Math.min(satelliteData.length, 500); + + for (let i = 0; i < count; i++) { + const satellite = satelliteData[i]; + const props = satellite.properties; + + const timeOffset = (i / count) * 2 * Math.PI * 0.1; + const adjustedTime = new Date(baseTime.getTime() + timeOffset * 1000 * 60 * 10); + + let pos = computeSatellitePosition(satellite, adjustedTime); + + if (!pos) { + pos = generateFallbackPosition(satellite, i, count); + } + + satellitePositions[i].current.copy(pos); + + satellitePositions[i].trail.push(pos.clone()); + if (satellitePositions[i].trail.length > TRAIL_LENGTH) { + satellitePositions[i].trail.shift(); + } + + positions[i * 3] = pos.x; + positions[i * 3 + 1] = pos.y; + positions[i * 3 + 2] = pos.z; + + const inclination = props?.inclination || 53; + const name = props?.name || ''; + const isStarlink = name.includes('STARLINK'); + const isGeo = inclination > 20 && inclination < 30; + const isIridium = name.includes('IRIDIUM'); + + let r, g, b; + if (isStarlink) { + r = 0.0; g = 0.9; b = 1.0; + } else if (isGeo) { + r = 1.0; g = 0.8; b = 0.0; + } else if (isIridium) { + r = 1.0; g = 0.5; b = 0.0; + } else if (inclination > 50 && inclination < 70) { + r = 0.0; g = 1.0; b = 0.3; + } else { + r = 1.0; g = 1.0; b = 1.0; + } + + colors[i * 3] = r; + colors[i * 3 + 1] = g; + colors[i * 3 + 2] = b; + + const trail = satellitePositions[i].trail; + for (let j = 0; j < TRAIL_LENGTH; j++) { + const trailIdx = (i * TRAIL_LENGTH + j) * 3; + + if (j < trail.length) { + const t = trail[j]; + trailPositions[trailIdx] = t.x; + trailPositions[trailIdx + 1] = t.y; + trailPositions[trailIdx + 2] = t.z; + + const alpha = j / trail.length; + trailColors[trailIdx] = r * alpha; + trailColors[trailIdx + 1] = g * alpha; + trailColors[trailIdx + 2] = b * alpha; + } else { + trailPositions[trailIdx] = 0; + trailPositions[trailIdx + 1] = 0; + trailPositions[trailIdx + 2] = 0; + trailColors[trailIdx] = 0; + trailColors[trailIdx + 1] = 0; + trailColors[trailIdx + 2] = 0; + } + } + } + + for (let i = count; i < 2000; i++) { + positions[i * 3] = 0; + positions[i * 3 + 1] = 0; + positions[i * 3 + 2] = 0; + + for (let j = 0; j < TRAIL_LENGTH; j++) { + const trailIdx = (i * TRAIL_LENGTH + j) * 3; + trailPositions[trailIdx] = 0; + trailPositions[trailIdx + 1] = 0; + trailPositions[trailIdx + 2] = 0; + } + } + + satellitePoints.geometry.attributes.position.needsUpdate = true; + satellitePoints.geometry.attributes.color.needsUpdate = true; + satellitePoints.geometry.setDrawRange(0, count); + + satelliteTrails.geometry.attributes.position.needsUpdate = true; + satelliteTrails.geometry.attributes.color.needsUpdate = true; +} + +export function toggleSatellites(visible) { + showSatellites = visible; + if (satellitePoints) { + satellitePoints.visible = visible; + } + if (satelliteTrails) { + satelliteTrails.visible = visible && showTrails; + } +} + +export function toggleTrails(visible) { + showTrails = visible; + if (satelliteTrails) { + satelliteTrails.visible = visible && showSatellites; + } +} + +export function getShowSatellites() { + return showSatellites; +} + +export function getSatelliteCount() { + return satelliteData.length; +} + +export function getSatelliteAt(index) { + if (index >= 0 && index < satelliteData.length) { + return satelliteData[index]; + } + return null; +} + +export function getSatelliteData() { + return satelliteData; +} + +export function selectSatellite(index) { + selectedSatellite = index; + return getSatelliteAt(index); +} + +export function getSelectedSatellite() { + return selectedSatellite; +} + +export function getSatellitePoints() { + return satellitePoints; +} diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js index 2723a272..1e9c1b7c 100644 --- a/frontend/public/earth/js/ui.js +++ b/frontend/public/earth/js/ui.js @@ -22,8 +22,9 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) { // Update zoom display export function updateZoomDisplay(zoomLevel, distance) { - document.getElementById('zoom-value').textContent = zoomLevel.toFixed(1) + 'x'; - document.getElementById('zoom-level').textContent = '缩放: ' + zoomLevel.toFixed(1) + 'x'; + const percent = Math.round(zoomLevel * 100); + document.getElementById('zoom-value').textContent = percent + '%'; + document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%'; document.getElementById('zoom-slider').value = zoomLevel; document.getElementById('camera-distance').textContent = distance + ' km'; } diff --git a/frontend/public/earth/js/utils.js b/frontend/public/earth/js/utils.js index 84f0bda2..dcc70ebe 100644 --- a/frontend/public/earth/js/utils.js +++ b/frontend/public/earth/js/utils.js @@ -20,7 +20,11 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) { export function vector3ToLatLon(vector) { const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z); const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI); - const lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180; + + let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180; + + while (lon <= -180) lon += 360; + while (lon > 180) lon -= 360; return { lat: parseFloat(lat.toFixed(4)), @@ -30,26 +34,43 @@ export function vector3ToLatLon(vector) { } // Convert screen coordinates to Earth surface 3D coordinates -export function screenToEarthCoords(x, y, camera, earth) { +export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) { const raycaster = new THREE.Raycaster(); - const mouse = new THREE.Vector2( - (x / window.innerWidth) * 2 - 1, - -(y / window.innerHeight) * 2 + 1 - ); + const mouse = new THREE.Vector2(); + + if (domElement === document.body) { + mouse.x = (clientX / window.innerWidth) * 2 - 1; + mouse.y = -(clientY / window.innerHeight) * 2 + 1; + } else { + const rect = domElement.getBoundingClientRect(); + mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1; + } raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObject(earth); if (intersects.length > 0) { - return intersects[0].point; + const localPoint = intersects[0].point.clone(); + earth.worldToLocal(localPoint); + return localPoint; } return null; } -// Calculate simplified distance between two points -export function calculateDistance(lat1, lon1, lat2, lon2) { - const dx = lon2 - lon1; - const dy = lat2 - lat1; - return Math.sqrt(dx * dx + dy * dy); +// Calculate accurate spherical distance between two points (Haversine formula) +export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) { + const toRad = (angle) => (angle * Math.PI) / 180; + + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return radius * c; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index faf51997..5114b67b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,12 +8,12 @@ export default defineConfig({ port: 3000, proxy: { '/api': { - target: 'http://localhost:8000', + target: 'http://backend:8000', changeOrigin: true, secure: false, }, '/ws': { - target: 'ws://localhost:8000', + target: 'ws://backend:8000', ws: true, changeOrigin: true, secure: false, @@ -29,4 +29,7 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + optimizeDeps: { + exclude: ['satellite.js'], + }, }) diff --git a/restart.sh b/restart.sh index a307ca10..e96be2a9 100755 --- a/restart.sh +++ b/restart.sh @@ -1,134 +1,33 @@ #!/bin/bash -# Planet 重启脚本 - 停止并重启所有服务 +# Planet 重启脚本 - 使用 volume 映射,代码改动自动同步 set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -echo -e "${BLUE}🔄 重启智能星球计划...${NC}" +echo "🔄 重启智能星球计划..." echo "" -# 停止服务 -echo -e "${YELLOW}🛑 停止服务...${NC}" +# 停止并重建容器(volume 映射会自动同步代码) +echo "🛑 停止容器..." +docker-compose down -# 停止后端 -if pgrep -f "uvicorn.*app.main:app" > /dev/null 2>&1; then - pkill -f "uvicorn.*app.main:app" 2>/dev/null || true - echo " ✅ 后端已停止" -else - echo " ℹ️ 后端未运行" -fi +echo "🚀 启动服务..." +docker-compose up -d -# 停止前端 -if pgrep -f "vite" > /dev/null 2>&1; then - pkill -f "vite" 2>/dev/null || true - echo " ✅ 前端已停止" -else - echo " ℹ️ 前端未运行" -fi +# 等待服务就绪 +echo "⏳ 等待服务就绪..." +sleep 10 -# 停止 Docker 服务 -if command -v docker &> /dev/null && docker ps &> /dev/null 2>&1; then - if command -v docker-compose &> /dev/null; then - docker-compose down > /dev/null 2>&1 || true - else - docker compose down > /dev/null 2>&1 || true - fi - echo " ✅ Docker 容器已停止" -fi - -# 等待服务完全停止 +# 验证服务 echo "" -echo -e "${YELLOW}⏳ 等待进程退出...${NC}" -sleep 2 - -# 检查端口是否已释放 -if lsof -i :8000 > /dev/null 2>&1 || netstat -tlnp 2>/dev/null | grep -q ":8000" || ss -tlnp 2>/dev/null | grep -q ":8000"; then - echo -e "${YELLOW}⚠️ 端口 8000 仍被占用,强制杀死...${NC}" - fuser -k 8000/tcp 2>/dev/null || true - sleep 1 -fi - -if lsof -i :3000 > /dev/null 2>&1 || netstat -tlnp 2>/dev/null | grep -q ":3000" || ss -tlnp 2>/dev/null | grep -q ":3000"; then - echo -e "${YELLOW}⚠️ 端口 3000 仍被占用,强制杀死...${NC}" - fuser -k 3000/tcp 2>/dev/null || true - sleep 1 -fi +echo "📊 服务状态:" +docker ps --format "table {{.Names}}\t{{.Status}}" echo "" -echo -e "${GREEN}✅ 服务已停止${NC}" +echo "✅ 完成!" +echo " 前端: http://localhost:3000" +echo " 后端: http://localhost:8000" echo "" - -# 启动服务 -echo -e "${BLUE}🚀 启动服务...${NC}" - -# 启动 Docker 服务 -if command -v docker &> /dev/null && docker ps &> /dev/null 2>&1; then - echo -e "${BLUE}🗄️ 启动数据库...${NC}" - if command -v docker-compose &> /dev/null; then - docker-compose up -d postgres redis > /dev/null 2>&1 || true - else - docker compose up -d postgres redis > /dev/null 2>&1 || true - fi - echo -e "${YELLOW}⏳ 等待数据库就绪...${NC}" - sleep 5 -fi - -# 同步 Python 依赖 -echo -e "${BLUE}🐍 同步 Python 依赖...${NC}" -cd "$SCRIPT_DIR/backend" -uv sync > /dev/null 2>&1 || true -cd "$SCRIPT_DIR" - -# 初始化数据库 -echo -e "${BLUE}🗃️ 初始化数据库...${NC}" -PYTHONPATH="$SCRIPT_DIR/backend" python "$SCRIPT_DIR/backend/scripts/init_admin.py" > /dev/null 2>&1 || true - -# 启动后端 -echo -e "${BLUE}🔧 启动后端服务...${NC}" -export PYTHONPATH="$SCRIPT_DIR/backend" -cd "$SCRIPT_DIR/backend" -nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/planet_backend.log 2>&1 & -BACKEND_PID=$! -cd "$SCRIPT_DIR" - -echo -e "${YELLOW}⏳ 等待后端启动...${NC}" -sleep 3 - -if curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo -e "${GREEN}✅ 后端已启动${NC}" -else - echo -e "${RED}❌ 后端启动失败${NC}" - tail -10 /tmp/planet_backend.log -fi - -# 启动前端 -echo -e "${BLUE}🌐 启动前端服务...${NC}" -cd "$SCRIPT_DIR/frontend" -npm run dev > /tmp/planet_frontend.log 2>&1 & -FRONTEND_PID=$! -cd "$SCRIPT_DIR" - -sleep 2 - -echo "" -echo -e "${GREEN}========================================${NC}" -echo -e "${GREEN}✅ 重启完成!${NC}" -echo -e "${GREEN}========================================${NC}" -echo "" -echo -e "${BLUE}🌐 访问地址:${NC}" -echo " 后端: http://localhost:8000" -echo " API文档: http://localhost:8000/docs" -echo " 前端: http://localhost:3000" -echo "" -echo -e "${BLUE}📝 日志:${NC}" -echo " tail -f /tmp/planet_backend.log" -echo " tail -f /tmp/planet_frontend.log" +echo "💡 代码改动会自动同步,无需重启"