fix: 修复3D地球坐标映射多个严重bug

## Bug修复详情

### 1. 致命错误:球面距离计算 (calculateDistance)
- 问题:使用勾股定理计算经纬度距离,在球体表面完全错误
- 修复:改用Haversine公式计算球面大圆距离
- 影响:赤道1度=111km,极地1度=19km,原计算误差巨大

### 2. 经度范围规范化 (vector3ToLatLon)
- 问题:Math.atan2返回[-180°,180°],转换后可能超出标准范围
- 修复:添加while循环规范化到[-180, 180]区间
- 影响:避免本初子午线附近返回360°的异常值

### 3. 屏幕坐标转换支持非全屏 (screenToEarthCoords)
- 问题:假设Canvas永远全屏,非全屏时点击偏移严重
- 修复:新增domElement参数,使用getBoundingClientRect()计算相对坐标
- 影响:嵌入式3D地球组件也能精准拾取

### 4. 地球旋转时经纬度映射错误
- 问题:Raycaster返回世界坐标,未考虑地球自转
- 修复:使用earth.worldToLocal()转换到本地坐标空间
- 影响:地球旋转时经纬度显示正确跟随

## 新增功能

- CelesTrak卫星数据采集器
- Space-Track卫星数据采集器
- 卫星可视化模块(500颗,实时SGP4轨道计算)
- 海底光缆悬停显示info-card
- 统一info-card组件
- 工具栏按钮(Stellarium风格)
- 缩放控制(百分比显示)
- Docker volume映射(代码热更新)

## 文件变更

- utils.js: 坐标转换核心逻辑修复
- satellites.js: 新增卫星可视化
- cables.js: 悬停交互支持
- main.js: 悬停/锁定逻辑
- controls.js: 工具栏UI
- info-card.js: 统一卡片组件
- docker-compose.yml: volume映射
- restart.sh: 简化重启脚本
This commit is contained in:
rayd1o
2026-03-17 04:10:24 +08:00
parent 02991730e5
commit c82e1d5a04
26 changed files with 1770 additions and 248 deletions

View File

@@ -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"]

View File

@@ -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()}

View File

@@ -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

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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"

View File

@@ -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())

View File

@@ -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"),

View File

@@ -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,
},
]

View File

@@ -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,
},
]

View File

@@ -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,
}

View File

@@ -16,3 +16,4 @@ email-validator
apscheduler>=3.10.4
pytest>=7.4.0
pytest-asyncio>=0.23.0
networkx>=3.0

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -3,12 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D球形地图 - 海底电缆系统</title>
<title>智能星球计划 - 现实层宇宙全息感知</title>
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1"
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0"
}
}
</script>
@@ -20,58 +21,40 @@
</head>
<body>
<div id="container">
<div id="zoom-toolbar">
<button id="zoom-out" class="zoom-btn"></button>
<span id="zoom-value" class="zoom-percent">100%</span>
<button id="zoom-in" class="zoom-btn">+</button>
<input type="range" id="zoom-slider" min="0.5" max="5" step="0.01" value="1">
</div>
<div id="info-panel">
<h1>全球海底电缆系统</h1>
<div class="subtitle">3D地形球形地图可视化 | 高分辨率卫星图</div>
<div class="zoom-controls">
<div style="width: 100%;">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">缩放控制</h3>
<div class="zoom-buttons">
<button id="zoom-in">放大</button>
<button id="zoom-out">缩小</button>
<button id="zoom-reset">重置</button>
</div>
<div class="slider-container" style="margin-top: 10px;">
<div class="slider-label">
<span>缩放级别:</span>
<span id="zoom-value">1.0x</span>
</div>
<input type="range" id="zoom-slider" min="0.5" max="5" step="0.1" value="1">
</div>
<h1>智能星球计划</h1>
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
<div id="info-card" class="info-card" style="display: none;">
<div class="info-card-header">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
</div>
<div id="info-card-content"></div>
</div>
<div id="cable-details" class="cable-info">
<h3 id="cable-name">点击电缆查看详情</h3>
<div class="cable-property">
<span class="property-label">所有者:</span>
<span id="cable-owner" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">状态:</span>
<span id="cable-status" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">长度:</span>
<span id="cable-length" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">经纬度:</span>
<span id="cable-coords" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">投入使用时间:</span>
<span id="cable-rfs" class="property-value">-</span>
</div>
</div>
<div class="controls">
<button id="rotate-toggle">暂停旋转</button>
<button id="reset-view">重置视图</button>
<button id="toggle-terrain">显示地形</button>
<button id="reload-data">重新加载数据</button>
</div>
<div id="error-message" class="error-message"></div>
</div>
<div id="control-toolbar">
<div class="toolbar-items">
<button id="rotate-toggle" class="toolbar-btn" title="自动旋转">🔄<span class="tooltip">自动旋转</span></button>
<button id="reset-view" class="toolbar-btn" title="重置视图">🎯<span class="tooltip">重置视图</span></button>
<button id="toggle-terrain" class="toolbar-btn" title="显示/隐藏地形">⛰️<span class="tooltip">显示/隐藏地形</span></button>
<button id="toggle-satellites" class="toolbar-btn" title="显示/隐藏卫星">🛰️<span class="tooltip">显示/隐藏卫星</span></button>
<button id="toggle-trails" class="toolbar-btn" title="显示/隐藏轨迹"><span class="tooltip">显示/隐藏轨迹</span></button>
<button id="reload-data" class="toolbar-btn" title="重新加载数据">🔃<span class="tooltip">重新加载数据</span></button>
</div>
<button id="toolbar-toggle" class="toolbar-btn" title="展开/收起工具栏"><span class="toggle-circle"></span></button>
</div>
<div id="coordinates-display">
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
<div class="coord-item">
@@ -124,6 +107,10 @@
<span class="stats-label">地形:</span>
<span class="stats-value" id="terrain-status">开启</span>
</div>
<div class="stats-item">
<span class="stats-label">卫星:</span>
<span class="stats-value" id="satellite-count">0 颗</span>
</div>
<div class="stats-item">
<span class="stats-label">视角距离:</span>
<span class="stats-value" id="camera-distance">300 km</span>

View File

@@ -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() {

View File

@@ -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();

View File

@@ -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 += `
<div class="info-card-property">
<span class="info-card-label">${field.label}</span>
<span class="info-card-value">${value}</span>
</div>
`;
}
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;
}

View File

@@ -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) {
@@ -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);

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -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;
}

View File

@@ -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'],
},
})

View File

@@ -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 "💡 代码改动会自动同步,无需重启"