Compare commits
2 Commits
6a519126c1
...
5433892bf6
| Author | SHA1 | Date |
|---|---|---|
|
|
5433892bf6 | |
|
|
d93b4f787a |
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "yarn",
|
||||||
|
"runtimeArgs": ["--cwd", "frontend", "dev"],
|
||||||
|
"port": 5173,
|
||||||
|
"autoPort": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,12 @@
|
||||||
"Bash(recreate_resources_table.py )",
|
"Bash(recreate_resources_table.py )",
|
||||||
"Bash(reset_positions.py )",
|
"Bash(reset_positions.py )",
|
||||||
"Bash(test_pluto.py )",
|
"Bash(test_pluto.py )",
|
||||||
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend backend/venv/bin/python:*)"
|
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend backend/venv/bin/python:*)",
|
||||||
|
"Bash(cat vite.config.ts)",
|
||||||
|
"Bash(ls -la .env*)",
|
||||||
|
"Bash(cat .env*)",
|
||||||
|
"Bash(node -e \"console.log\\('fiber', require\\('@react-three/fiber/package.json'\\).version\\); console.log\\('three', require\\('three/package.json'\\).version\\); console.log\\('drei', require\\('@react-three/drei/package.json'\\).version\\)\")",
|
||||||
|
"Bash(npx tsc *)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ from typing import Optional, Dict, Any
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.celestial import BodyInfo
|
from app.models.celestial import BodyInfo
|
||||||
from app.services.horizons import horizons_service
|
from app.services.horizons import horizons_service
|
||||||
from app.services.db_service import celestial_body_service, resource_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
|
from app.services.resource_service import resource_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Optional
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.horizons import horizons_service
|
from app.services.horizons import horizons_service
|
||||||
from app.services.db_service import celestial_body_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
from app.services.orbit_service import orbit_service
|
from app.services.orbit_service import orbit_service
|
||||||
from app.services.task_service import task_service
|
from app.services.task_service import task_service
|
||||||
from app.services.nasa_worker import generate_orbits_task
|
from app.services.nasa_worker import generate_orbits_task
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@ from app.services.horizons import horizons_service
|
||||||
from app.services.cache import cache_service
|
from app.services.cache import cache_service
|
||||||
from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
|
from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
|
||||||
from app.services.system_settings_service import system_settings_service
|
from app.services.system_settings_service import system_settings_service
|
||||||
from app.services.db_service import (
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
celestial_body_service,
|
from app.services.nasa_cache_service import nasa_cache_service
|
||||||
position_service,
|
from app.services.position_service import position_service
|
||||||
nasa_cache_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ from typing import Optional, Dict, Any
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.db import Resource
|
from app.models.db import Resource
|
||||||
from app.services.db_service import celestial_body_service, resource_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
|
from app.services.resource_service import resource_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.db_service import static_data_service
|
from app.services.static_data_service import static_data_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/celestial/static", tags=["celestial-static"])
|
router = APIRouter(prefix="/celestial/static", tags=["celestial-static"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.horizons import horizons_service
|
from app.services.horizons import horizons_service
|
||||||
from app.services.db_service import celestial_body_service, position_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
|
from app.services.position_service import position_service
|
||||||
from app.services.task_service import task_service
|
from app.services.task_service import task_service
|
||||||
from app.services.nasa_worker import download_positions_task
|
from app.services.nasa_worker import download_positions_task
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""Rocket simulator configuration APIs."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.db import RocketConfig, User
|
||||||
|
from app.services.auth_deps import require_admin
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rockets", tags=["rockets"])
|
||||||
|
RocketCode = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9-]*$")]
|
||||||
|
|
||||||
|
|
||||||
|
class RocketStage(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
dry_mass_kg: float = Field(gt=0)
|
||||||
|
fuel_mass_kg: float = Field(gt=0)
|
||||||
|
max_thrust_n: float = Field(gt=0)
|
||||||
|
specific_impulse_s: float = Field(gt=0)
|
||||||
|
engine_count: int = Field(gt=0)
|
||||||
|
length_ratio: float = Field(gt=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class RocketBase(BaseModel):
|
||||||
|
code: RocketCode
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
name_zh: str | None = Field(default=None, max_length=100)
|
||||||
|
manufacturer: str | None = Field(default=None, max_length=100)
|
||||||
|
country: str | None = Field(default=None, max_length=100)
|
||||||
|
launch_site_name: str = Field(default="Equatorial Launch Site", min_length=1, max_length=120)
|
||||||
|
launch_latitude_deg: float = Field(default=0, ge=-90, le=90)
|
||||||
|
launch_longitude_deg: float = Field(default=0, ge=-180, le=180)
|
||||||
|
description: str | None = None
|
||||||
|
color: str = Field(default="#f8fafc", pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
height_m: float = Field(gt=0)
|
||||||
|
diameter_m: float = Field(gt=0)
|
||||||
|
payload_mass_kg: float = Field(ge=0, default=0)
|
||||||
|
drag_coefficient: float = Field(gt=0, le=2, default=0.4)
|
||||||
|
reference_area_m2: float = Field(gt=0)
|
||||||
|
target_orbit_km: float = Field(gt=0, default=200)
|
||||||
|
target_velocity_mps: float = Field(gt=0, default=7800)
|
||||||
|
separation_delay_seconds: float = Field(ge=0, le=30, default=2)
|
||||||
|
second_stage_ignition_delay_seconds: float = Field(ge=0, le=30, default=1)
|
||||||
|
stage_1: RocketStage
|
||||||
|
stage_2: RocketStage
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
@field_validator("code", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_code(cls, value: str) -> str:
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
class RocketCreate(RocketBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RocketUpdate(BaseModel):
|
||||||
|
code: RocketCode | None = None
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
name_zh: str | None = Field(default=None, max_length=100)
|
||||||
|
manufacturer: str | None = Field(default=None, max_length=100)
|
||||||
|
country: str | None = Field(default=None, max_length=100)
|
||||||
|
launch_site_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
launch_latitude_deg: float | None = Field(default=None, ge=-90, le=90)
|
||||||
|
launch_longitude_deg: float | None = Field(default=None, ge=-180, le=180)
|
||||||
|
description: str | None = None
|
||||||
|
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
height_m: float | None = Field(default=None, gt=0)
|
||||||
|
diameter_m: float | None = Field(default=None, gt=0)
|
||||||
|
payload_mass_kg: float | None = Field(default=None, ge=0)
|
||||||
|
drag_coefficient: float | None = Field(default=None, gt=0, le=2)
|
||||||
|
reference_area_m2: float | None = Field(default=None, gt=0)
|
||||||
|
target_orbit_km: float | None = Field(default=None, gt=0)
|
||||||
|
target_velocity_mps: float | None = Field(default=None, gt=0)
|
||||||
|
separation_delay_seconds: float | None = Field(default=None, ge=0, le=30)
|
||||||
|
second_stage_ignition_delay_seconds: float | None = Field(default=None, ge=0, le=30)
|
||||||
|
stage_1: RocketStage | None = None
|
||||||
|
stage_2: RocketStage | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
@field_validator("code", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_code(cls, value: str | None) -> str | None:
|
||||||
|
return value.strip().lower() if value else value
|
||||||
|
|
||||||
|
|
||||||
|
class RocketResponse(RocketBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
def rocket_values(data: RocketCreate | RocketUpdate, *, exclude_unset: bool = False) -> dict:
|
||||||
|
values = data.model_dump(exclude_unset=exclude_unset)
|
||||||
|
for key in ("stage_1", "stage_2"):
|
||||||
|
if key in values and values[key] is not None:
|
||||||
|
values[key] = dict(values[key])
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_unique_code(db: AsyncSession, code: str, exclude_id: int | None = None) -> None:
|
||||||
|
query = select(RocketConfig.id).where(RocketConfig.code == code)
|
||||||
|
if exclude_id is not None:
|
||||||
|
query = query.where(RocketConfig.id != exclude_id)
|
||||||
|
if (await db.execute(query)).scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="Rocket code already exists")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RocketResponse])
|
||||||
|
async def list_active_rockets(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RocketConfig)
|
||||||
|
.where(RocketConfig.is_active.is_(True))
|
||||||
|
.order_by(RocketConfig.sort_order, RocketConfig.id)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin")
|
||||||
|
async def list_rockets_for_admin(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
filters = []
|
||||||
|
if search:
|
||||||
|
keyword = f"%{search.strip()}%"
|
||||||
|
filters.append(or_(
|
||||||
|
RocketConfig.code.ilike(keyword),
|
||||||
|
RocketConfig.name.ilike(keyword),
|
||||||
|
RocketConfig.name_zh.ilike(keyword),
|
||||||
|
))
|
||||||
|
|
||||||
|
total = await db.scalar(select(func.count(RocketConfig.id)).where(*filters))
|
||||||
|
result = await db.execute(
|
||||||
|
select(RocketConfig)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(RocketConfig.sort_order, RocketConfig.id)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"rockets": [RocketResponse.model_validate(item) for item in result.scalars().all()],
|
||||||
|
"total": total or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{code}", response_model=RocketResponse)
|
||||||
|
async def get_active_rocket(code: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RocketConfig).where(
|
||||||
|
RocketConfig.code == code.lower(),
|
||||||
|
RocketConfig.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rocket = result.scalar_one_or_none()
|
||||||
|
if not rocket:
|
||||||
|
raise HTTPException(status_code=404, detail="Rocket not found")
|
||||||
|
return rocket
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin", response_model=RocketResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_rocket(
|
||||||
|
data: RocketCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
await ensure_unique_code(db, data.code)
|
||||||
|
rocket = RocketConfig(**rocket_values(data))
|
||||||
|
db.add(rocket)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rocket)
|
||||||
|
return rocket
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/{rocket_id}", response_model=RocketResponse)
|
||||||
|
async def update_rocket(
|
||||||
|
rocket_id: int,
|
||||||
|
data: RocketUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rocket = await db.get(RocketConfig, rocket_id)
|
||||||
|
if not rocket:
|
||||||
|
raise HTTPException(status_code=404, detail="Rocket not found")
|
||||||
|
|
||||||
|
values = rocket_values(data, exclude_unset=True)
|
||||||
|
if not values:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
if "code" in values:
|
||||||
|
await ensure_unique_code(db, values["code"], rocket_id)
|
||||||
|
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(rocket, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rocket)
|
||||||
|
return rocket
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/{rocket_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_rocket(
|
||||||
|
rocket_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rocket = await db.get(RocketConfig, rocket_id)
|
||||||
|
if not rocket:
|
||||||
|
raise HTTPException(status_code=404, detail="Rocket not found")
|
||||||
|
await db.delete(rocket)
|
||||||
|
await db.commit()
|
||||||
|
|
@ -34,6 +34,7 @@ from app.api.star_system import router as star_system_router
|
||||||
from app.api.scheduled_job import router as scheduled_job_router
|
from app.api.scheduled_job import router as scheduled_job_router
|
||||||
from app.api.social import router as social_router # Import social_router
|
from app.api.social import router as social_router # Import social_router
|
||||||
from app.api.event import router as event_router # Import event_router
|
from app.api.event import router as event_router # Import event_router
|
||||||
|
from app.api.rocket import router as rocket_router
|
||||||
from app.services.redis_cache import redis_cache
|
from app.services.redis_cache import redis_cache
|
||||||
from app.services.cache_preheat import preheat_all_caches
|
from app.services.cache_preheat import preheat_all_caches
|
||||||
from app.services.scheduler_service import scheduler_service
|
from app.services.scheduler_service import scheduler_service
|
||||||
|
|
@ -187,6 +188,7 @@ app.include_router(task_router, prefix=settings.api_prefix)
|
||||||
app.include_router(scheduled_job_router, prefix=settings.api_prefix)
|
app.include_router(scheduled_job_router, prefix=settings.api_prefix)
|
||||||
app.include_router(social_router, prefix=settings.api_prefix)
|
app.include_router(social_router, prefix=settings.api_prefix)
|
||||||
app.include_router(event_router, prefix=settings.api_prefix) # Added event_router
|
app.include_router(event_router, prefix=settings.api_prefix) # Added event_router
|
||||||
|
app.include_router(rocket_router, prefix=settings.api_prefix)
|
||||||
|
|
||||||
# Mount static files for uploaded resources
|
# Mount static files for uploaded resources
|
||||||
upload_dir = Path(__file__).parent.parent / "upload"
|
upload_dir = Path(__file__).parent.parent / "upload"
|
||||||
|
|
@ -235,4 +237,4 @@ if __name__ == "__main__":
|
||||||
port=8000,
|
port=8000,
|
||||||
reload=True,
|
reload=True,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from .system_settings import SystemSettings
|
||||||
from .task import Task
|
from .task import Task
|
||||||
from .user_follow import UserFollow
|
from .user_follow import UserFollow
|
||||||
from .celestial_event import CelestialEvent
|
from .celestial_event import CelestialEvent
|
||||||
|
from .rocket import RocketConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CelestialBody",
|
"CelestialBody",
|
||||||
|
|
@ -33,4 +34,5 @@ __all__ = [
|
||||||
"Task",
|
"Task",
|
||||||
"UserFollow",
|
"UserFollow",
|
||||||
"CelestialEvent",
|
"CelestialEvent",
|
||||||
|
"RocketConfig",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Rocket configuration ORM model."""
|
||||||
|
from sqlalchemy import Boolean, Column, Double, Integer, String, Text, TIMESTAMP, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RocketConfig(Base):
|
||||||
|
"""Two-stage launch vehicle configuration used by the simulator."""
|
||||||
|
|
||||||
|
__tablename__ = "rocket_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
name_zh = Column(String(100), nullable=True)
|
||||||
|
manufacturer = Column(String(100), nullable=True)
|
||||||
|
country = Column(String(100), nullable=True)
|
||||||
|
launch_site_name = Column(String(120), nullable=False, default="Equatorial Launch Site")
|
||||||
|
launch_latitude_deg = Column(Double, nullable=False, default=0)
|
||||||
|
launch_longitude_deg = Column(Double, nullable=False, default=0)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
color = Column(String(20), nullable=False, default="#f8fafc")
|
||||||
|
height_m = Column(Double, nullable=False)
|
||||||
|
diameter_m = Column(Double, nullable=False)
|
||||||
|
payload_mass_kg = Column(Double, nullable=False, default=0)
|
||||||
|
drag_coefficient = Column(Double, nullable=False, default=0.4)
|
||||||
|
reference_area_m2 = Column(Double, nullable=False)
|
||||||
|
target_orbit_km = Column(Double, nullable=False, default=200)
|
||||||
|
target_velocity_mps = Column(Double, nullable=False, default=7800)
|
||||||
|
separation_delay_seconds = Column(Double, nullable=False, default=2)
|
||||||
|
second_stage_ignition_delay_seconds = Column(Double, nullable=False, default=1)
|
||||||
|
stage_1 = Column(JSONB, nullable=False)
|
||||||
|
stage_2 = Column(JSONB, nullable=False)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
@ -8,7 +8,8 @@ from typing import List, Dict, Any
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
|
from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
|
||||||
from app.services.db_service import celestial_body_service, position_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
|
from app.services.position_service import position_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""Persistence operations for celestial bodies."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.db import CelestialBody
|
||||||
|
|
||||||
|
|
||||||
|
class CelestialBodyService:
|
||||||
|
"""Create, read, update, and delete celestial bodies."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_all_bodies(
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
body_type: Optional[str] = None,
|
||||||
|
system_id: Optional[int] = None,
|
||||||
|
) -> List[CelestialBody]:
|
||||||
|
async def query(active_session: AsyncSession) -> List[CelestialBody]:
|
||||||
|
statement = select(CelestialBody)
|
||||||
|
if body_type:
|
||||||
|
statement = statement.where(CelestialBody.type == body_type)
|
||||||
|
if system_id is not None:
|
||||||
|
statement = statement.where(CelestialBody.system_id == system_id)
|
||||||
|
result = await active_session.execute(statement.order_by(CelestialBody.name))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_body_by_id(
|
||||||
|
body_id: str,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> Optional[CelestialBody]:
|
||||||
|
async def query(active_session: AsyncSession) -> Optional[CelestialBody]:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(CelestialBody).where(CelestialBody.id == body_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_body(
|
||||||
|
body_data: Dict[str, Any],
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> CelestialBody:
|
||||||
|
async def create(active_session: AsyncSession) -> CelestialBody:
|
||||||
|
body = CelestialBody(**body_data)
|
||||||
|
active_session.add(body)
|
||||||
|
await active_session.commit()
|
||||||
|
await active_session.refresh(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await create(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await create(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_body(
|
||||||
|
body_id: str,
|
||||||
|
update_data: Dict[str, Any],
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> Optional[CelestialBody]:
|
||||||
|
async def update(active_session: AsyncSession) -> Optional[CelestialBody]:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(CelestialBody).where(CelestialBody.id == body_id)
|
||||||
|
)
|
||||||
|
body = result.scalar_one_or_none()
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
if hasattr(body, key):
|
||||||
|
setattr(body, key, value)
|
||||||
|
|
||||||
|
await active_session.commit()
|
||||||
|
await active_session.refresh(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await update(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await update(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_body(
|
||||||
|
body_id: str,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> bool:
|
||||||
|
async def delete(active_session: AsyncSession) -> bool:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(CelestialBody).where(CelestialBody.id == body_id)
|
||||||
|
)
|
||||||
|
body = result.scalar_one_or_none()
|
||||||
|
if body is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await active_session.delete(body)
|
||||||
|
await active_session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await delete(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await delete(active_session)
|
||||||
|
|
||||||
|
|
||||||
|
celestial_body_service = CelestialBodyService()
|
||||||
|
|
@ -1,689 +1,27 @@
|
||||||
|
"""Backward-compatible exports for domain-specific persistence services.
|
||||||
|
|
||||||
|
New code should import from the individual service modules. This facade keeps
|
||||||
|
existing API routes, jobs, and maintenance scripts working during migration.
|
||||||
"""
|
"""
|
||||||
Database service layer for celestial data operations
|
|
||||||
"""
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
from datetime import datetime
|
|
||||||
from sqlalchemy import select, and_, delete
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.models.db import CelestialBody, Position, StaticData, NasaCache, Resource
|
from app.services.celestial_body_service import (
|
||||||
from app.database import AsyncSessionLocal
|
CelestialBodyService,
|
||||||
|
celestial_body_service,
|
||||||
logger = logging.getLogger(__name__)
|
)
|
||||||
|
from app.services.nasa_cache_service import NasaCacheService, nasa_cache_service
|
||||||
|
from app.services.position_service import PositionService, position_service
|
||||||
class CelestialBodyService:
|
from app.services.resource_service import ResourceService, resource_service
|
||||||
"""Service for celestial body operations"""
|
from app.services.static_data_service import StaticDataService, static_data_service
|
||||||
|
|
||||||
@staticmethod
|
__all__ = [
|
||||||
async def get_all_bodies(
|
"CelestialBodyService",
|
||||||
session: Optional[AsyncSession] = None,
|
"NasaCacheService",
|
||||||
body_type: Optional[str] = None,
|
"PositionService",
|
||||||
system_id: Optional[int] = None
|
"ResourceService",
|
||||||
) -> List[CelestialBody]:
|
"StaticDataService",
|
||||||
"""
|
"celestial_body_service",
|
||||||
Get all celestial bodies, optionally filtered by type and star system
|
"nasa_cache_service",
|
||||||
|
"position_service",
|
||||||
Args:
|
"resource_service",
|
||||||
session: Database session
|
"static_data_service",
|
||||||
body_type: Filter by body type (star, planet, dwarf_planet, etc.)
|
]
|
||||||
system_id: Filter by star system ID (1=Solar System, 2+=Exoplanets)
|
|
||||||
"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
query = select(CelestialBody)
|
|
||||||
if body_type:
|
|
||||||
query = query.where(CelestialBody.type == body_type)
|
|
||||||
if system_id is not None:
|
|
||||||
query = query.where(CelestialBody.system_id == system_id)
|
|
||||||
result = await s.execute(query.order_by(CelestialBody.name))
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_body_by_id(
|
|
||||||
body_id: str,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> Optional[CelestialBody]:
|
|
||||||
"""Get a celestial body by ID"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(CelestialBody).where(CelestialBody.id == body_id)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def create_body(
|
|
||||||
body_data: Dict[str, Any],
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> CelestialBody:
|
|
||||||
"""Create a new celestial body"""
|
|
||||||
async def _create(s: AsyncSession):
|
|
||||||
body = CelestialBody(**body_data)
|
|
||||||
s.add(body)
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(body)
|
|
||||||
return body
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _create(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _create(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_body(
|
|
||||||
body_id: str,
|
|
||||||
update_data: Dict[str, Any],
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> Optional[CelestialBody]:
|
|
||||||
"""Update a celestial body"""
|
|
||||||
async def _update(s: AsyncSession):
|
|
||||||
# Query the body
|
|
||||||
result = await s.execute(
|
|
||||||
select(CelestialBody).where(CelestialBody.id == body_id)
|
|
||||||
)
|
|
||||||
body = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not body:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
for key, value in update_data.items():
|
|
||||||
if hasattr(body, key):
|
|
||||||
setattr(body, key, value)
|
|
||||||
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(body)
|
|
||||||
return body
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _update(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _update(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def delete_body(
|
|
||||||
body_id: str,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> bool:
|
|
||||||
"""Delete a celestial body"""
|
|
||||||
async def _delete(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(CelestialBody).where(CelestialBody.id == body_id)
|
|
||||||
)
|
|
||||||
body = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not body:
|
|
||||||
return False
|
|
||||||
|
|
||||||
await s.delete(body)
|
|
||||||
await s.commit()
|
|
||||||
return True
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _delete(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _delete(s)
|
|
||||||
|
|
||||||
|
|
||||||
class PositionService:
|
|
||||||
"""Service for position data operations"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def save_positions(
|
|
||||||
body_id: str,
|
|
||||||
positions: List[Dict[str, Any]],
|
|
||||||
source: str = "nasa_horizons",
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> int:
|
|
||||||
"""Save multiple position records for a celestial body (upsert: insert or update if exists)"""
|
|
||||||
async def _save(s: AsyncSession):
|
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for pos_data in positions:
|
|
||||||
# Use PostgreSQL's INSERT ... ON CONFLICT to handle duplicates
|
|
||||||
stmt = insert(Position).values(
|
|
||||||
body_id=body_id,
|
|
||||||
time=pos_data["time"],
|
|
||||||
x=pos_data["x"],
|
|
||||||
y=pos_data["y"],
|
|
||||||
z=pos_data["z"],
|
|
||||||
vx=pos_data.get("vx"),
|
|
||||||
vy=pos_data.get("vy"),
|
|
||||||
vz=pos_data.get("vz"),
|
|
||||||
source=source
|
|
||||||
)
|
|
||||||
|
|
||||||
# On conflict (body_id, time), update the existing record
|
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=['body_id', 'time'],
|
|
||||||
set_={
|
|
||||||
'x': pos_data["x"],
|
|
||||||
'y': pos_data["y"],
|
|
||||||
'z': pos_data["z"],
|
|
||||||
'vx': pos_data.get("vx"),
|
|
||||||
'vy': pos_data.get("vy"),
|
|
||||||
'vz': pos_data.get("vz"),
|
|
||||||
'source': source
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
await s.execute(stmt)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
await s.commit()
|
|
||||||
return count
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _save(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _save(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_positions(
|
|
||||||
body_id: str,
|
|
||||||
start_time: Optional[datetime] = None,
|
|
||||||
end_time: Optional[datetime] = None,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> List[Position]:
|
|
||||||
"""Get positions for a celestial body within a time range"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
query = select(Position).where(Position.body_id == body_id)
|
|
||||||
|
|
||||||
if start_time and end_time:
|
|
||||||
query = query.where(
|
|
||||||
and_(
|
|
||||||
Position.time >= start_time,
|
|
||||||
Position.time <= end_time
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif start_time:
|
|
||||||
query = query.where(Position.time >= start_time)
|
|
||||||
elif end_time:
|
|
||||||
query = query.where(Position.time <= end_time)
|
|
||||||
|
|
||||||
query = query.order_by(Position.time)
|
|
||||||
result = await s.execute(query)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_positions_in_range(
|
|
||||||
body_id: str,
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> List[Position]:
|
|
||||||
"""Alias for get_positions with required time range"""
|
|
||||||
return await PositionService.get_positions(body_id, start_time, end_time, session)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def save_position(
|
|
||||||
body_id: str,
|
|
||||||
time: datetime,
|
|
||||||
x: float,
|
|
||||||
y: float,
|
|
||||||
z: float,
|
|
||||||
source: str = "nasa_horizons",
|
|
||||||
vx: Optional[float] = None,
|
|
||||||
vy: Optional[float] = None,
|
|
||||||
vz: Optional[float] = None,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> Position:
|
|
||||||
"""Save a single position record"""
|
|
||||||
async def _save(s: AsyncSession):
|
|
||||||
# Check if position already exists
|
|
||||||
existing = await s.execute(
|
|
||||||
select(Position).where(
|
|
||||||
and_(
|
|
||||||
Position.body_id == body_id,
|
|
||||||
Position.time == time
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
existing_pos = existing.scalar_one_or_none()
|
|
||||||
|
|
||||||
if existing_pos:
|
|
||||||
# Update existing position
|
|
||||||
existing_pos.x = x
|
|
||||||
existing_pos.y = y
|
|
||||||
existing_pos.z = z
|
|
||||||
existing_pos.vx = vx
|
|
||||||
existing_pos.vy = vy
|
|
||||||
existing_pos.vz = vz
|
|
||||||
existing_pos.source = source
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(existing_pos)
|
|
||||||
return existing_pos
|
|
||||||
else:
|
|
||||||
# Create new position
|
|
||||||
position = Position(
|
|
||||||
body_id=body_id,
|
|
||||||
time=time,
|
|
||||||
x=x,
|
|
||||||
y=y,
|
|
||||||
z=z,
|
|
||||||
vx=vx,
|
|
||||||
vy=vy,
|
|
||||||
vz=vz,
|
|
||||||
source=source
|
|
||||||
)
|
|
||||||
s.add(position)
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(position)
|
|
||||||
return position
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _save(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _save(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def delete_old_positions(
|
|
||||||
before_time: datetime,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> int:
|
|
||||||
"""Delete position records older than specified time"""
|
|
||||||
async def _delete(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
delete(Position).where(Position.time < before_time)
|
|
||||||
)
|
|
||||||
await s.commit()
|
|
||||||
return result.rowcount
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _delete(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _delete(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_available_dates(
|
|
||||||
body_id: str,
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> List[datetime]:
|
|
||||||
"""Get all dates that have position data for a specific body within a time range"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
from sqlalchemy import func, Date
|
|
||||||
|
|
||||||
# Query distinct dates (truncate to date)
|
|
||||||
query = select(func.date(Position.time)).where(
|
|
||||||
and_(
|
|
||||||
Position.body_id == body_id,
|
|
||||||
Position.time >= start_time,
|
|
||||||
Position.time <= end_time
|
|
||||||
)
|
|
||||||
).distinct().order_by(func.date(Position.time))
|
|
||||||
|
|
||||||
result = await s.execute(query)
|
|
||||||
dates = [row[0] for row in result]
|
|
||||||
return dates
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
|
|
||||||
class NasaCacheService:
|
|
||||||
"""Service for NASA API response caching"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_cached_response(
|
|
||||||
body_id: str,
|
|
||||||
start_time: Optional[datetime],
|
|
||||||
end_time: Optional[datetime],
|
|
||||||
step: str,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get cached NASA API response"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
# Remove timezone info for comparison with database TIMESTAMP WITHOUT TIME ZONE
|
|
||||||
start_naive = start_time.replace(tzinfo=None) if start_time else None
|
|
||||||
end_naive = end_time.replace(tzinfo=None) if end_time else None
|
|
||||||
now_naive = datetime.utcnow()
|
|
||||||
|
|
||||||
result = await s.execute(
|
|
||||||
select(NasaCache).where(
|
|
||||||
and_(
|
|
||||||
NasaCache.body_id == body_id,
|
|
||||||
NasaCache.start_time == start_naive,
|
|
||||||
NasaCache.end_time == end_naive,
|
|
||||||
NasaCache.step == step,
|
|
||||||
NasaCache.expires_at > now_naive
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
cache = result.scalar_one_or_none()
|
|
||||||
return cache.data if cache else None
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def save_response(
|
|
||||||
body_id: str,
|
|
||||||
start_time: Optional[datetime],
|
|
||||||
end_time: Optional[datetime],
|
|
||||||
step: str,
|
|
||||||
response_data: Dict[str, Any],
|
|
||||||
ttl_days: int = 7,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> NasaCache:
|
|
||||||
"""Save NASA API response to cache (upsert: insert or update if exists)"""
|
|
||||||
async def _save(s: AsyncSession):
|
|
||||||
from datetime import timedelta
|
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
|
||||||
|
|
||||||
# Remove timezone info for database storage (TIMESTAMP WITHOUT TIME ZONE)
|
|
||||||
start_naive = start_time.replace(tzinfo=None) if start_time else None
|
|
||||||
end_naive = end_time.replace(tzinfo=None) if end_time else None
|
|
||||||
now_naive = datetime.utcnow()
|
|
||||||
|
|
||||||
# Generate cache key
|
|
||||||
start_str = start_time.isoformat() if start_time else "null"
|
|
||||||
end_str = end_time.isoformat() if end_time else "null"
|
|
||||||
cache_key = f"{body_id}:{start_str}:{end_str}:{step}"
|
|
||||||
|
|
||||||
# Use PostgreSQL's INSERT ... ON CONFLICT to handle duplicates atomically
|
|
||||||
stmt = insert(NasaCache).values(
|
|
||||||
cache_key=cache_key,
|
|
||||||
body_id=body_id,
|
|
||||||
start_time=start_naive,
|
|
||||||
end_time=end_naive,
|
|
||||||
step=step,
|
|
||||||
data=response_data,
|
|
||||||
expires_at=now_naive + timedelta(days=ttl_days)
|
|
||||||
)
|
|
||||||
|
|
||||||
# On conflict, update the existing record
|
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=['cache_key'],
|
|
||||||
set_={
|
|
||||||
'data': response_data,
|
|
||||||
'created_at': now_naive,
|
|
||||||
'expires_at': now_naive + timedelta(days=ttl_days)
|
|
||||||
}
|
|
||||||
).returning(NasaCache)
|
|
||||||
|
|
||||||
result = await s.execute(stmt)
|
|
||||||
cache = result.scalar_one()
|
|
||||||
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(cache)
|
|
||||||
return cache
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _save(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _save(s)
|
|
||||||
|
|
||||||
|
|
||||||
class StaticDataService:
|
|
||||||
"""Service for static data operations"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_all_items(
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> List[StaticData]:
|
|
||||||
"""Get all static data items"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(StaticData).order_by(StaticData.category, StaticData.name)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def create_static(
|
|
||||||
data: Dict[str, Any],
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> StaticData:
|
|
||||||
"""Create new static data"""
|
|
||||||
async def _create(s: AsyncSession):
|
|
||||||
item = StaticData(**data)
|
|
||||||
s.add(item)
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _create(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _create(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_static(
|
|
||||||
item_id: int,
|
|
||||||
update_data: Dict[str, Any],
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> Optional[StaticData]:
|
|
||||||
"""Update static data"""
|
|
||||||
async def _update(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(StaticData).where(StaticData.id == item_id)
|
|
||||||
)
|
|
||||||
item = result.scalar_one_or_none()
|
|
||||||
if not item:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for key, value in update_data.items():
|
|
||||||
if hasattr(item, key):
|
|
||||||
setattr(item, key, value)
|
|
||||||
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _update(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _update(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def delete_static(
|
|
||||||
item_id: int,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> bool:
|
|
||||||
"""Delete static data"""
|
|
||||||
async def _delete(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(StaticData).where(StaticData.id == item_id)
|
|
||||||
)
|
|
||||||
item = result.scalar_one_or_none()
|
|
||||||
if not item:
|
|
||||||
return False
|
|
||||||
|
|
||||||
await s.delete(item)
|
|
||||||
await s.commit()
|
|
||||||
return True
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _delete(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _delete(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_by_category(
|
|
||||||
category: str,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> List[StaticData]:
|
|
||||||
"""Get all static data items for a category"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(StaticData)
|
|
||||||
.where(StaticData.category == category)
|
|
||||||
.order_by(StaticData.name)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_all_categories(
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> List[str]:
|
|
||||||
"""Get all available categories"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(StaticData.category).distinct()
|
|
||||||
)
|
|
||||||
return [row[0] for row in result]
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceService:
|
|
||||||
"""Service for resource file management"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def create_resource(
|
|
||||||
resource_data: Dict[str, Any],
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> Resource:
|
|
||||||
"""Create a new resource record"""
|
|
||||||
async def _create(s: AsyncSession):
|
|
||||||
resource = Resource(**resource_data)
|
|
||||||
s.add(resource)
|
|
||||||
await s.commit()
|
|
||||||
await s.refresh(resource)
|
|
||||||
return resource
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _create(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _create(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_resources_by_body(
|
|
||||||
body_id: str,
|
|
||||||
resource_type: Optional[str] = None,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> List[Resource]:
|
|
||||||
"""Get all resources for a celestial body"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
query = select(Resource).where(Resource.body_id == body_id)
|
|
||||||
if resource_type:
|
|
||||||
query = query.where(Resource.resource_type == resource_type)
|
|
||||||
result = await s.execute(query.order_by(Resource.created_at))
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_all_resources_grouped_by_body(
|
|
||||||
body_ids: Optional[List[str]] = None,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> Dict[str, List[Resource]]:
|
|
||||||
"""
|
|
||||||
Get all resources grouped by body_id (optimized for bulk loading)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body_ids: Optional list of body IDs to filter by
|
|
||||||
session: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping body_id to list of resources
|
|
||||||
"""
|
|
||||||
async def _query(s: AsyncSession):
|
|
||||||
query = select(Resource).order_by(Resource.body_id, Resource.created_at)
|
|
||||||
if body_ids:
|
|
||||||
query = query.where(Resource.body_id.in_(body_ids))
|
|
||||||
result = await s.execute(query)
|
|
||||||
resources = result.scalars().all()
|
|
||||||
|
|
||||||
# Group by body_id
|
|
||||||
grouped = {}
|
|
||||||
for resource in resources:
|
|
||||||
if resource.body_id not in grouped:
|
|
||||||
grouped[resource.body_id] = []
|
|
||||||
grouped[resource.body_id].append(resource)
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _query(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _query(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def delete_resource(
|
|
||||||
resource_id: int,
|
|
||||||
session: Optional[AsyncSession] = None
|
|
||||||
) -> bool:
|
|
||||||
"""Delete a resource record"""
|
|
||||||
async def _delete(s: AsyncSession):
|
|
||||||
result = await s.execute(
|
|
||||||
select(Resource).where(Resource.id == resource_id)
|
|
||||||
)
|
|
||||||
resource = result.scalar_one_or_none()
|
|
||||||
if resource:
|
|
||||||
await s.delete(resource)
|
|
||||||
await s.commit()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
if session:
|
|
||||||
return await _delete(session)
|
|
||||||
else:
|
|
||||||
async with AsyncSessionLocal() as s:
|
|
||||||
return await _delete(s)
|
|
||||||
|
|
||||||
|
|
||||||
# Export service instances
|
|
||||||
celestial_body_service = CelestialBodyService()
|
|
||||||
position_service = PositionService()
|
|
||||||
nasa_cache_service = NasaCacheService()
|
|
||||||
static_data_service = StaticDataService()
|
|
||||||
resource_service = ResourceService()
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""Persistence operations for NASA API response caching."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.db import NasaCache
|
||||||
|
|
||||||
|
|
||||||
|
class NasaCacheService:
|
||||||
|
"""Read and write cached NASA API responses."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_cached_response(
|
||||||
|
body_id: str,
|
||||||
|
start_time: Optional[datetime],
|
||||||
|
end_time: Optional[datetime],
|
||||||
|
step: str,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
async def query(active_session: AsyncSession) -> Optional[Dict[str, Any]]:
|
||||||
|
start_naive = start_time.replace(tzinfo=None) if start_time else None
|
||||||
|
end_naive = end_time.replace(tzinfo=None) if end_time else None
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(NasaCache).where(
|
||||||
|
and_(
|
||||||
|
NasaCache.body_id == body_id,
|
||||||
|
NasaCache.start_time == start_naive,
|
||||||
|
NasaCache.end_time == end_naive,
|
||||||
|
NasaCache.step == step,
|
||||||
|
NasaCache.expires_at > datetime.utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cache = result.scalar_one_or_none()
|
||||||
|
return cache.data if cache else None
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def save_response(
|
||||||
|
body_id: str,
|
||||||
|
start_time: Optional[datetime],
|
||||||
|
end_time: Optional[datetime],
|
||||||
|
step: str,
|
||||||
|
response_data: Dict[str, Any],
|
||||||
|
ttl_days: int = 7,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> NasaCache:
|
||||||
|
async def save(active_session: AsyncSession) -> NasaCache:
|
||||||
|
start_naive = start_time.replace(tzinfo=None) if start_time else None
|
||||||
|
end_naive = end_time.replace(tzinfo=None) if end_time else None
|
||||||
|
now = datetime.utcnow()
|
||||||
|
start_key = start_time.isoformat() if start_time else "null"
|
||||||
|
end_key = end_time.isoformat() if end_time else "null"
|
||||||
|
cache_key = f"{body_id}:{start_key}:{end_key}:{step}"
|
||||||
|
|
||||||
|
statement = insert(NasaCache).values(
|
||||||
|
cache_key=cache_key,
|
||||||
|
body_id=body_id,
|
||||||
|
start_time=start_naive,
|
||||||
|
end_time=end_naive,
|
||||||
|
step=step,
|
||||||
|
data=response_data,
|
||||||
|
expires_at=now + timedelta(days=ttl_days),
|
||||||
|
)
|
||||||
|
statement = statement.on_conflict_do_update(
|
||||||
|
index_elements=["cache_key"],
|
||||||
|
set_={
|
||||||
|
"data": response_data,
|
||||||
|
"created_at": now,
|
||||||
|
"expires_at": now + timedelta(days=ttl_days),
|
||||||
|
},
|
||||||
|
).returning(NasaCache)
|
||||||
|
|
||||||
|
result = await active_session.execute(statement)
|
||||||
|
cache = result.scalar_one()
|
||||||
|
await active_session.commit()
|
||||||
|
await active_session.refresh(cache)
|
||||||
|
return cache
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await save(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await save(active_session)
|
||||||
|
|
||||||
|
|
||||||
|
nasa_cache_service = NasaCacheService()
|
||||||
|
|
@ -10,7 +10,8 @@ from typing import List, Optional
|
||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.services.task_service import task_service
|
from app.services.task_service import task_service
|
||||||
from app.services.db_service import celestial_body_service, position_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
|
from app.services.position_service import position_service
|
||||||
from app.services.horizons import horizons_service
|
from app.services.horizons import horizons_service
|
||||||
from app.services.orbit_service import orbit_service
|
from app.services.orbit_service import orbit_service
|
||||||
from app.services.event_service import event_service
|
from app.services.event_service import event_service
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""Persistence operations for celestial position data."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, delete, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.db import Position
|
||||||
|
|
||||||
|
|
||||||
|
class PositionService:
|
||||||
|
"""Store and query time-series position data."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def save_positions(
|
||||||
|
body_id: str,
|
||||||
|
positions: List[Dict[str, Any]],
|
||||||
|
source: str = "nasa_horizons",
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> int:
|
||||||
|
async def save(active_session: AsyncSession) -> int:
|
||||||
|
for position_data in positions:
|
||||||
|
statement = insert(Position).values(
|
||||||
|
body_id=body_id,
|
||||||
|
time=position_data["time"],
|
||||||
|
x=position_data["x"],
|
||||||
|
y=position_data["y"],
|
||||||
|
z=position_data["z"],
|
||||||
|
vx=position_data.get("vx"),
|
||||||
|
vy=position_data.get("vy"),
|
||||||
|
vz=position_data.get("vz"),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
statement = statement.on_conflict_do_update(
|
||||||
|
index_elements=["body_id", "time"],
|
||||||
|
set_={
|
||||||
|
"x": position_data["x"],
|
||||||
|
"y": position_data["y"],
|
||||||
|
"z": position_data["z"],
|
||||||
|
"vx": position_data.get("vx"),
|
||||||
|
"vy": position_data.get("vy"),
|
||||||
|
"vz": position_data.get("vz"),
|
||||||
|
"source": source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await active_session.execute(statement)
|
||||||
|
|
||||||
|
await active_session.commit()
|
||||||
|
return len(positions)
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await save(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await save(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_positions(
|
||||||
|
body_id: str,
|
||||||
|
start_time: Optional[datetime] = None,
|
||||||
|
end_time: Optional[datetime] = None,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> List[Position]:
|
||||||
|
async def query(active_session: AsyncSession) -> List[Position]:
|
||||||
|
statement = select(Position).where(Position.body_id == body_id)
|
||||||
|
if start_time and end_time:
|
||||||
|
statement = statement.where(
|
||||||
|
and_(Position.time >= start_time, Position.time <= end_time)
|
||||||
|
)
|
||||||
|
elif start_time:
|
||||||
|
statement = statement.where(Position.time >= start_time)
|
||||||
|
elif end_time:
|
||||||
|
statement = statement.where(Position.time <= end_time)
|
||||||
|
|
||||||
|
result = await active_session.execute(statement.order_by(Position.time))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_positions_in_range(
|
||||||
|
body_id: str,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> List[Position]:
|
||||||
|
return await PositionService.get_positions(
|
||||||
|
body_id, start_time, end_time, session
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def save_position(
|
||||||
|
body_id: str,
|
||||||
|
time: datetime,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
source: str = "nasa_horizons",
|
||||||
|
vx: Optional[float] = None,
|
||||||
|
vy: Optional[float] = None,
|
||||||
|
vz: Optional[float] = None,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> Position:
|
||||||
|
async def save(active_session: AsyncSession) -> Position:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(Position).where(
|
||||||
|
and_(Position.body_id == body_id, Position.time == time)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position = result.scalar_one_or_none()
|
||||||
|
if position is None:
|
||||||
|
position = Position(body_id=body_id, time=time, x=x, y=y, z=z)
|
||||||
|
active_session.add(position)
|
||||||
|
|
||||||
|
position.x = x
|
||||||
|
position.y = y
|
||||||
|
position.z = z
|
||||||
|
position.vx = vx
|
||||||
|
position.vy = vy
|
||||||
|
position.vz = vz
|
||||||
|
position.source = source
|
||||||
|
await active_session.commit()
|
||||||
|
await active_session.refresh(position)
|
||||||
|
return position
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await save(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await save(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_old_positions(
|
||||||
|
before_time: datetime,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> int:
|
||||||
|
async def remove(active_session: AsyncSession) -> int:
|
||||||
|
result = await active_session.execute(
|
||||||
|
delete(Position).where(Position.time < before_time)
|
||||||
|
)
|
||||||
|
await active_session.commit()
|
||||||
|
return result.rowcount
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await remove(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await remove(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_available_dates(
|
||||||
|
body_id: str,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> List[datetime]:
|
||||||
|
async def query(active_session: AsyncSession) -> List[datetime]:
|
||||||
|
date_expression = func.date(Position.time)
|
||||||
|
statement = (
|
||||||
|
select(date_expression)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Position.body_id == body_id,
|
||||||
|
Position.time >= start_time,
|
||||||
|
Position.time <= end_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.order_by(date_expression)
|
||||||
|
)
|
||||||
|
result = await active_session.execute(statement)
|
||||||
|
return [row[0] for row in result]
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
|
||||||
|
position_service = PositionService()
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Persistence operations for celestial resource metadata."""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.db import Resource
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceService:
|
||||||
|
"""Create, query, and delete celestial resource records."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_resource(
|
||||||
|
resource_data: Dict[str, Any],
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> Resource:
|
||||||
|
async def create(active_session: AsyncSession) -> Resource:
|
||||||
|
resource = Resource(**resource_data)
|
||||||
|
active_session.add(resource)
|
||||||
|
await active_session.commit()
|
||||||
|
await active_session.refresh(resource)
|
||||||
|
return resource
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await create(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await create(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_resources_by_body(
|
||||||
|
body_id: str,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> List[Resource]:
|
||||||
|
async def query(active_session: AsyncSession) -> List[Resource]:
|
||||||
|
statement = select(Resource).where(Resource.body_id == body_id)
|
||||||
|
if resource_type:
|
||||||
|
statement = statement.where(Resource.resource_type == resource_type)
|
||||||
|
result = await active_session.execute(
|
||||||
|
statement.order_by(Resource.created_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_all_resources_grouped_by_body(
|
||||||
|
body_ids: Optional[List[str]] = None,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> Dict[str, List[Resource]]:
|
||||||
|
async def query(active_session: AsyncSession) -> Dict[str, List[Resource]]:
|
||||||
|
statement = select(Resource).order_by(
|
||||||
|
Resource.body_id, Resource.created_at
|
||||||
|
)
|
||||||
|
if body_ids:
|
||||||
|
statement = statement.where(Resource.body_id.in_(body_ids))
|
||||||
|
|
||||||
|
result = await active_session.execute(statement)
|
||||||
|
grouped: Dict[str, List[Resource]] = defaultdict(list)
|
||||||
|
for resource in result.scalars().all():
|
||||||
|
grouped[resource.body_id].append(resource)
|
||||||
|
return dict(grouped)
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_resource(
|
||||||
|
resource_id: int,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> bool:
|
||||||
|
async def delete(active_session: AsyncSession) -> bool:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(Resource).where(Resource.id == resource_id)
|
||||||
|
)
|
||||||
|
resource = result.scalar_one_or_none()
|
||||||
|
if resource is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await active_session.delete(resource)
|
||||||
|
await active_session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await delete(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await delete(active_session)
|
||||||
|
|
||||||
|
|
||||||
|
resource_service = ResourceService()
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Persistence operations for system static data."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.db import StaticData
|
||||||
|
|
||||||
|
|
||||||
|
class StaticDataService:
|
||||||
|
"""Create, read, update, and delete static data entries."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_all_items(
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> List[StaticData]:
|
||||||
|
async def query(active_session: AsyncSession) -> List[StaticData]:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(StaticData).order_by(StaticData.category, StaticData.name)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_static(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> StaticData:
|
||||||
|
async def create(active_session: AsyncSession) -> StaticData:
|
||||||
|
item = StaticData(**data)
|
||||||
|
active_session.add(item)
|
||||||
|
await active_session.commit()
|
||||||
|
await active_session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await create(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await create(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_static(
|
||||||
|
item_id: int,
|
||||||
|
update_data: Dict[str, Any],
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> Optional[StaticData]:
|
||||||
|
async def update(active_session: AsyncSession) -> Optional[StaticData]:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(StaticData).where(StaticData.id == item_id)
|
||||||
|
)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
if hasattr(item, key):
|
||||||
|
setattr(item, key, value)
|
||||||
|
|
||||||
|
await active_session.commit()
|
||||||
|
await active_session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await update(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await update(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_static(
|
||||||
|
item_id: int,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> bool:
|
||||||
|
async def delete(active_session: AsyncSession) -> bool:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(StaticData).where(StaticData.id == item_id)
|
||||||
|
)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if item is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await active_session.delete(item)
|
||||||
|
await active_session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await delete(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await delete(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_by_category(
|
||||||
|
category: str,
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> List[StaticData]:
|
||||||
|
async def query(active_session: AsyncSession) -> List[StaticData]:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(StaticData)
|
||||||
|
.where(StaticData.category == category)
|
||||||
|
.order_by(StaticData.name)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_all_categories(
|
||||||
|
session: Optional[AsyncSession] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
async def query(active_session: AsyncSession) -> List[str]:
|
||||||
|
result = await active_session.execute(
|
||||||
|
select(StaticData.category).distinct()
|
||||||
|
)
|
||||||
|
return [row[0] for row in result]
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
return await query(session)
|
||||||
|
async with AsyncSessionLocal() as active_session:
|
||||||
|
return await query(active_session)
|
||||||
|
|
||||||
|
|
||||||
|
static_data_service = StaticDataService()
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS rocket_configs (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
code varchar(50) NOT NULL UNIQUE,
|
||||||
|
name varchar(100) NOT NULL,
|
||||||
|
name_zh varchar(100),
|
||||||
|
manufacturer varchar(100),
|
||||||
|
country varchar(100),
|
||||||
|
launch_site_name varchar(120) NOT NULL DEFAULT 'Equatorial Launch Site',
|
||||||
|
launch_latitude_deg double precision NOT NULL DEFAULT 0 CHECK (launch_latitude_deg BETWEEN -90 AND 90),
|
||||||
|
launch_longitude_deg double precision NOT NULL DEFAULT 0 CHECK (launch_longitude_deg BETWEEN -180 AND 180),
|
||||||
|
description text,
|
||||||
|
color varchar(20) NOT NULL DEFAULT '#f8fafc',
|
||||||
|
height_m double precision NOT NULL CHECK (height_m > 0),
|
||||||
|
diameter_m double precision NOT NULL CHECK (diameter_m > 0),
|
||||||
|
payload_mass_kg double precision NOT NULL DEFAULT 0 CHECK (payload_mass_kg >= 0),
|
||||||
|
drag_coefficient double precision NOT NULL DEFAULT 0.4 CHECK (drag_coefficient > 0),
|
||||||
|
reference_area_m2 double precision NOT NULL CHECK (reference_area_m2 > 0),
|
||||||
|
target_orbit_km double precision NOT NULL DEFAULT 200 CHECK (target_orbit_km > 0),
|
||||||
|
target_velocity_mps double precision NOT NULL DEFAULT 7800 CHECK (target_velocity_mps > 0),
|
||||||
|
separation_delay_seconds double precision NOT NULL DEFAULT 2 CHECK (separation_delay_seconds >= 0),
|
||||||
|
second_stage_ignition_delay_seconds double precision NOT NULL DEFAULT 1 CHECK (second_stage_ignition_delay_seconds >= 0),
|
||||||
|
stage_1 jsonb NOT NULL,
|
||||||
|
stage_2 jsonb NOT NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamp DEFAULT now(),
|
||||||
|
updated_at timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE rocket_configs ADD COLUMN IF NOT EXISTS launch_site_name varchar(120) NOT NULL DEFAULT 'Equatorial Launch Site';
|
||||||
|
ALTER TABLE rocket_configs ADD COLUMN IF NOT EXISTS launch_latitude_deg double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE rocket_configs ADD COLUMN IF NOT EXISTS launch_longitude_deg double precision NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_rocket_configs_code ON rocket_configs (code);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_rocket_configs_is_active ON rocket_configs (is_active);
|
||||||
|
|
||||||
|
INSERT INTO rocket_configs (
|
||||||
|
code, name, name_zh, manufacturer, country,
|
||||||
|
launch_site_name, launch_latitude_deg, launch_longitude_deg, description, color,
|
||||||
|
height_m, diameter_m, payload_mass_kg, drag_coefficient, reference_area_m2,
|
||||||
|
target_orbit_km, target_velocity_mps, separation_delay_seconds,
|
||||||
|
second_stage_ignition_delay_seconds, stage_1, stage_2, is_active, sort_order
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'falcon-9', 'Falcon 9', '猎鹰9号', 'SpaceX', '美国',
|
||||||
|
'Cape Canaveral', 28.5623, -80.5774,
|
||||||
|
'两级可重复使用运载火箭,用于近地轨道发射任务模拟。', '#f8fafc',
|
||||||
|
70, 3.7, 15000, 0.4, 10.75, 200, 7800, 2.2, 1.0,
|
||||||
|
jsonb_build_object('name', '一级推进器', 'dry_mass_kg', 25600, 'fuel_mass_kg', 411000, 'max_thrust_n', 7607000, 'specific_impulse_s', 282, 'engine_count', 9, 'length_ratio', 0.65),
|
||||||
|
jsonb_build_object('name', '二级推进器', 'dry_mass_kg', 4000, 'fuel_mass_kg', 107500, 'max_thrust_n', 981000, 'specific_impulse_s', 348, 'engine_count', 1, 'length_ratio', 0.35),
|
||||||
|
true, 1
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'long-march-5', 'Long March 5', '长征五号', '中国运载火箭技术研究院', '中国',
|
||||||
|
'文昌航天发射场', 19.6145, 110.951,
|
||||||
|
'中国大型两级液体运载火箭,用于重型载荷与深空任务模拟。', '#e8edf2',
|
||||||
|
56.97, 5, 18000, 0.42, 19.63, 200, 7800, 2.5, 1.2,
|
||||||
|
jsonb_build_object('name', '芯一级', 'dry_mass_kg', 38000, 'fuel_mass_kg', 175000, 'max_thrust_n', 10600000, 'specific_impulse_s', 310, 'engine_count', 10, 'length_ratio', 0.62),
|
||||||
|
jsonb_build_object('name', '芯二级', 'dry_mass_kg', 12000, 'fuel_mass_kg', 26000, 'max_thrust_n', 768000, 'specific_impulse_s', 442, 'engine_count', 2, 'length_ratio', 0.38),
|
||||||
|
true, 2
|
||||||
|
)
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE rocket_configs
|
||||||
|
SET launch_site_name = 'Cape Canaveral', launch_latitude_deg = 28.5623, launch_longitude_deg = -80.5774
|
||||||
|
WHERE code = 'falcon-9' AND launch_site_name = 'Equatorial Launch Site';
|
||||||
|
|
||||||
|
UPDATE rocket_configs
|
||||||
|
SET launch_site_name = '文昌航天发射场', launch_latitude_deg = 19.6145, launch_longitude_deg = 110.951
|
||||||
|
WHERE code = 'long-march-5' AND launch_site_name = 'Equatorial Launch Site';
|
||||||
|
|
||||||
|
INSERT INTO menus (
|
||||||
|
parent_id, name, title, icon, path, component, sort_order,
|
||||||
|
is_active, description
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
parent.id, 'rocket_configs', '火箭数据管理', 'rocket',
|
||||||
|
'/admin/rockets', 'admin/Rockets', 5, true,
|
||||||
|
'管理火箭发射模拟使用的两级运载火箭数据'
|
||||||
|
FROM menus AS parent
|
||||||
|
WHERE parent.name = 'data_management'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM menus WHERE name = 'rocket_configs')
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO role_menus (role_id, menu_id)
|
||||||
|
SELECT roles.id, menus.id
|
||||||
|
FROM roles
|
||||||
|
JOIN menus ON menus.name = 'rocket_configs'
|
||||||
|
WHERE roles.name = 'admin'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM role_menus
|
||||||
|
WHERE role_menus.role_id = roles.id
|
||||||
|
AND role_menus.menu_id = menus.id
|
||||||
|
);
|
||||||
|
|
@ -23,11 +23,9 @@ import logging
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from app.services.horizons import horizons_service
|
from app.services.horizons import horizons_service
|
||||||
from app.services.db_service import (
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
celestial_body_service,
|
from app.services.nasa_cache_service import nasa_cache_service
|
||||||
position_service,
|
from app.services.position_service import position_service
|
||||||
nasa_cache_service
|
|
||||||
)
|
|
||||||
from app.services.redis_cache import redis_cache, cache_nasa_response
|
from app.services.redis_cache import redis_cache, cache_nasa_response
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5420,7 +5420,8 @@ CREATE TABLE "public"."users" (
|
||||||
"is_active" bool NOT NULL,
|
"is_active" bool NOT NULL,
|
||||||
"created_at" timestamp(6) DEFAULT now(),
|
"created_at" timestamp(6) DEFAULT now(),
|
||||||
"updated_at" timestamp(6) DEFAULT now(),
|
"updated_at" timestamp(6) DEFAULT now(),
|
||||||
"last_login_at" timestamp(6)
|
"last_login_at" timestamp(6),
|
||||||
|
"avatar_url" varchar(255) COLLATE "pg_catalog"."default"
|
||||||
)
|
)
|
||||||
;
|
;
|
||||||
ALTER TABLE "public"."users" OWNER TO "postgres";
|
ALTER TABLE "public"."users" OWNER TO "postgres";
|
||||||
|
|
@ -5430,6 +5431,7 @@ COMMENT ON COLUMN "public"."users"."email" IS 'Email address';
|
||||||
COMMENT ON COLUMN "public"."users"."full_name" IS 'Full name';
|
COMMENT ON COLUMN "public"."users"."full_name" IS 'Full name';
|
||||||
COMMENT ON COLUMN "public"."users"."is_active" IS 'Account active status';
|
COMMENT ON COLUMN "public"."users"."is_active" IS 'Account active status';
|
||||||
COMMENT ON COLUMN "public"."users"."last_login_at" IS 'Last login time';
|
COMMENT ON COLUMN "public"."users"."last_login_at" IS 'Last login time';
|
||||||
|
COMMENT ON COLUMN "public"."users"."avatar_url" IS 'User avatar file path';
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Records of users
|
-- Records of users
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.horizons import horizons_service
|
from app.services.horizons import horizons_service
|
||||||
from app.services.db_service import position_service, celestial_body_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
|
from app.services.position_service import position_service
|
||||||
|
|
||||||
|
|
||||||
async def prefetch_month(year: int, month: int, session):
|
async def prefetch_month(year: int, month: int, session):
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.horizons import horizons_service
|
from app.services.horizons import horizons_service
|
||||||
from app.services.db_service import celestial_body_service, position_service
|
from app.services.celestial_body_service import celestial_body_service
|
||||||
|
from app.services.position_service import position_service
|
||||||
from app.models.celestial import CELESTIAL_BODIES
|
from app.models.celestial import CELESTIAL_BODIES
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import os
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.db_service import static_data_service
|
from app.services.static_data_service import static_data_service
|
||||||
from app.models.db import StaticData
|
from app.models.db import StaticData
|
||||||
from sqlalchemy import select, update, insert
|
from sqlalchemy import select, update, insert
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Cosmo 文档
|
||||||
|
|
||||||
|
项目入口文档保留在仓库根目录:
|
||||||
|
|
||||||
|
- [README](../README.md)
|
||||||
|
- [项目说明](../PROJECT.md)
|
||||||
|
- [快速开始](../QUICKSTART.md)
|
||||||
|
- [部署指南](../DEPLOYMENT.md)
|
||||||
|
|
||||||
|
其余文档按用途归档:
|
||||||
|
|
||||||
|
- `architecture/`:架构、数据策略、算法和数据库设计
|
||||||
|
- `guides/`:配置、运维和功能使用指南
|
||||||
|
- `plans/`:路线图及阶段计划
|
||||||
|
- `reports/`:阶段完成报告、问题分析和修复记录
|
||||||
|
|
@ -37,4 +37,4 @@
|
||||||
|------|--------------|-------------|------|
|
|------|--------------|-------------|------|
|
||||||
| user:tokens:{uid} | Set | 存该用户所有活动的 JWT Token,用于多端登录和注销。 | 随 Token 过期 |
|
| user:tokens:{uid} | Set | 存该用户所有活动的 JWT Token,用于多端登录和注销。 | 随 Token 过期 |
|
||||||
| positions:{args} | String (JSON) | API 响应缓存。存前端需要的天体坐标数组。 | 1小时 (实时) / 7天 (历史) |
|
| positions:{args} | String (JSON) | API 响应缓存。存前端需要的天体坐标数组。 | 1小时 (实时) / 7天 (历史) |
|
||||||
| cosmo:danmaku:stream | ZSet | 留言板消息流。按时间排序,自动过期。 | 24 小时 (可配) |
|
| cosmo:danmaku:stream | ZSet | 留言板消息流。按时间排序,自动过期。 | 24 小时 (可配) |
|
||||||
|
|
@ -307,7 +307,7 @@ GET /celestial/orbits?body_type=planet
|
||||||
|
|
||||||
## 相关文档
|
## 相关文档
|
||||||
|
|
||||||
- [天体轨道生成系统技术文档](./ORBIT_GENERATION_SYSTEM.md)
|
- [天体轨道生成系统技术文档](../architecture/ORBIT_GENERATION_SYSTEM.md)
|
||||||
- [NASA JPL Horizons API文档](https://ssd.jpl.nasa.gov/horizons/)
|
- [NASA JPL Horizons API文档](https://ssd.jpl.nasa.gov/horizons/)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -87,4 +87,4 @@
|
||||||
3. **验证**:
|
3. **验证**:
|
||||||
* 测试关注/取关。
|
* 测试关注/取关。
|
||||||
* 测试频道消息发送与接收(验证 Redis 存储)。
|
* 测试频道消息发送与接收(验证 Redis 存储)。
|
||||||
* 测试 Horizons 缓存生效。
|
* 测试 Horizons 缓存生效。
|
||||||
|
|
@ -328,4 +328,4 @@ function getPlanetTexture(radius, temp) {
|
||||||
// 4. 宜居带/岩石行星 (默认)
|
// 4. 宜居带/岩石行星 (默认)
|
||||||
return textureLoader.load(PATH + 'rocky_atmosphere.jpg');
|
return textureLoader.load(PATH + 'rocky_atmosphere.jpg');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -364,7 +364,7 @@ POSITIVE:
|
||||||
- **Model Source**: `/Users/jiliu/WorkSpace/cosmo/backend/upload/model/`
|
- **Model Source**: `/Users/jiliu/WorkSpace/cosmo/backend/upload/model/`
|
||||||
- **Texture Build Output**: `/Users/jiliu/WorkSpace/cosmo/frontend/dist/textures/`
|
- **Texture Build Output**: `/Users/jiliu/WorkSpace/cosmo/frontend/dist/textures/`
|
||||||
- **Model Build Output**: `/Users/jiliu/WorkSpace/cosmo/frontend/dist/models/`
|
- **Model Build Output**: `/Users/jiliu/WorkSpace/cosmo/frontend/dist/models/`
|
||||||
- **Documentation**: `/Users/jiliu/WorkSpace/cosmo/frontend/PERFORMANCE_OPTIMIZATION.md`
|
- **Documentation**: `../guides/PERFORMANCE_OPTIMIZATION.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -387,4 +387,3 @@ POSITIVE:
|
||||||
2. Add KTX2/Basis texture compression
|
2. Add KTX2/Basis texture compression
|
||||||
3. Implement virtual scrolling for star catalog
|
3. Implement virtual scrolling for star catalog
|
||||||
4. Add service worker for persistent caching
|
4. Add service worker for persistent caching
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
|
"baseline-browser-mapping": "^2.10.43",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { TimelineController } from './components/TimelineController';
|
||||||
import { Loading } from './components/Loading';
|
import { Loading } from './components/Loading';
|
||||||
import { ControlPanel } from './components/ControlPanel';
|
import { ControlPanel } from './components/ControlPanel';
|
||||||
import { AuthModal } from './components/AuthModal';
|
import { AuthModal } from './components/AuthModal';
|
||||||
|
import { BodyDetailOverlay } from './components/BodyDetailOverlay';
|
||||||
import { auth } from './utils/auth';
|
import { auth } from './utils/auth';
|
||||||
import type { CelestialBody } from './types';
|
import type { CelestialBody } from './types';
|
||||||
import { useToast } from './contexts/ToastContext';
|
import { useToast } from './contexts/ToastContext';
|
||||||
|
|
@ -21,7 +22,6 @@ import { useToast } from './contexts/ToastContext';
|
||||||
// Lazy load non-critical components for better performance
|
// Lazy load non-critical components for better performance
|
||||||
const InterstellarTicker = lazy(() => import('./components/InterstellarTicker').then(m => ({ default: m.InterstellarTicker })));
|
const InterstellarTicker = lazy(() => import('./components/InterstellarTicker').then(m => ({ default: m.InterstellarTicker })));
|
||||||
const MessageBoard = lazy(() => import('./components/MessageBoard').then(m => ({ default: m.MessageBoard })));
|
const MessageBoard = lazy(() => import('./components/MessageBoard').then(m => ({ default: m.MessageBoard })));
|
||||||
const BodyDetailOverlay = lazy(() => import('./components/BodyDetailOverlay').then(m => ({ default: m.BodyDetailOverlay })));
|
|
||||||
|
|
||||||
// Timeline configuration - will be fetched from backend later
|
// Timeline configuration - will be fetched from backend later
|
||||||
const TIMELINE_DAYS = 30; // Total days in timeline range
|
const TIMELINE_DAYS = 30; // Total days in timeline range
|
||||||
|
|
@ -223,6 +223,7 @@ function App() {
|
||||||
setViewMode(prev => prev === 'solar' ? 'galaxy' : 'solar');
|
setViewMode(prev => prev === 'solar' ? 'galaxy' : 'solar');
|
||||||
setSelectedBody(null); // Reset selection when switching
|
setSelectedBody(null); // Reset selection when switching
|
||||||
}}
|
}}
|
||||||
|
onOpenRocketSimulator={() => navigate('/rocket-simulator')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Auth Modal */}
|
{/* Auth Modal */}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,37 @@
|
||||||
/**
|
/**
|
||||||
* Router configuration
|
* Router configuration
|
||||||
*/
|
*/
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Login } from './pages/Login';
|
|
||||||
import { AdminLayout } from './pages/admin/AdminLayout';
|
|
||||||
import { Dashboard } from './pages/admin/Dashboard';
|
|
||||||
import { CelestialBodies } from './pages/admin/CelestialBodies';
|
|
||||||
import { CelestialEvents } from './pages/admin/CelestialEvents';
|
|
||||||
import { StarSystems } from './pages/admin/StarSystems';
|
|
||||||
import { StaticData } from './pages/admin/StaticData';
|
|
||||||
import { Users } from './pages/admin/Users';
|
|
||||||
import { NASADownload } from './pages/admin/NASADownload';
|
|
||||||
import { SystemSettings } from './pages/admin/SystemSettings';
|
|
||||||
import { Tasks } from './pages/admin/Tasks';
|
|
||||||
import { ScheduledJobs } from './pages/admin/ScheduledJobs';
|
|
||||||
import { UserProfile } from './pages/admin/UserProfile';
|
|
||||||
import { ChangePassword } from './pages/admin/ChangePassword';
|
|
||||||
import { MyCelestialBodies } from './pages/admin/MyCelestialBodies';
|
|
||||||
import { auth } from './utils/auth';
|
import { auth } from './utils/auth';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import App from './App';
|
|
||||||
|
const App = lazy(() => import('./App'));
|
||||||
|
const Login = lazy(() => import('./pages/Login').then((module) => ({ default: module.Login })));
|
||||||
|
const RocketSimulator = lazy(() => import('./pages/RocketSimulator').then((module) => ({ default: module.RocketSimulator })));
|
||||||
|
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout').then((module) => ({ default: module.AdminLayout })));
|
||||||
|
const Dashboard = lazy(() => import('./pages/admin/Dashboard').then((module) => ({ default: module.Dashboard })));
|
||||||
|
const CelestialBodies = lazy(() => import('./pages/admin/CelestialBodies').then((module) => ({ default: module.CelestialBodies })));
|
||||||
|
const CelestialEvents = lazy(() => import('./pages/admin/CelestialEvents').then((module) => ({ default: module.CelestialEvents })));
|
||||||
|
const StarSystems = lazy(() => import('./pages/admin/StarSystems').then((module) => ({ default: module.StarSystems })));
|
||||||
|
const StaticData = lazy(() => import('./pages/admin/StaticData').then((module) => ({ default: module.StaticData })));
|
||||||
|
const Users = lazy(() => import('./pages/admin/Users').then((module) => ({ default: module.Users })));
|
||||||
|
const NASADownload = lazy(() => import('./pages/admin/NASADownload').then((module) => ({ default: module.NASADownload })));
|
||||||
|
const SystemSettings = lazy(() => import('./pages/admin/SystemSettings').then((module) => ({ default: module.SystemSettings })));
|
||||||
|
const Tasks = lazy(() => import('./pages/admin/Tasks').then((module) => ({ default: module.Tasks })));
|
||||||
|
const ScheduledJobs = lazy(() => import('./pages/admin/ScheduledJobs').then((module) => ({ default: module.ScheduledJobs })));
|
||||||
|
const UserProfile = lazy(() => import('./pages/admin/UserProfile').then((module) => ({ default: module.UserProfile })));
|
||||||
|
const ChangePassword = lazy(() => import('./pages/admin/ChangePassword').then((module) => ({ default: module.ChangePassword })));
|
||||||
|
const MyCelestialBodies = lazy(() => import('./pages/admin/MyCelestialBodies').then((module) => ({ default: module.MyCelestialBodies })));
|
||||||
|
const Rockets = lazy(() => import('./pages/admin/Rockets').then((module) => ({ default: module.Rockets })));
|
||||||
|
|
||||||
|
function RouteFallback() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center bg-black" role="status" aria-label="页面加载中">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-green-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Protected Route wrapper
|
// Protected Route wrapper
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -33,12 +45,14 @@ export function Router() {
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
{/* Main app (3D visualization) */}
|
{/* Main app (3D visualization) */}
|
||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
|
<Route path="/rocket-simulator" element={<RocketSimulator />} />
|
||||||
|
|
||||||
{/* User routes (protected) */}
|
{/* User routes (protected) */}
|
||||||
<Route
|
<Route
|
||||||
|
|
@ -73,11 +87,13 @@ export function Router() {
|
||||||
<Route path="tasks" element={<Tasks />} />
|
<Route path="tasks" element={<Tasks />} />
|
||||||
<Route path="scheduled-jobs" element={<ScheduledJobs />} />
|
<Route path="scheduled-jobs" element={<ScheduledJobs />} />
|
||||||
<Route path="settings" element={<SystemSettings />} />
|
<Route path="settings" element={<SystemSettings />} />
|
||||||
|
<Route path="rockets" element={<Rockets />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,6 @@ interface BeltProps {
|
||||||
rotationSpeed: number;
|
rotationSpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BeltData {
|
|
||||||
category: string;
|
|
||||||
name: string;
|
|
||||||
name_zh: string;
|
|
||||||
data: BeltProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Belt({
|
function Belt({
|
||||||
innerRadiusAU,
|
innerRadiusAU,
|
||||||
outerRadiusAU,
|
outerRadiusAU,
|
||||||
|
|
|
||||||
|
|
@ -39,20 +39,30 @@ export function BodyDetailOverlay({ bodyId, preloadedData, onClose }: BodyDetail
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard against out-of-order responses and setState-after-unmount.
|
||||||
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
request.get(`/celestial/info/${bodyId}`)
|
request.get(`/celestial/info/${bodyId}`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
setBodyData(response.data);
|
if (active) setBodyData(response.data);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
if (!active) return;
|
||||||
console.error("Failed to fetch body details:", error);
|
console.error("Failed to fetch body details:", error);
|
||||||
toast.error("加载天体详情失败");
|
toast.error("加载天体详情失败");
|
||||||
onClose(); // Close overlay on error
|
onClose(); // Close overlay on error
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setLoading(false);
|
if (active) setLoading(false);
|
||||||
});
|
});
|
||||||
}, [bodyId, preloadedData, onClose, toast]);
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
// Only refetch when the target body changes. `onClose`/`toast` are stable in
|
||||||
|
// behavior but not identity, so excluding them avoids refetch on every parent render.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bodyId, preloadedData]);
|
||||||
|
|
||||||
if ((!bodyId && !preloadedData) || !bodyData) {
|
if ((!bodyId && !preloadedData) || !bodyData) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
|
import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
|
||||||
import { Mesh, Group, DoubleSide } from 'three';
|
import { Mesh, Group, DoubleSide } from 'three';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useGLTF, useTexture } from '@react-three/drei';
|
import { useGLTF } from '@react-three/drei';
|
||||||
import { useFrame } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import type { CelestialBody as CelestialBodyType } from '../types';
|
import type { CelestialBody as CelestialBodyType } from '../types';
|
||||||
import { fetchBodyResources } from '../utils/api';
|
import { fetchBodyResources } from '../utils/api';
|
||||||
import { getCelestialSize } from '../config/celestialSizes';
|
import { getCelestialSize } from '../config/celestialSizes';
|
||||||
import { useSystemSetting } from '../hooks/useSystemSetting';
|
import { useSystemSetting } from '../hooks/useSystemSetting';
|
||||||
|
import { useOptionalTexture } from '../hooks/useOptionalTexture';
|
||||||
|
|
||||||
interface BodyViewerProps {
|
interface BodyViewerProps {
|
||||||
body: CelestialBodyType;
|
body: CelestialBodyType;
|
||||||
|
|
@ -15,7 +16,7 @@ interface BodyViewerProps {
|
||||||
|
|
||||||
// Planetary Rings component - handles texture-based rings
|
// Planetary Rings component - handles texture-based rings
|
||||||
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
||||||
const texture = texturePath ? useTexture(texturePath) : null;
|
const texture = useOptionalTexture(texturePath);
|
||||||
const meshRef = useRef<Mesh>(null);
|
const meshRef = useRef<Mesh>(null);
|
||||||
|
|
||||||
// Dynamic ring dimensions based on planet size
|
// Dynamic ring dimensions based on planet size
|
||||||
|
|
@ -273,6 +274,142 @@ function ProbeModelViewer({ modelPath, modelScale }: { modelPath: string; modelS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Irregular Comet Nucleus - potato-like shape (module scope to avoid remount每次渲染)
|
||||||
|
function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Texture | null }) {
|
||||||
|
const nucleusRef = useRef<Mesh>(null);
|
||||||
|
|
||||||
|
// Create irregular geometry by deforming a sphere
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
const geo = new THREE.SphereGeometry(size, 64, 64); // Higher resolution for detail view
|
||||||
|
const positions = geo.attributes.position;
|
||||||
|
|
||||||
|
// Deform vertices to create irregular shape
|
||||||
|
for (let i = 0; i < positions.count; i++) {
|
||||||
|
const x = positions.getX(i);
|
||||||
|
const y = positions.getY(i);
|
||||||
|
const z = positions.getZ(i);
|
||||||
|
|
||||||
|
// Create multiple noise frequencies for complex shape
|
||||||
|
const noise1 = Math.sin(x * 3.5) * Math.cos(y * 2.7) * Math.sin(z * 4.2);
|
||||||
|
const noise2 = Math.cos(x * 5.1) * Math.sin(y * 4.3) * Math.cos(z * 3.8);
|
||||||
|
const noise3 = Math.sin(x * 7.2) * Math.sin(y * 6.1) * Math.cos(z * 5.5);
|
||||||
|
|
||||||
|
const deformation = 1 + (noise1 * 0.25 + noise2 * 0.15 + noise3 * 0.1);
|
||||||
|
|
||||||
|
positions.setXYZ(i, x * deformation, y * deformation, z * deformation);
|
||||||
|
}
|
||||||
|
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
return geo;
|
||||||
|
}, [size]);
|
||||||
|
|
||||||
|
// Slow rotation
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (nucleusRef.current) {
|
||||||
|
nucleusRef.current.rotation.y += delta * 0.05;
|
||||||
|
nucleusRef.current.rotation.z += delta * 0.02;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={nucleusRef} geometry={geometry}>
|
||||||
|
{texture ? (
|
||||||
|
<meshStandardMaterial
|
||||||
|
key="textured"
|
||||||
|
map={texture}
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0.1}
|
||||||
|
color="#b8b8b8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<meshStandardMaterial
|
||||||
|
key="plain"
|
||||||
|
color="#6b6b6b"
|
||||||
|
roughness={0.95}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Coma (gas/dust cloud) around comet nucleus (module scope)
|
||||||
|
function CometComa({ radius }: { radius: number }) {
|
||||||
|
const positions = useMemo(() => {
|
||||||
|
const count = 300; // More particles for detail view
|
||||||
|
const p = new Float32Array(count * 3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Denser near center, spreading outward
|
||||||
|
const r = radius * (0.8 + Math.pow(Math.random(), 1.5) * 4); // 0.8x to 4.8x radius
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos(2 * Math.random() - 1);
|
||||||
|
|
||||||
|
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||||
|
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||||
|
p[i * 3 + 2] = r * Math.cos(phi);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}, [radius]);
|
||||||
|
|
||||||
|
const pointsRef = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
// Animate coma
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
if (pointsRef.current) {
|
||||||
|
pointsRef.current.rotation.y += delta * 0.05;
|
||||||
|
// Pulsing effect
|
||||||
|
const scale = 1 + Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
|
||||||
|
pointsRef.current.scale.setScalar(scale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Inner bright coma */}
|
||||||
|
<points ref={pointsRef}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="position" args={[positions, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial
|
||||||
|
size={radius * 0.8}
|
||||||
|
color="#88ccff"
|
||||||
|
transparent
|
||||||
|
opacity={0.7}
|
||||||
|
sizeAttenuation={true}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
depthWrite={false}
|
||||||
|
/>
|
||||||
|
</points>
|
||||||
|
|
||||||
|
{/* Middle glow layer */}
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[radius * 2.5, 32, 32]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#66aadd"
|
||||||
|
transparent
|
||||||
|
opacity={0.15}
|
||||||
|
side={THREE.BackSide}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
depthWrite={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Outer diffuse glow */}
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[radius * 4.5, 32, 32]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#4488aa"
|
||||||
|
transparent
|
||||||
|
opacity={0.12}
|
||||||
|
side={THREE.BackSide}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
depthWrite={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Sub-component for Planet models
|
// Sub-component for Planet models
|
||||||
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, ringTexturePath, meshRef, disableGlow = false }: {
|
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, ringTexturePath, meshRef, disableGlow = false }: {
|
||||||
body: CelestialBodyType;
|
body: CelestialBodyType;
|
||||||
|
|
@ -281,10 +418,10 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
||||||
emissiveIntensity: number;
|
emissiveIntensity: number;
|
||||||
texturePath: string | null;
|
texturePath: string | null;
|
||||||
ringTexturePath?: string | null;
|
ringTexturePath?: string | null;
|
||||||
meshRef: React.RefObject<Mesh>;
|
meshRef: React.RefObject<Mesh | null>;
|
||||||
disableGlow?: boolean;
|
disableGlow?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const texture = texturePath ? useTexture(texturePath) : null;
|
const texture = useOptionalTexture(texturePath);
|
||||||
|
|
||||||
// Rotation animation
|
// Rotation animation
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
|
@ -293,140 +430,6 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Irregular Comet Nucleus - potato-like shape
|
|
||||||
function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Texture | null }) {
|
|
||||||
const nucleusRef = useRef<Mesh>(null);
|
|
||||||
|
|
||||||
// Create irregular geometry by deforming a sphere
|
|
||||||
const geometry = useMemo(() => {
|
|
||||||
const geo = new THREE.SphereGeometry(size, 64, 64); // Higher resolution for detail view
|
|
||||||
const positions = geo.attributes.position;
|
|
||||||
|
|
||||||
// Deform vertices to create irregular shape
|
|
||||||
for (let i = 0; i < positions.count; i++) {
|
|
||||||
const x = positions.getX(i);
|
|
||||||
const y = positions.getY(i);
|
|
||||||
const z = positions.getZ(i);
|
|
||||||
|
|
||||||
// Create multiple noise frequencies for complex shape
|
|
||||||
const noise1 = Math.sin(x * 3.5) * Math.cos(y * 2.7) * Math.sin(z * 4.2);
|
|
||||||
const noise2 = Math.cos(x * 5.1) * Math.sin(y * 4.3) * Math.cos(z * 3.8);
|
|
||||||
const noise3 = Math.sin(x * 7.2) * Math.sin(y * 6.1) * Math.cos(z * 5.5);
|
|
||||||
|
|
||||||
const deformation = 1 + (noise1 * 0.25 + noise2 * 0.15 + noise3 * 0.1);
|
|
||||||
|
|
||||||
positions.setXYZ(i, x * deformation, y * deformation, z * deformation);
|
|
||||||
}
|
|
||||||
|
|
||||||
geo.computeVertexNormals();
|
|
||||||
return geo;
|
|
||||||
}, [size]);
|
|
||||||
|
|
||||||
// Slow rotation
|
|
||||||
useFrame((_, delta) => {
|
|
||||||
if (nucleusRef.current) {
|
|
||||||
nucleusRef.current.rotation.y += delta * 0.05;
|
|
||||||
nucleusRef.current.rotation.z += delta * 0.02;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mesh ref={nucleusRef} geometry={geometry}>
|
|
||||||
{texture ? (
|
|
||||||
<meshStandardMaterial
|
|
||||||
map={texture}
|
|
||||||
roughness={0.9}
|
|
||||||
metalness={0.1}
|
|
||||||
color="#b8b8b8"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#6b6b6b"
|
|
||||||
roughness={0.95}
|
|
||||||
metalness={0.05}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced Coma (gas/dust cloud) around comet nucleus
|
|
||||||
function CometComa({ radius }: { radius: number }) {
|
|
||||||
const positions = useMemo(() => {
|
|
||||||
const count = 300; // More particles for detail view
|
|
||||||
const p = new Float32Array(count * 3);
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
// Denser near center, spreading outward
|
|
||||||
const r = radius * (0.8 + Math.pow(Math.random(), 1.5) * 4); // 0.8x to 4.8x radius
|
|
||||||
const theta = Math.random() * Math.PI * 2;
|
|
||||||
const phi = Math.acos(2 * Math.random() - 1);
|
|
||||||
|
|
||||||
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
|
||||||
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
|
||||||
p[i * 3 + 2] = r * Math.cos(phi);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}, [radius]);
|
|
||||||
|
|
||||||
const pointsRef = useRef<THREE.Points>(null);
|
|
||||||
|
|
||||||
// Animate coma
|
|
||||||
useFrame((state, delta) => {
|
|
||||||
if (pointsRef.current) {
|
|
||||||
pointsRef.current.rotation.y += delta * 0.05;
|
|
||||||
// Pulsing effect
|
|
||||||
const scale = 1 + Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
|
|
||||||
pointsRef.current.scale.setScalar(scale);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Inner bright coma */}
|
|
||||||
<points ref={pointsRef}>
|
|
||||||
<bufferGeometry>
|
|
||||||
<bufferAttribute attach="position" args={[positions, 3]} />
|
|
||||||
</bufferGeometry>
|
|
||||||
<pointsMaterial
|
|
||||||
size={radius * 0.8}
|
|
||||||
color="#88ccff"
|
|
||||||
transparent
|
|
||||||
opacity={0.7}
|
|
||||||
sizeAttenuation={true}
|
|
||||||
blending={THREE.AdditiveBlending}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</points>
|
|
||||||
|
|
||||||
{/* Middle glow layer */}
|
|
||||||
<mesh>
|
|
||||||
<sphereGeometry args={[radius * 2.5, 32, 32]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color="#66aadd"
|
|
||||||
transparent
|
|
||||||
opacity={0.15}
|
|
||||||
side={THREE.BackSide}
|
|
||||||
blending={THREE.AdditiveBlending}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Outer diffuse glow */}
|
|
||||||
<mesh>
|
|
||||||
<sphereGeometry args={[radius * 4.5, 32, 32]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color="#4488aa"
|
|
||||||
transparent
|
|
||||||
opacity={0.12}
|
|
||||||
side={THREE.BackSide}
|
|
||||||
blending={THREE.AdditiveBlending}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Use irregular nucleus for comets, regular sphere for others */}
|
{/* Use irregular nucleus for comets, regular sphere for others */}
|
||||||
|
|
@ -440,6 +443,7 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
||||||
<sphereGeometry args={[size, 64, 64]} /> {/* Higher resolution for detail view */}
|
<sphereGeometry args={[size, 64, 64]} /> {/* Higher resolution for detail view */}
|
||||||
{texture ? (
|
{texture ? (
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
|
key="textured"
|
||||||
map={texture}
|
map={texture}
|
||||||
emissive={emissive}
|
emissive={emissive}
|
||||||
emissiveIntensity={emissiveIntensity}
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
|
@ -448,6 +452,7 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
|
key="plain"
|
||||||
color="#888888"
|
color="#888888"
|
||||||
emissive={emissive}
|
emissive={emissive}
|
||||||
emissiveIntensity={emissiveIntensity}
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* CameraController - handles camera movement and focus
|
* CameraController - handles camera movement and focus
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, type RefObject } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import { Vector3 } from 'three';
|
import { Vector3 } from 'three';
|
||||||
import type { CelestialBody } from '../types';
|
import type { CelestialBody } from '../types';
|
||||||
import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition';
|
import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition';
|
||||||
|
import { getCelestialSize } from '../config/celestialSizes';
|
||||||
|
|
||||||
|
interface OrbitControlsLike {
|
||||||
|
target: Vector3;
|
||||||
|
update: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CelestialTypeConfigs = Record<string, { ratio?: number; default?: number }> | null;
|
||||||
|
|
||||||
interface CameraControllerProps {
|
interface CameraControllerProps {
|
||||||
focusTarget: CelestialBody | null;
|
focusTarget: CelestialBody | null;
|
||||||
|
|
@ -13,6 +21,8 @@ interface CameraControllerProps {
|
||||||
onAnimationComplete?: () => void;
|
onAnimationComplete?: () => void;
|
||||||
resetTrigger?: number;
|
resetTrigger?: number;
|
||||||
defaultPosition?: [number, number, number];
|
defaultPosition?: [number, number, number];
|
||||||
|
typeConfigs?: CelestialTypeConfigs;
|
||||||
|
controlsRef: RefObject<OrbitControlsLike | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CameraController({
|
export function CameraController({
|
||||||
|
|
@ -20,83 +30,78 @@ export function CameraController({
|
||||||
allBodies,
|
allBodies,
|
||||||
onAnimationComplete,
|
onAnimationComplete,
|
||||||
resetTrigger = 0,
|
resetTrigger = 0,
|
||||||
defaultPosition = [10, 8, 10] // Default closer to inner solar system
|
defaultPosition = [10, 8, 10],
|
||||||
|
typeConfigs,
|
||||||
|
controlsRef,
|
||||||
}: CameraControllerProps) {
|
}: CameraControllerProps) {
|
||||||
const { camera } = useThree();
|
const { camera } = useThree();
|
||||||
const targetPosition = useRef(new Vector3());
|
const targetPosition = useRef(new Vector3());
|
||||||
|
const targetLookPosition = useRef(new Vector3());
|
||||||
const isAnimating = useRef(false);
|
const isAnimating = useRef(false);
|
||||||
const isResetting = useRef(false);
|
const isResetting = useRef(false);
|
||||||
const animationProgress = useRef(0);
|
const animationProgress = useRef(0);
|
||||||
const startPosition = useRef(new Vector3());
|
const startPosition = useRef(new Vector3());
|
||||||
|
const startLookPosition = useRef(new Vector3());
|
||||||
|
const trackedLookPosition = useRef(new Vector3());
|
||||||
const lastResetTrigger = useRef(0);
|
const lastResetTrigger = useRef(0);
|
||||||
|
|
||||||
|
// Keep the latest bodies list without making it a trigger for the focus effect.
|
||||||
|
// `allBodies` gets a new array reference on every timeline/historical frame, and we
|
||||||
|
// don't want that to re-fire the fly-to animation while the user is watching playback.
|
||||||
|
const allBodiesRef = useRef(allBodies);
|
||||||
|
allBodiesRef.current = allBodies;
|
||||||
|
|
||||||
// Handle manual reset trigger
|
// Handle manual reset trigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resetTrigger !== lastResetTrigger.current) {
|
if (resetTrigger !== lastResetTrigger.current) {
|
||||||
lastResetTrigger.current = resetTrigger;
|
lastResetTrigger.current = resetTrigger;
|
||||||
// Force reset
|
// Force reset
|
||||||
targetPosition.current.set(...defaultPosition);
|
targetPosition.current.set(...defaultPosition);
|
||||||
|
targetLookPosition.current.set(0, 0, 0);
|
||||||
startPosition.current.copy(camera.position);
|
startPosition.current.copy(camera.position);
|
||||||
|
startLookPosition.current.copy(controlsRef.current?.target ?? new Vector3());
|
||||||
isAnimating.current = true;
|
isAnimating.current = true;
|
||||||
isResetting.current = true;
|
isResetting.current = true;
|
||||||
animationProgress.current = 0;
|
animationProgress.current = 0;
|
||||||
}
|
}
|
||||||
}, [resetTrigger, camera, defaultPosition]); // Only run when resetTrigger changes
|
}, [resetTrigger, camera, defaultPosition, controlsRef]); // Only run when resetTrigger changes
|
||||||
|
|
||||||
// Handle focus target changes
|
// Handle focus target changes
|
||||||
|
// Trigger only on the SELECTED body changing (by id), not on every new bodies array.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const bodies = allBodiesRef.current;
|
||||||
if (focusTarget) {
|
if (focusTarget) {
|
||||||
isResetting.current = false; // Cancel reset if target selected
|
isResetting.current = false; // Cancel reset if target selected
|
||||||
|
|
||||||
// Focus on target - use smart rendered position
|
// Focus on target - use smart rendered position
|
||||||
const renderPos = calculateRenderPosition(focusTarget, allBodies);
|
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
|
||||||
|
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
|
||||||
const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y);
|
const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y);
|
||||||
|
const bodySize = getCelestialSize(latestBody, undefined, typeConfigs);
|
||||||
|
const parentInfo = findParentPlanet(latestBody, bodies);
|
||||||
|
const focusDistance = latestBody.type === 'star'
|
||||||
|
? Math.max(5, bodySize * 9)
|
||||||
|
: latestBody.type === 'planet'
|
||||||
|
? Math.max(4, bodySize * 7)
|
||||||
|
: latestBody.type === 'dwarf_planet'
|
||||||
|
? Math.max(2.4, bodySize * 8)
|
||||||
|
: parentInfo
|
||||||
|
? Math.max(1.4, bodySize * 8)
|
||||||
|
: Math.max(1.6, bodySize * 9);
|
||||||
|
const previousLook = controlsRef.current?.target ?? new Vector3();
|
||||||
|
const viewDirection = camera.position.clone().sub(previousLook);
|
||||||
|
if (viewDirection.lengthSq() < 0.0001) viewDirection.set(1, 0.65, 1);
|
||||||
|
viewDirection.normalize();
|
||||||
|
|
||||||
// Calculate ideal camera position based on target
|
targetLookPosition.current.copy(currentTargetPos);
|
||||||
const pos = focusTarget.positions[0];
|
targetPosition.current.copy(currentTargetPos)
|
||||||
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
.addScaledVector(viewDirection, focusDistance)
|
||||||
const parentInfo = findParentPlanet(focusTarget, allBodies);
|
.add(new Vector3(0, focusDistance * 0.12, 0));
|
||||||
|
|
||||||
let offset: number;
|
|
||||||
let heightMultiplier = 1;
|
|
||||||
let sideMultiplier = 1;
|
|
||||||
|
|
||||||
if (focusTarget.type === 'planet') {
|
|
||||||
offset = 4;
|
|
||||||
heightMultiplier = 1.5;
|
|
||||||
sideMultiplier = 1;
|
|
||||||
} else if (focusTarget.type === 'probe') {
|
|
||||||
if (parentInfo) {
|
|
||||||
offset = 3;
|
|
||||||
heightMultiplier = 0.8;
|
|
||||||
sideMultiplier = 1.2;
|
|
||||||
} else if (distance < 10) {
|
|
||||||
offset = 5;
|
|
||||||
heightMultiplier = 0.6;
|
|
||||||
sideMultiplier = 1.5;
|
|
||||||
} else if (distance > 50) {
|
|
||||||
offset = 4;
|
|
||||||
heightMultiplier = 0.8;
|
|
||||||
sideMultiplier = 1;
|
|
||||||
} else {
|
|
||||||
offset = 6;
|
|
||||||
heightMultiplier = 0.8;
|
|
||||||
sideMultiplier = 1.2;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
offset = 10;
|
|
||||||
heightMultiplier = 1;
|
|
||||||
sideMultiplier = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPosition.current.set(
|
|
||||||
currentTargetPos.x + (offset * sideMultiplier),
|
|
||||||
currentTargetPos.y + (offset * heightMultiplier),
|
|
||||||
currentTargetPos.z + offset
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start animation to target
|
// Start animation to target
|
||||||
startPosition.current.copy(camera.position);
|
startPosition.current.copy(camera.position);
|
||||||
|
startLookPosition.current.copy(previousLook);
|
||||||
|
trackedLookPosition.current.copy(currentTargetPos);
|
||||||
isAnimating.current = true;
|
isAnimating.current = true;
|
||||||
animationProgress.current = 0;
|
animationProgress.current = 0;
|
||||||
|
|
||||||
|
|
@ -108,9 +113,11 @@ export function CameraController({
|
||||||
animationProgress.current = 0;
|
animationProgress.current = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [focusTarget, allBodies, camera]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [focusTarget?.id, camera, controlsRef, typeConfigs]);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
const orbitControls = controlsRef.current;
|
||||||
if (isAnimating.current) {
|
if (isAnimating.current) {
|
||||||
// Smooth animation using easing
|
// Smooth animation using easing
|
||||||
animationProgress.current += delta * 0.8; // Animation speed
|
animationProgress.current += delta * 0.8; // Animation speed
|
||||||
|
|
@ -129,15 +136,24 @@ export function CameraController({
|
||||||
// Interpolate camera position
|
// Interpolate camera position
|
||||||
camera.position.lerpVectors(startPosition.current, targetPosition.current, eased);
|
camera.position.lerpVectors(startPosition.current, targetPosition.current, eased);
|
||||||
|
|
||||||
// Look at target - use smart rendered position (only during animation)
|
if (orbitControls) {
|
||||||
if (focusTarget) {
|
orbitControls.target.lerpVectors(startLookPosition.current, targetLookPosition.current, eased);
|
||||||
const renderPos = calculateRenderPosition(focusTarget, allBodies);
|
orbitControls.update();
|
||||||
camera.lookAt(renderPos.x, renderPos.z, renderPos.y);
|
}
|
||||||
} else {
|
camera.lookAt(targetLookPosition.current);
|
||||||
camera.lookAt(0, 0, 0);
|
} else if (focusTarget && orbitControls) {
|
||||||
|
const bodies = allBodiesRef.current;
|
||||||
|
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
|
||||||
|
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
|
||||||
|
const nextLook = new Vector3(renderPos.x, renderPos.z, renderPos.y);
|
||||||
|
const movement = nextLook.sub(trackedLookPosition.current);
|
||||||
|
if (movement.lengthSq() > 0.00000001) {
|
||||||
|
camera.position.add(movement);
|
||||||
|
orbitControls.target.add(movement);
|
||||||
|
trackedLookPosition.current.add(movement);
|
||||||
|
orbitControls.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// After animation completes, OrbitControls will take over
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,26 @@ import { useRef, useMemo, useState, useEffect } from 'react';
|
||||||
import { Mesh, DoubleSide } from 'three'; // Removed AdditiveBlending here
|
import { Mesh, DoubleSide } from 'three'; // Removed AdditiveBlending here
|
||||||
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
|
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
|
||||||
import { useFrame } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import { useTexture, Billboard } from '@react-three/drei';
|
import { Billboard } from '@react-three/drei';
|
||||||
import type { CelestialBody as CelestialBodyType } from '../types';
|
import type { CelestialBody as CelestialBodyType } from '../types';
|
||||||
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
||||||
import { fetchBodyResources } from '../utils/api';
|
import { fetchBodyResources } from '../utils/api';
|
||||||
import { getCelestialSize } from '../config/celestialSizes';
|
import { getCelestialSize } from '../config/celestialSizes';
|
||||||
import { createLabelTexture } from '../utils/labelTexture';
|
import { createLabelTexture } from '../utils/labelTexture';
|
||||||
|
import { useOptionalTexture } from '../hooks/useOptionalTexture';
|
||||||
|
|
||||||
interface CelestialBodyProps {
|
interface CelestialBodyProps {
|
||||||
body: CelestialBodyType;
|
body: CelestialBodyType;
|
||||||
allBodies: CelestialBodyType[];
|
allBodies: CelestialBodyType[];
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
onBodySelect?: (body: CelestialBodyType) => void;
|
onBodySelect?: (body: CelestialBodyType) => void;
|
||||||
typeConfigs?: any;
|
typeConfigs?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Planetary Rings component - handles texture-based rings
|
// Planetary Rings component - handles texture-based rings
|
||||||
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
||||||
const texture = texturePath ? useTexture(texturePath) : null;
|
const texture = useOptionalTexture(texturePath);
|
||||||
const meshRef = useRef<Mesh>(null);
|
const meshRef = useRef<Mesh>(null);
|
||||||
|
|
||||||
// Dynamic ring dimensions based on planet size
|
// Dynamic ring dimensions based on planet size
|
||||||
|
|
@ -87,13 +89,14 @@ function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string |
|
||||||
}
|
}
|
||||||
|
|
||||||
// Planet component with texture
|
// Planet component with texture
|
||||||
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false, onBodySelect, typeConfigs }: {
|
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: {
|
||||||
body: CelestialBodyType;
|
body: CelestialBodyType;
|
||||||
size: number;
|
size: number;
|
||||||
emissive: string;
|
emissive: string;
|
||||||
emissiveIntensity: number;
|
emissiveIntensity: number;
|
||||||
allBodies: CelestialBodyType[];
|
allBodies: CelestialBodyType[];
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
onBodySelect?: (body: CelestialBodyType) => void;
|
onBodySelect?: (body: CelestialBodyType) => void;
|
||||||
typeConfigs?: any;
|
typeConfigs?: any;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -164,6 +167,7 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
|
||||||
hasOffset={renderPosition.hasOffset}
|
hasOffset={renderPosition.hasOffset}
|
||||||
allBodies={allBodies}
|
allBodies={allBodies}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
showLabel={showLabel}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={onBodySelect}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +219,7 @@ function IrregularNucleus({ size, texture, onClick }: { size: number; texture: T
|
||||||
>
|
>
|
||||||
{texture ? (
|
{texture ? (
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
|
key="textured"
|
||||||
map={texture}
|
map={texture}
|
||||||
roughness={0.9}
|
roughness={0.9}
|
||||||
metalness={0.1}
|
metalness={0.1}
|
||||||
|
|
@ -222,6 +227,7 @@ function IrregularNucleus({ size, texture, onClick }: { size: number; texture: T
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
|
key="plain"
|
||||||
color="#6b6b6b"
|
color="#6b6b6b"
|
||||||
roughness={0.95}
|
roughness={0.95}
|
||||||
metalness={0.05}
|
metalness={0.05}
|
||||||
|
|
@ -309,7 +315,7 @@ function CometComa({ radius }: { radius: number }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate component to handle texture loading
|
// Separate component to handle texture loading
|
||||||
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, ringTexturePath, position, meshRef, hasOffset, allBodies, isSelected = false, onBodySelect }: {
|
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, ringTexturePath, position, meshRef, hasOffset, allBodies, isSelected = false, showLabel = true, onBodySelect }: {
|
||||||
body: CelestialBodyType;
|
body: CelestialBodyType;
|
||||||
size: number;
|
size: number;
|
||||||
emissive: string;
|
emissive: string;
|
||||||
|
|
@ -318,14 +324,14 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
||||||
texturePath: string | null;
|
texturePath: string | null;
|
||||||
ringTexturePath?: string | null;
|
ringTexturePath?: string | null;
|
||||||
position: { x: number; y: number; z: number };
|
position: { x: number; y: number; z: number };
|
||||||
meshRef: React.RefObject<Mesh>;
|
meshRef: React.RefObject<Mesh | null>;
|
||||||
hasOffset: boolean;
|
hasOffset: boolean;
|
||||||
allBodies: CelestialBodyType[];
|
allBodies: CelestialBodyType[];
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
onBodySelect?: (body: CelestialBodyType) => void;
|
onBodySelect?: (body: CelestialBodyType) => void;
|
||||||
}) {
|
}) {
|
||||||
// Load texture if path is provided
|
const texture = useOptionalTexture(texturePath);
|
||||||
const texture = texturePath ? useTexture(texturePath) : null;
|
|
||||||
|
|
||||||
// Slow rotation for visual effect
|
// Slow rotation for visual effect
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
|
@ -380,6 +386,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
||||||
<sphereGeometry args={[size, 32, 32]} />
|
<sphereGeometry args={[size, 32, 32]} />
|
||||||
{texture ? (
|
{texture ? (
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
|
key="textured"
|
||||||
map={texture}
|
map={texture}
|
||||||
emissive={emissive}
|
emissive={emissive}
|
||||||
emissiveIntensity={emissiveIntensity}
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
|
@ -390,6 +397,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
|
key="plain"
|
||||||
color="#888888"
|
color="#888888"
|
||||||
emissive={emissive}
|
emissive={emissive}
|
||||||
emissiveIntensity={emissiveIntensity}
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
|
@ -463,7 +471,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name label using CanvasTexture */}
|
{/* Name label using CanvasTexture */}
|
||||||
{labelTexture && (
|
{showLabel && labelTexture && (
|
||||||
<Billboard
|
<Billboard
|
||||||
position={[0, size + 0.8, 0]} // Slightly closer to body
|
position={[0, size + 0.8, 0]} // Slightly closer to body
|
||||||
follow={true}
|
follow={true}
|
||||||
|
|
@ -487,18 +495,13 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CelestialBody({ body, allBodies, isSelected = false, onBodySelect, typeConfigs }: CelestialBodyProps) {
|
export function CelestialBody({ body, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: CelestialBodyProps) {
|
||||||
// Get the current position (use the first position for now)
|
// Get the current position (use the first position for now)
|
||||||
const position = body.positions[0];
|
const position = body.positions[0];
|
||||||
|
|
||||||
if (!position) return null;
|
|
||||||
|
|
||||||
// Skip probes - they will use 3D models
|
|
||||||
if (body.type === 'probe') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine size based on body type
|
// Determine size based on body type
|
||||||
|
// NOTE: this hook must run before any early return to keep hook order stable
|
||||||
|
// across re-renders (e.g. when positions transiently empty during a refetch).
|
||||||
const appearance = useMemo(() => {
|
const appearance = useMemo(() => {
|
||||||
if (body.type === 'star') {
|
if (body.type === 'star') {
|
||||||
return {
|
return {
|
||||||
|
|
@ -534,6 +537,13 @@ export function CelestialBody({ body, allBodies, isSelected = false, onBodySelec
|
||||||
};
|
};
|
||||||
}, [body, typeConfigs]);
|
}, [body, typeConfigs]);
|
||||||
|
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
|
// Skip probes - they will use 3D models
|
||||||
|
if (body.type === 'probe') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Planet
|
<Planet
|
||||||
body={body}
|
body={body}
|
||||||
|
|
@ -542,6 +552,7 @@ export function CelestialBody({ body, allBodies, isSelected = false, onBodySelec
|
||||||
emissiveIntensity={appearance.emissiveIntensity}
|
emissiveIntensity={appearance.emissiveIntensity}
|
||||||
allBodies={allBodies}
|
allBodies={allBodies}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
showLabel={showLabel}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={onBodySelect}
|
||||||
typeConfigs={typeConfigs}
|
typeConfigs={typeConfigs}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Orbit,
|
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
|
@ -9,6 +8,7 @@ import {
|
||||||
Camera,
|
Camera,
|
||||||
Globe,
|
Globe,
|
||||||
Sparkles
|
Sparkles
|
||||||
|
,Rocket
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ControlPanelProps {
|
interface ControlPanelProps {
|
||||||
|
|
@ -23,6 +23,7 @@ interface ControlPanelProps {
|
||||||
onScreenshot: () => void;
|
onScreenshot: () => void;
|
||||||
viewMode: 'solar' | 'galaxy';
|
viewMode: 'solar' | 'galaxy';
|
||||||
onToggleViewMode: () => void;
|
onToggleViewMode: () => void;
|
||||||
|
onOpenRocketSimulator: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ControlPanel({
|
export function ControlPanel({
|
||||||
|
|
@ -37,6 +38,7 @@ export function ControlPanel({
|
||||||
onScreenshot,
|
onScreenshot,
|
||||||
viewMode,
|
viewMode,
|
||||||
onToggleViewMode,
|
onToggleViewMode,
|
||||||
|
onOpenRocketSimulator,
|
||||||
}: ControlPanelProps) {
|
}: ControlPanelProps) {
|
||||||
const buttonClass = (isActive: boolean) => `
|
const buttonClass = (isActive: boolean) => `
|
||||||
p-2 rounded-lg transition-all duration-200 relative group
|
p-2 rounded-lg transition-all duration-200 relative group
|
||||||
|
|
@ -50,6 +52,15 @@ export function ControlPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-24 right-6 z-40 flex flex-col gap-3 items-end">
|
<div className="absolute top-24 right-6 z-40 flex flex-col gap-3 items-end">
|
||||||
|
<button
|
||||||
|
onClick={onOpenRocketSimulator}
|
||||||
|
className="p-2 rounded-lg bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 border border-amber-400/30 transition-all duration-200 relative group"
|
||||||
|
aria-label="打开火箭发射模拟"
|
||||||
|
>
|
||||||
|
<Rocket size={20} />
|
||||||
|
<div className={tooltipClass}>火箭发射模拟</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* View Mode Toggle */}
|
{/* View Mode Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleViewMode}
|
onClick={onToggleViewMode}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Remove fixed positioning, now handled by parent container (Html component in 3D)
|
|
||||||
<div className="flex flex-col items-center -translate-y-24 pointer-events-none">
|
<div className="flex flex-col items-center -translate-y-24 pointer-events-none">
|
||||||
<style>{styles}</style>
|
<style>{styles}</style>
|
||||||
{/* Main Info Card */}
|
{/* Main Info Card */}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { UserAuth } from './UserAuth';
|
import { UserAuth } from './UserAuth';
|
||||||
|
import type { AuthUser } from '../utils/auth';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
selectedBodyName?: string;
|
selectedBodyName?: string;
|
||||||
bodyCount: number;
|
bodyCount: number;
|
||||||
cutoffDate?: Date | null;
|
cutoffDate?: Date | null;
|
||||||
user?: any;
|
user?: AuthUser | null;
|
||||||
onOpenAuth?: () => void;
|
onOpenAuth?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
onNavigateToAdmin?: () => void;
|
onNavigateToAdmin?: () => void;
|
||||||
|
|
@ -72,4 +73,4 @@ export function Header({
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,18 @@ interface ProbeProps {
|
||||||
body: CelestialBody;
|
body: CelestialBody;
|
||||||
allBodies: CelestialBody[];
|
allBodies: CelestialBody[];
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
onBodySelect?: (body: CelestialBody) => void;
|
onBodySelect?: (body: CelestialBody) => void;
|
||||||
typeConfigs?: any;
|
typeConfigs?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate component for each probe type to properly use hooks
|
// Separate component for each probe type to properly use hooks
|
||||||
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0, onBodySelect, typeConfigs }: {
|
function ProbeModel({ body, modelPath, allBodies, isSelected = false, showLabel = true, resourceScale = 1.0, onBodySelect, typeConfigs }: {
|
||||||
body: CelestialBody;
|
body: CelestialBody;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
allBodies: CelestialBody[];
|
allBodies: CelestialBody[];
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onError: () => void;
|
showLabel?: boolean;
|
||||||
resourceScale?: number;
|
resourceScale?: number;
|
||||||
onBodySelect?: (body: CelestialBody) => void;
|
onBodySelect?: (body: CelestialBody) => void;
|
||||||
typeConfigs?: any;
|
typeConfigs?: any;
|
||||||
|
|
@ -111,25 +112,13 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render Logic
|
|
||||||
if (!scene || !configuredScene) return null;
|
|
||||||
|
|
||||||
// Calculate ACTUAL distance from Sun (not scaled)
|
// Calculate ACTUAL distance from Sun (not scaled)
|
||||||
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||||
|
|
||||||
// Get offset description if this probe has one
|
// Get offset description if this probe has one
|
||||||
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
||||||
|
|
||||||
// Handle click event
|
// 6. Hook: Label texture (must be called before any early return to keep hook order stable)
|
||||||
const handleClick = (e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onBodySelect) {
|
|
||||||
onBodySelect(body);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate label texture
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
const labelTexture = useMemo(() => {
|
const labelTexture = useMemo(() => {
|
||||||
return createLabelTexture(
|
return createLabelTexture(
|
||||||
body.name_zh || body.name,
|
body.name_zh || body.name,
|
||||||
|
|
@ -139,6 +128,17 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
||||||
);
|
);
|
||||||
}, [body.name, body.name_zh, offsetDesc, distance]);
|
}, [body.name, body.name_zh, offsetDesc, distance]);
|
||||||
|
|
||||||
|
// Render Logic
|
||||||
|
if (!scene || !configuredScene) return null;
|
||||||
|
|
||||||
|
// Handle click event
|
||||||
|
const handleClick = (e: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onBodySelect) {
|
||||||
|
onBodySelect(body);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
|
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
|
||||||
|
|
@ -153,7 +153,7 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name label with CanvasTexture */}
|
{/* Name label with CanvasTexture */}
|
||||||
{labelTexture && (
|
{showLabel && labelTexture && (
|
||||||
<Billboard
|
<Billboard
|
||||||
position={[0, optimalScale * 2.5, 0]}
|
position={[0, optimalScale * 2.5, 0]}
|
||||||
follow={true}
|
follow={true}
|
||||||
|
|
@ -178,10 +178,11 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback component when model is not available
|
// Fallback component when model is not available
|
||||||
function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect, typeConfigs }: {
|
function ProbeFallback({ body, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: {
|
||||||
body: CelestialBody;
|
body: CelestialBody;
|
||||||
allBodies: CelestialBody[];
|
allBodies: CelestialBody[];
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
onBodySelect?: (body: CelestialBody) => void;
|
onBodySelect?: (body: CelestialBody) => void;
|
||||||
typeConfigs?: any;
|
typeConfigs?: any;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -231,7 +232,7 @@ function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect, type
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Name label with CanvasTexture */}
|
{/* Name label with CanvasTexture */}
|
||||||
{labelTexture && (
|
{showLabel && labelTexture && (
|
||||||
<Billboard
|
<Billboard
|
||||||
position={[0, 1, 0]}
|
position={[0, 1, 0]}
|
||||||
follow={true}
|
follow={true}
|
||||||
|
|
@ -255,7 +256,7 @@ function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect, type
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeConfigs }: ProbeProps) {
|
export function Probe({ body, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: ProbeProps) {
|
||||||
const position = body.positions[0];
|
const position = body.positions[0];
|
||||||
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
|
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
|
||||||
const [loadError, setLoadError] = useState<boolean>(false);
|
const [loadError, setLoadError] = useState<boolean>(false);
|
||||||
|
|
@ -312,11 +313,9 @@ export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeC
|
||||||
modelPath={modelPath}
|
modelPath={modelPath}
|
||||||
allBodies={allBodies}
|
allBodies={allBodies}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
showLabel={showLabel}
|
||||||
resourceScale={resourceScale}
|
resourceScale={resourceScale}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={onBodySelect}
|
||||||
onError={() => {
|
|
||||||
setLoadError(true);
|
|
||||||
}}
|
|
||||||
typeConfigs={typeConfigs}
|
typeConfigs={typeConfigs}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +324,7 @@ export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeC
|
||||||
body={body}
|
body={body}
|
||||||
allBodies={allBodies}
|
allBodies={allBodies}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
showLabel={showLabel}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={onBodySelect}
|
||||||
typeConfigs={typeConfigs}
|
typeConfigs={typeConfigs}
|
||||||
/>;
|
/>;
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,6 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
// Auto-expand the group when a body is selected from the 3D scene
|
// Auto-expand the group when a body is selected from the 3D scene
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedBody) {
|
if (selectedBody) {
|
||||||
// Don't auto-collapse panel if it's already open
|
|
||||||
// Only auto-collapse if panel was already collapsed
|
|
||||||
// This allows users to keep the panel open while browsing bodies
|
|
||||||
|
|
||||||
// Auto-expand the group that contains the selected body
|
// Auto-expand the group that contains the selected body
|
||||||
setExpandedGroup(selectedBody.type);
|
setExpandedGroup(selectedBody.type);
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +75,10 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
setExpandedGroup(prev => prev === groupName ? null : groupName);
|
setExpandedGroup(prev => prev === groupName ? null : groupName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBodySelect = (body: CelestialBody) => {
|
||||||
|
onBodySelect(body);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
|
|
@ -161,7 +161,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
isExpanded={expandedGroup === 'star'}
|
isExpanded={expandedGroup === 'star'}
|
||||||
onToggle={() => toggleGroup('star')}
|
onToggle={() => toggleGroup('star')}
|
||||||
selectedBody={selectedBody}
|
selectedBody={selectedBody}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={handleBodySelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -175,7 +175,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
isExpanded={expandedGroup === 'planet'}
|
isExpanded={expandedGroup === 'planet'}
|
||||||
onToggle={() => toggleGroup('planet')}
|
onToggle={() => toggleGroup('planet')}
|
||||||
selectedBody={selectedBody}
|
selectedBody={selectedBody}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={handleBodySelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -189,7 +189,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
isExpanded={expandedGroup === 'dwarf_planet'}
|
isExpanded={expandedGroup === 'dwarf_planet'}
|
||||||
onToggle={() => toggleGroup('dwarf_planet')}
|
onToggle={() => toggleGroup('dwarf_planet')}
|
||||||
selectedBody={selectedBody}
|
selectedBody={selectedBody}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={handleBodySelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
isExpanded={expandedGroup === 'satellite'}
|
isExpanded={expandedGroup === 'satellite'}
|
||||||
onToggle={() => toggleGroup('satellite')}
|
onToggle={() => toggleGroup('satellite')}
|
||||||
selectedBody={selectedBody}
|
selectedBody={selectedBody}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={handleBodySelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -217,7 +217,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
isExpanded={expandedGroup === 'comet'}
|
isExpanded={expandedGroup === 'comet'}
|
||||||
onToggle={() => toggleGroup('comet')}
|
onToggle={() => toggleGroup('comet')}
|
||||||
selectedBody={selectedBody}
|
selectedBody={selectedBody}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={handleBodySelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -231,7 +231,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
isExpanded={expandedGroup === 'probe'}
|
isExpanded={expandedGroup === 'probe'}
|
||||||
onToggle={() => toggleGroup('probe')}
|
onToggle={() => toggleGroup('probe')}
|
||||||
selectedBody={selectedBody}
|
selectedBody={selectedBody}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={handleBodySelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
*/
|
*/
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { OrbitControls, Stars as BackgroundStars, Html } from '@react-three/drei';
|
import { OrbitControls, Stars as BackgroundStars, Html } from '@react-three/drei';
|
||||||
import { useMemo, useState, useEffect } from 'react';
|
import * as THREE from 'three';
|
||||||
|
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
|
||||||
import { CelestialBody } from './CelestialBody';
|
import { CelestialBody } from './CelestialBody';
|
||||||
import { Probe } from './Probe';
|
import { Probe } from './Probe';
|
||||||
import { CameraController } from './CameraController';
|
import { CameraController } from './CameraController';
|
||||||
|
|
@ -15,7 +17,6 @@ import { Galaxies } from './Galaxies';
|
||||||
import { Nebulae } from './Nebulae';
|
import { Nebulae } from './Nebulae';
|
||||||
import { FocusInfo } from './FocusInfo';
|
import { FocusInfo } from './FocusInfo';
|
||||||
import { AsteroidBelts } from './AsteroidBelts';
|
import { AsteroidBelts } from './AsteroidBelts';
|
||||||
import { scalePosition } from '../utils/scaleDistance';
|
|
||||||
import { calculateRenderPosition } from '../utils/renderPosition';
|
import { calculateRenderPosition } from '../utils/renderPosition';
|
||||||
import type { CelestialBody as CelestialBodyType, Position } from '../types';
|
import type { CelestialBody as CelestialBodyType, Position } from '../types';
|
||||||
import type { ToastContextValue } from '../contexts/ToastContext'; // Import ToastContextValue
|
import type { ToastContextValue } from '../contexts/ToastContext'; // Import ToastContextValue
|
||||||
|
|
@ -35,6 +36,29 @@ interface SceneProps {
|
||||||
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0, toast, onViewDetails }: SceneProps) {
|
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0, toast, onViewDetails }: SceneProps) {
|
||||||
const [typeConfigs] = useSystemSetting('celestial_type_configs', null);
|
const [typeConfigs] = useSystemSetting('celestial_type_configs', null);
|
||||||
const [rawCameraPos] = useSystemSetting('default_camera_position', [10, 8, 10]);
|
const [rawCameraPos] = useSystemSetting('default_camera_position', [10, 8, 10]);
|
||||||
|
const [rendererGeneration, setRendererGeneration] = useState(0);
|
||||||
|
const recoveryAttemptedRef = useRef(false);
|
||||||
|
const contextListenerCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const orbitControlsRef = useRef<OrbitControlsImpl>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => contextListenerCleanupRef.current?.(), []);
|
||||||
|
|
||||||
|
const handleCanvasCreated = useCallback(({ gl, camera }: { gl: THREE.WebGLRenderer; camera: THREE.Camera }) => {
|
||||||
|
gl.sortObjects = true;
|
||||||
|
gl.setClearColor(0x000000, 1);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
contextListenerCleanupRef.current?.();
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
const handleContextLost = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (recoveryAttemptedRef.current) return;
|
||||||
|
recoveryAttemptedRef.current = true;
|
||||||
|
setRendererGeneration((generation) => generation + 1);
|
||||||
|
};
|
||||||
|
canvas.addEventListener('webglcontextlost', handleContextLost, { once: true });
|
||||||
|
contextListenerCleanupRef.current = () => canvas.removeEventListener('webglcontextlost', handleContextLost);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Parse camera position if it's a string (from system settings)
|
// Parse camera position if it's a string (from system settings)
|
||||||
const defaultCameraPos = useMemo(() => {
|
const defaultCameraPos = useMemo(() => {
|
||||||
|
|
@ -85,31 +109,21 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
||||||
// Always show all probes (changed from previous behavior)
|
// Always show all probes (changed from previous behavior)
|
||||||
const visibleProbes = probes;
|
const visibleProbes = probes;
|
||||||
|
|
||||||
// Calculate target position for OrbitControls
|
const currentSelectedBody = useMemo(
|
||||||
const controlsTarget = useMemo(() => {
|
() => selectedBody ? bodies.find((body) => body.id === selectedBody.id) ?? selectedBody : null,
|
||||||
if (selectedBody) {
|
[bodies, selectedBody],
|
||||||
const pos = selectedBody.positions[0];
|
);
|
||||||
const scaledPos = scalePosition(pos.x, pos.y, pos.z);
|
|
||||||
return [scaledPos.x, scaledPos.z, scaledPos.y] as [number, number, number];
|
|
||||||
}
|
|
||||||
return [0, 0, 0] as [number, number, number];
|
|
||||||
}, [selectedBody]);
|
|
||||||
|
|
||||||
// Calculate position for FocusInfo (needs to match rendered position of body)
|
|
||||||
const focusInfoPosition = useMemo(() => {
|
const focusInfoPosition = useMemo(() => {
|
||||||
if (!selectedBody) return [0, 0, 0] as [number, number, number];
|
if (!currentSelectedBody) return [0, 0, 0] as [number, number, number];
|
||||||
|
const position = calculateRenderPosition(currentSelectedBody, bodies, typeConfigs);
|
||||||
// We need to use the EXACT same logic as CelestialBody/Probe components
|
return [position.x, position.z, position.y] as [number, number, number];
|
||||||
// to ensure the label sticks to the object
|
}, [bodies, currentSelectedBody, typeConfigs]);
|
||||||
const renderPos = calculateRenderPosition(selectedBody, bodies, typeConfigs);
|
|
||||||
|
|
||||||
// Convert to Three.js coordinates (x, z, y)
|
|
||||||
return [renderPos.x, renderPos.z, renderPos.y] as [number, number, number];
|
|
||||||
}, [selectedBody, bodies, typeConfigs]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="cosmo-scene-container" className="w-full h-full bg-black">
|
<div id="cosmo-scene-container" className="w-full h-full bg-black">
|
||||||
<Canvas
|
<Canvas
|
||||||
|
key={rendererGeneration}
|
||||||
|
style={{ background: '#000000' }}
|
||||||
camera={{
|
camera={{
|
||||||
position: defaultCameraPos, // Dynamic default position
|
position: defaultCameraPos, // Dynamic default position
|
||||||
fov: 60, // Slightly narrower FOV for less distortion
|
fov: 60, // Slightly narrower FOV for less distortion
|
||||||
|
|
@ -120,19 +134,8 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
||||||
antialias: true,
|
antialias: true,
|
||||||
preserveDrawingBuffer: true, // Required for screenshots
|
preserveDrawingBuffer: true, // Required for screenshots
|
||||||
}}
|
}}
|
||||||
onCreated={({ gl, camera }) => {
|
onCreated={handleCanvasCreated}
|
||||||
gl.sortObjects = true; // Enable object sorting by renderOrder
|
|
||||||
camera.lookAt(0, 0, 0); // Look at the Sun (center)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Camera controller for smooth transitions */}
|
|
||||||
<CameraController
|
|
||||||
focusTarget={selectedBody}
|
|
||||||
allBodies={bodies}
|
|
||||||
resetTrigger={resetTrigger}
|
|
||||||
defaultPosition={defaultCameraPos}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Increase ambient light to see textures better */}
|
{/* Increase ambient light to see textures better */}
|
||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.5} />
|
||||||
|
|
||||||
|
|
@ -171,6 +174,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
||||||
body={body}
|
body={body}
|
||||||
allBodies={bodies}
|
allBodies={bodies}
|
||||||
isSelected={selectedBody?.id === body.id}
|
isSelected={selectedBody?.id === body.id}
|
||||||
|
showLabel={!selectedBody || selectedBody.id === body.id}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={onBodySelect}
|
||||||
typeConfigs={typeConfigs}
|
typeConfigs={typeConfigs}
|
||||||
/>
|
/>
|
||||||
|
|
@ -186,6 +190,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
||||||
body={body}
|
body={body}
|
||||||
allBodies={bodies}
|
allBodies={bodies}
|
||||||
isSelected={selectedBody?.id === body.id}
|
isSelected={selectedBody?.id === body.id}
|
||||||
|
showLabel={!selectedBody || selectedBody.id === body.id}
|
||||||
onBodySelect={onBodySelect}
|
onBodySelect={onBodySelect}
|
||||||
typeConfigs={typeConfigs}
|
typeConfigs={typeConfigs}
|
||||||
/>
|
/>
|
||||||
|
|
@ -202,23 +207,36 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
||||||
|
|
||||||
{/* Camera controls */}
|
{/* Camera controls */}
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
|
ref={orbitControlsRef}
|
||||||
|
makeDefault
|
||||||
enablePan={true}
|
enablePan={true}
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
enableRotate={true}
|
enableRotate={true}
|
||||||
minDistance={2}
|
enableDamping={true}
|
||||||
|
dampingFactor={0.08}
|
||||||
|
minDistance={0.5}
|
||||||
maxDistance={500}
|
maxDistance={500}
|
||||||
target={controlsTarget}
|
|
||||||
enabled={true} // Always enabled
|
enabled={true} // Always enabled
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Run after OrbitControls so focus interpolation owns the final camera transform. */}
|
||||||
|
<CameraController
|
||||||
|
focusTarget={selectedBody}
|
||||||
|
allBodies={bodies}
|
||||||
|
resetTrigger={resetTrigger}
|
||||||
|
defaultPosition={defaultCameraPos}
|
||||||
|
typeConfigs={typeConfigs}
|
||||||
|
controlsRef={orbitControlsRef}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dynamic Focus Info Label */}
|
{/* Dynamic Focus Info Label */}
|
||||||
{selectedBody && showInfoPanel && (
|
{currentSelectedBody && showInfoPanel && (
|
||||||
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
|
<Html position={focusInfoPosition} center zIndexRange={[30, 0]} style={{ pointerEvents: 'none' }}>
|
||||||
<FocusInfo
|
<FocusInfo
|
||||||
body={selectedBody}
|
body={currentSelectedBody}
|
||||||
onClose={() => setShowInfoPanel(false)}
|
onClose={() => setShowInfoPanel(false)}
|
||||||
toast={toast}
|
toast={toast}
|
||||||
onViewDetails={onViewDetails}
|
onViewDetails={onViewDetails}
|
||||||
/>
|
/>
|
||||||
</Html>
|
</Html>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* TimelineController - controls time for viewing historical positions
|
* TimelineController - controls time for viewing historical positions
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { Play, Pause, RotateCcw, FastForward, CalendarClock } from 'lucide-react';
|
import { Play, Pause, RotateCcw, FastForward, CalendarClock } from 'lucide-react';
|
||||||
|
|
||||||
export interface TimelineState {
|
export interface TimelineState {
|
||||||
|
|
@ -19,14 +19,19 @@ interface TimelineControllerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimelineController({ onTimeChange, minDate, maxDate }: TimelineControllerProps) {
|
export function TimelineController({ onTimeChange, minDate, maxDate }: TimelineControllerProps) {
|
||||||
// Swap: startDate is now (maxDate), endDate is past (minDate)
|
// Swap: startDate is now (maxDate), endDate is past (minDate).
|
||||||
const startDate = maxDate || new Date(); // Start from now
|
// Reduce to primitive timestamps so identity is stable across parent re-renders
|
||||||
const endDate = minDate || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // End at past
|
// (minDate/maxDate are frequently passed as fresh `new Date(...)` objects).
|
||||||
|
const startTime = maxDate ? maxDate.getTime() : Date.now();
|
||||||
|
const endTime = minDate ? minDate.getTime() : Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const [currentDate, setCurrentDate] = useState<Date>(startDate); // Start from now
|
const startDate = useMemo(() => new Date(startTime), [startTime]);
|
||||||
|
const endDate = useMemo(() => new Date(endTime), [endTime]);
|
||||||
|
|
||||||
|
const [currentDate, setCurrentDate] = useState<Date>(() => new Date(startTime)); // Start from now
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [speed, setSpeed] = useState(1); // 1 day per second
|
const [speed, setSpeed] = useState(1); // 1 day per second
|
||||||
const animationFrameRef = useRef<number>();
|
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||||
const lastUpdateRef = useRef<number>(Date.now());
|
const lastUpdateRef = useRef<number>(Date.now());
|
||||||
|
|
||||||
// Animation loop
|
// Animation loop
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react';
|
import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react';
|
||||||
import { API_BASE_URL } from '../utils/request';
|
import type { AuthUser } from '../utils/auth';
|
||||||
|
|
||||||
interface UserAuthProps {
|
interface UserAuthProps {
|
||||||
user: any;
|
user: AuthUser | null | undefined;
|
||||||
onOpenAuth: () => void;
|
onOpenAuth: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onNavigateToAdmin: () => void;
|
onNavigateToAdmin: () => void;
|
||||||
|
|
@ -26,7 +26,7 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
|
||||||
|
|
||||||
// Helper to get full avatar URL
|
// Helper to get full avatar URL
|
||||||
const getAvatarUrl = () => {
|
const getAvatarUrl = () => {
|
||||||
if (!user?.avatar_url) return null;
|
if (!user?.avatar_url) return undefined;
|
||||||
return `/upload/${user.avatar_url}`;
|
return `/upload/${user.avatar_url}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -77,8 +77,7 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Open admin in new tab
|
onNavigateToAdmin();
|
||||||
window.open('/admin', '_blank');
|
|
||||||
setShowUserMenu(false);
|
setShowUserMenu(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2.5 text-sm text-gray-200 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors rounded-lg"
|
className="w-full text-left px-3 py-2.5 text-sm text-gray-200 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors rounded-lg"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Table, Input, Button, Space, Popconfirm, Switch, Card, Tooltip } from 'antd';
|
import { Table, Input, Button, Space, Popconfirm, Switch, Card, Tooltip } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface DataTableProps<T> {
|
interface DataTableProps<T> {
|
||||||
|
|
@ -14,14 +13,18 @@ interface DataTableProps<T> {
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
onPageChange?: (page: number, pageSize: number) => void;
|
onPageChange?: (page: number, pageSize: number) => void;
|
||||||
onSearch?: (keyword: string) => void;
|
onSearch?: (keyword: string) => void;
|
||||||
|
searchPlaceholder?: string;
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
|
showAdd?: boolean;
|
||||||
onEdit?: (record: T) => void;
|
onEdit?: (record: T) => void;
|
||||||
|
showEdit?: boolean;
|
||||||
onDelete?: (record: T) => void;
|
onDelete?: (record: T) => void;
|
||||||
onStatusChange?: (record: T, checked: boolean) => void;
|
onStatusChange?: (record: T, checked: boolean) => void;
|
||||||
statusField?: keyof T; // Field name for the status switch (e.g., 'is_active')
|
statusField?: keyof T; // Field name for the status switch (e.g., 'is_active')
|
||||||
rowKey?: string;
|
rowKey?: string;
|
||||||
// Custom actions to be added before edit/delete buttons
|
// Custom actions to be added before edit/delete buttons
|
||||||
customActions?: (record: T) => ReactNode;
|
customActions?: (record: T) => ReactNode;
|
||||||
|
scroll?: TableProps<T>['scroll'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<T extends object>({
|
export function DataTable<T extends object>({
|
||||||
|
|
@ -34,16 +37,18 @@ export function DataTable<T extends object>({
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
searchPlaceholder = '搜索...',
|
||||||
onAdd,
|
onAdd,
|
||||||
|
showAdd = true,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
showEdit = true,
|
||||||
onDelete,
|
onDelete,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
statusField = 'is_active' as keyof T,
|
statusField = 'is_active' as keyof T,
|
||||||
rowKey = 'id',
|
rowKey = 'id',
|
||||||
customActions,
|
customActions,
|
||||||
|
scroll = { x: 'max-content' },
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
|
|
||||||
// Inject action columns if callbacks are provided
|
// Inject action columns if callbacks are provided
|
||||||
const tableColumns: ColumnsType<T> = [
|
const tableColumns: ColumnsType<T> = [
|
||||||
...columns,
|
...columns,
|
||||||
|
|
@ -80,7 +85,7 @@ export function DataTable<T extends object>({
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
{customActions && customActions(record)}
|
{customActions && customActions(record)}
|
||||||
{onEdit && (
|
{onEdit && showEdit && (
|
||||||
<Tooltip title="编辑">
|
<Tooltip title="编辑">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -122,17 +127,16 @@ export function DataTable<T extends object>({
|
||||||
<Space>
|
<Space>
|
||||||
{onSearch && (
|
{onSearch && (
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="搜索..."
|
placeholder={searchPlaceholder}
|
||||||
allowClear
|
allowClear
|
||||||
onSearch={onSearch}
|
onSearch={onSearch}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setKeyword(e.target.value);
|
if (!e.target.value) onSearch('');
|
||||||
if (!e.target.value) onSearch(''); // Auto search on clear
|
|
||||||
}}
|
}}
|
||||||
style={{ width: 250 }}
|
style={{ width: 250 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onAdd && (
|
{onAdd && showAdd && (
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||||
新增
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -162,7 +166,7 @@ export function DataTable<T extends object>({
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={scroll}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
|
import { OrbitControls, Stars } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
|
||||||
|
import type { RocketConfig, SimulationState } from './types';
|
||||||
|
import { useOptionalTexture } from '../../hooks/useOptionalTexture';
|
||||||
|
import { RocketModel } from './RocketModel';
|
||||||
|
import { clamp, fogColor, groundDrop, separationAge, skyColor, starOpacity } from './sceneMath';
|
||||||
|
|
||||||
|
export type CameraMode = 'follow' | 'global';
|
||||||
|
|
||||||
|
interface RocketFlightSceneProps {
|
||||||
|
rocket: RocketConfig;
|
||||||
|
state: SimulationState;
|
||||||
|
cameraMode: CameraMode;
|
||||||
|
viewScale: number;
|
||||||
|
viewScaleResetTrigger: number;
|
||||||
|
onViewScaleChange: (scale: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS = 6_371_000;
|
||||||
|
const EARTH_RADIUS = 34;
|
||||||
|
const EARTH_TEXTURE = '/upload/texture/2k_earth_daymap.jpg';
|
||||||
|
const EARTH_CENTER = new THREE.Vector3(0, -EARTH_RADIUS, 0);
|
||||||
|
const EARTH_ROTATION = new THREE.Euler(0, -1.25, -0.08);
|
||||||
|
const VIEW_SCALE_CALIBRATION = 0.7;
|
||||||
|
|
||||||
|
interface LaunchSite {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManualCameraRef {
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchSiteForRocket(rocket: RocketConfig) {
|
||||||
|
return {
|
||||||
|
latitude: rocket.launch_latitude_deg,
|
||||||
|
longitude: rocket.launch_longitude_deg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function orbitLift(targetAltitude: number) {
|
||||||
|
return clamp(targetAltitude / 200_000 * 11, 8, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalFlightFrame(altitude: number, downrange: number, targetAltitude: number, site: LaunchSite) {
|
||||||
|
const radius = EARTH_RADIUS + clamp(altitude / targetAltitude, 0, 1.45) * orbitLift(targetAltitude);
|
||||||
|
const angle = downrange / EARTH_RADIUS_METERS;
|
||||||
|
const latitude = THREE.MathUtils.degToRad(site.latitude);
|
||||||
|
const longitude = THREE.MathUtils.degToRad(site.longitude);
|
||||||
|
const launchNormal = new THREE.Vector3(
|
||||||
|
Math.cos(latitude) * Math.cos(longitude),
|
||||||
|
Math.sin(latitude),
|
||||||
|
-Math.cos(latitude) * Math.sin(longitude),
|
||||||
|
);
|
||||||
|
const east = new THREE.Vector3(-Math.sin(longitude), 0, -Math.cos(longitude));
|
||||||
|
const normal = launchNormal.multiplyScalar(Math.cos(angle)).addScaledVector(east, Math.sin(angle)).applyEuler(EARTH_ROTATION);
|
||||||
|
const tangent = east.multiplyScalar(Math.cos(angle)).addScaledVector(
|
||||||
|
new THREE.Vector3(
|
||||||
|
Math.cos(latitude) * Math.cos(longitude),
|
||||||
|
Math.sin(latitude),
|
||||||
|
-Math.cos(latitude) * Math.sin(longitude),
|
||||||
|
),
|
||||||
|
-Math.sin(angle),
|
||||||
|
).applyEuler(EARTH_ROTATION);
|
||||||
|
|
||||||
|
return {
|
||||||
|
normal,
|
||||||
|
tangent,
|
||||||
|
position: EARTH_CENTER.clone().addScaledVector(normal, radius),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable single-plane ground, simple launch platform and service rack. */
|
||||||
|
function LaunchRack({ state }: { state: SimulationState }) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.position.y = -groundDrop(state.altitude);
|
||||||
|
// Slide sideways with downrange travel so the pad falls behind.
|
||||||
|
groupRef.current.position.x = -clamp(state.downrange / 4000, 0, 60);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
<mesh position={[0, -0.72, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||||
|
<circleGeometry args={[120, 64]} />
|
||||||
|
<meshStandardMaterial color="#26342c" metalness={0} roughness={1} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.35, 0]} receiveShadow>
|
||||||
|
<boxGeometry args={[18, 0.7, 13]} />
|
||||||
|
<meshStandardMaterial color="#656b68" metalness={0.06} roughness={0.94} />
|
||||||
|
</mesh>
|
||||||
|
<group position={[-5.6, 0, 0]}>
|
||||||
|
<mesh position={[0, 5.5, 0]}>
|
||||||
|
<boxGeometry args={[0.65, 11, 0.8]} />
|
||||||
|
<meshStandardMaterial color="#505b60" metalness={0.52} roughness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
{[5.4, 9.2].map((y) => (
|
||||||
|
<mesh key={y} position={[2.1, y, 0]}>
|
||||||
|
<boxGeometry args={[4.2, 0.26, 0.42]} />
|
||||||
|
<meshStandardMaterial color="#667176" metalness={0.5} roughness={0.48} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EarthSurface({ texture, state }: { texture: THREE.Texture | null; state?: SimulationState }) {
|
||||||
|
const highAltitude = state ? state.altitude >= 9_000 : true;
|
||||||
|
if (!highAltitude) return null;
|
||||||
|
|
||||||
|
const gap = state ? 7 + clamp((state.altitude - 9_000) / 91_000, 0, 1) * 5 : 0;
|
||||||
|
const radius = state ? 62 : EARTH_RADIUS;
|
||||||
|
const centerY = state ? -radius - gap : -EARTH_RADIUS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[0, centerY, 0]}>
|
||||||
|
<mesh rotation={[0, -1.25, -0.08]} receiveShadow>
|
||||||
|
<sphereGeometry args={[radius, 96, 64]} />
|
||||||
|
<meshStandardMaterial map={texture ?? undefined} color={texture ? '#ffffff' : '#295c72'} roughness={0.9} metalness={0} />
|
||||||
|
</mesh>
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[radius * 1.018, 72, 48]} />
|
||||||
|
<meshBasicMaterial color="#7fc7e8" transparent opacity={state ? 0.12 : 0.18} side={THREE.BackSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh rotation={[0, -0.9, 0]}>
|
||||||
|
<sphereGeometry args={[radius * 1.006, 72, 48]} />
|
||||||
|
<meshPhongMaterial color="#ffffff" transparent opacity={0.07} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetOrbit({ targetAltitude, launchSite }: { targetAltitude: number; launchSite: LaunchSite }) {
|
||||||
|
const orbit = useMemo(() => {
|
||||||
|
const radius = EARTH_RADIUS + orbitLift(targetAltitude);
|
||||||
|
const points = Array.from({ length: 181 }, (_, index) => {
|
||||||
|
const angle = index / 180 * Math.PI * 2;
|
||||||
|
return globalFlightFrame(0, angle * EARTH_RADIUS_METERS, targetAltitude, launchSite)
|
||||||
|
.normal.multiplyScalar(radius).add(EARTH_CENTER);
|
||||||
|
});
|
||||||
|
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||||
|
const material = new THREE.LineDashedMaterial({ color: '#8a9496', dashSize: 1.2, gapSize: 0.75, transparent: true, opacity: 0.65 });
|
||||||
|
const line = new THREE.LineLoop(geometry, material);
|
||||||
|
line.computeLineDistances();
|
||||||
|
return line;
|
||||||
|
}, [launchSite, targetAltitude]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
orbit.geometry.dispose();
|
||||||
|
(orbit.material as THREE.Material).dispose();
|
||||||
|
}, [orbit]);
|
||||||
|
|
||||||
|
return <primitive object={orbit} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlightPath({ state, targetAltitude, launchSite }: { state: SimulationState; targetAltitude: number; launchSite: LaunchSite }) {
|
||||||
|
const line = useMemo(() => new THREE.Line(
|
||||||
|
new THREE.BufferGeometry(),
|
||||||
|
new THREE.LineBasicMaterial({ color: '#59c78f', transparent: true, opacity: 0.95 }),
|
||||||
|
), []);
|
||||||
|
const lineRef = useRef<THREE.Line>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const activeLine = lineRef.current;
|
||||||
|
if (!activeLine) return;
|
||||||
|
const points = [globalFlightFrame(0, 0, targetAltitude, launchSite).position];
|
||||||
|
state.history.forEach((point) => {
|
||||||
|
points.push(globalFlightFrame(point.altitude, point.downrange, targetAltitude, launchSite).position);
|
||||||
|
});
|
||||||
|
points.push(globalFlightFrame(state.altitude, state.downrange, targetAltitude, launchSite).position);
|
||||||
|
const previousGeometry = activeLine.geometry;
|
||||||
|
activeLine.geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||||
|
previousGeometry.dispose();
|
||||||
|
}, [launchSite, state.altitude, state.downrange, state.history, targetAltitude]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
lineRef.current?.geometry.dispose();
|
||||||
|
(line.material as THREE.Material).dispose();
|
||||||
|
}, [line]);
|
||||||
|
|
||||||
|
return <primitive ref={lineRef} object={line} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobalEarthView({ rocket, state, texture }: { rocket: RocketConfig; state: SimulationState; texture: THREE.Texture | null }) {
|
||||||
|
const targetAltitude = rocket.target_orbit_km * 1000;
|
||||||
|
const launchSite = launchSiteForRocket(rocket);
|
||||||
|
const frame = globalFlightFrame(state.altitude, state.downrange, targetAltitude, launchSite);
|
||||||
|
const launchFrame = globalFlightFrame(0, 0, targetAltitude, launchSite);
|
||||||
|
const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch);
|
||||||
|
const direction = frame.normal.clone().multiplyScalar(Math.cos(pitchFromVertical))
|
||||||
|
.addScaledVector(frame.tangent, Math.sin(pitchFromVertical)).normalize();
|
||||||
|
const orientation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);
|
||||||
|
const markerPosition = launchFrame.position.clone().addScaledVector(launchFrame.normal, 0.25);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EarthSurface texture={texture} />
|
||||||
|
<TargetOrbit targetAltitude={targetAltitude} launchSite={launchSite} />
|
||||||
|
<FlightPath state={state} targetAltitude={targetAltitude} launchSite={launchSite} />
|
||||||
|
<mesh position={markerPosition}>
|
||||||
|
<sphereGeometry args={[0.34, 16, 12]} />
|
||||||
|
<meshBasicMaterial color="#f2b84b" />
|
||||||
|
</mesh>
|
||||||
|
<group position={frame.position} quaternion={orientation} scale={0.7 * VIEW_SCALE_CALIBRATION}>
|
||||||
|
<RocketModel rocket={rocket} state={state} />
|
||||||
|
</group>
|
||||||
|
<pointLight position={frame.position} color="#f2b84b" intensity={2.5} distance={9} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FollowVehicle({ rocket, state }: { rocket: RocketConfig; state: SimulationState }) {
|
||||||
|
const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group rotation={[0, 0, -pitchFromVertical]}>
|
||||||
|
<SeparationBurst state={state} />
|
||||||
|
<RocketModel rocket={rocket} state={state} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expanding shockwave ring shown briefly at stage separation. */
|
||||||
|
function SeparationBurst({ state }: { state: SimulationState }) {
|
||||||
|
const ringRef = useRef<THREE.Mesh>(null);
|
||||||
|
const age = separationAge(state);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (!ringRef.current) return;
|
||||||
|
const visible = age !== null && age < 1.1;
|
||||||
|
ringRef.current.visible = visible;
|
||||||
|
if (visible && age !== null) {
|
||||||
|
const scale = 1 + age * 9;
|
||||||
|
ringRef.current.scale.set(scale, scale, scale);
|
||||||
|
const mat = ringRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = clamp(1 - age / 1.1, 0, 1) * 0.7;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} visible={false}>
|
||||||
|
<ringGeometry args={[0.6, 0.9, 32]} />
|
||||||
|
<meshBasicMaterial color="#ffd68a" transparent opacity={0} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _camPos = new THREE.Vector3();
|
||||||
|
const _lookAt = new THREE.Vector3();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two webcast-style camera modes:
|
||||||
|
* - follow: tight tracking shot that stays close to the vehicle, gently
|
||||||
|
* pulling back with altitude (like an onboard/tracking-dish view).
|
||||||
|
* - global: wide cinematic establishing shot that pulls far back so the
|
||||||
|
* receding pad, downrange arc and darkening sky are all in frame.
|
||||||
|
*/
|
||||||
|
function FlightCamera({ rocket, state, mode, viewScale, viewScaleResetTrigger, manualCameraRef }: { rocket: RocketConfig; state: SimulationState; mode: CameraMode; viewScale: number; viewScaleResetTrigger: number; manualCameraRef: ManualCameraRef }) {
|
||||||
|
useEffect(() => {
|
||||||
|
manualCameraRef.current = false;
|
||||||
|
}, [manualCameraRef, mode, rocket.code, viewScaleResetTrigger]);
|
||||||
|
|
||||||
|
useFrame(({ camera, size }, delta) => {
|
||||||
|
if (manualCameraRef.current) return;
|
||||||
|
const perspectiveCamera = camera as THREE.PerspectiveCamera;
|
||||||
|
if (mode === 'follow') {
|
||||||
|
const zoom = VIEW_SCALE_CALIBRATION * clamp(viewScale, 50, 250) / 100;
|
||||||
|
const portraitScale = size.width / size.height < 0.8 ? 1.55 : 1;
|
||||||
|
const pullback = (22 + clamp(state.altitude / 4000, 0, 14)) / zoom * portraitScale;
|
||||||
|
const height = (7 + clamp(state.altitude / 7000, 0, 7)) / Math.sqrt(zoom);
|
||||||
|
const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch);
|
||||||
|
_camPos.set(pullback * 0.32, height, pullback);
|
||||||
|
_lookAt.set(
|
||||||
|
Math.sin(pitchFromVertical) * 5.2,
|
||||||
|
Math.cos(pitchFromVertical) * 5.2,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
perspectiveCamera.fov = THREE.MathUtils.lerp(perspectiveCamera.fov, 42, 0.08);
|
||||||
|
} else {
|
||||||
|
const portrait = size.width / size.height < 0.8;
|
||||||
|
const zoom = clamp(viewScale, 50, 250) / 100;
|
||||||
|
const targetAltitude = rocket.target_orbit_km * 1000;
|
||||||
|
const launchFrame = globalFlightFrame(0, 0, targetAltitude, launchSiteForRocket(rocket));
|
||||||
|
const viewDirection = launchFrame.normal.clone()
|
||||||
|
.addScaledVector(launchFrame.tangent, -0.72)
|
||||||
|
.add(new THREE.Vector3(0, 0.16, 0))
|
||||||
|
.normalize();
|
||||||
|
_camPos.copy(EARTH_CENTER).addScaledVector(viewDirection, (portrait ? 320 : 165) / zoom);
|
||||||
|
_lookAt.copy(EARTH_CENTER);
|
||||||
|
perspectiveCamera.fov = THREE.MathUtils.lerp(perspectiveCamera.fov, 38, 0.08);
|
||||||
|
}
|
||||||
|
// Frame-rate independent smoothing.
|
||||||
|
const t = 1 - Math.pow(0.001, delta);
|
||||||
|
camera.position.lerp(_camPos, t * 0.9);
|
||||||
|
camera.lookAt(_lookAt);
|
||||||
|
perspectiveCamera.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sky + fog that shift color with altitude to sell the climb into space. */
|
||||||
|
function Atmosphere({ state, mode }: { state: SimulationState; mode: CameraMode }) {
|
||||||
|
useFrame(({ scene }) => {
|
||||||
|
if (mode === 'global') {
|
||||||
|
scene.background = new THREE.Color('#020408');
|
||||||
|
scene.fog = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sky = new THREE.Color(skyColor(state.altitude));
|
||||||
|
scene.background = sky;
|
||||||
|
if (!scene.fog) scene.fog = new THREE.Fog(sky, 40, 260);
|
||||||
|
const fog = scene.fog as THREE.Fog;
|
||||||
|
fog.color.set(fogColor(state.altitude));
|
||||||
|
// Thin the fog out with altitude so orbit reads crisp and dark.
|
||||||
|
const density = clamp(1 - state.altitude / 45_000, 0, 1);
|
||||||
|
fog.near = 40 + (1 - density) * 400;
|
||||||
|
fog.far = 260 + (1 - density) * 1400;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SceneContents({ rocket, state, cameraMode, viewScale, viewScaleResetTrigger, onViewScaleChange }: RocketFlightSceneProps) {
|
||||||
|
const earthTexture = useOptionalTexture(EARTH_TEXTURE);
|
||||||
|
const manualCameraRef = useRef(false);
|
||||||
|
const orbitControlsRef = useRef<OrbitControlsImpl>(null);
|
||||||
|
const manualDistanceRef = useRef<number | null>(null);
|
||||||
|
const viewScaleRef = useRef(viewScale);
|
||||||
|
viewScaleRef.current = viewScale;
|
||||||
|
const controlTarget = useMemo<[number, number, number]>(
|
||||||
|
() => cameraMode === 'follow' ? [0, 5.2, 0] : [EARTH_CENTER.x, EARTH_CENTER.y, EARTH_CENTER.z],
|
||||||
|
[cameraMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Atmosphere state={state} mode={cameraMode} />
|
||||||
|
<OrbitControls
|
||||||
|
ref={orbitControlsRef}
|
||||||
|
target={controlTarget}
|
||||||
|
enablePan={false}
|
||||||
|
enableRotate
|
||||||
|
enableZoom
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.08}
|
||||||
|
minDistance={4}
|
||||||
|
maxDistance={cameraMode === 'global' ? 500 : 100}
|
||||||
|
onStart={() => {
|
||||||
|
manualCameraRef.current = true;
|
||||||
|
manualDistanceRef.current = orbitControlsRef.current?.getDistance() ?? null;
|
||||||
|
}}
|
||||||
|
onChange={() => {
|
||||||
|
if (!manualCameraRef.current || manualDistanceRef.current === null || !orbitControlsRef.current) return;
|
||||||
|
const distance = orbitControlsRef.current.getDistance();
|
||||||
|
if (distance <= 0) return;
|
||||||
|
const nextScale = Math.round(clamp(viewScaleRef.current * manualDistanceRef.current / distance, 50, 250) / 5) * 5;
|
||||||
|
manualDistanceRef.current = distance;
|
||||||
|
if (nextScale !== viewScaleRef.current) {
|
||||||
|
viewScaleRef.current = nextScale;
|
||||||
|
onViewScaleChange(nextScale);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FlightCamera rocket={rocket} state={state} mode={cameraMode} viewScale={viewScale} viewScaleResetTrigger={viewScaleResetTrigger} manualCameraRef={manualCameraRef} />
|
||||||
|
<ambientLight intensity={cameraMode === 'global' ? 0.3 : 0.55} />
|
||||||
|
<hemisphereLight args={['#bfe3ff', '#19150d', cameraMode === 'global' ? 0.25 : 0.6]} />
|
||||||
|
<directionalLight position={[28, 34, 45]} intensity={2.4} castShadow />
|
||||||
|
<group visible={cameraMode === 'global' || starOpacity(state.altitude) > 0.02}>
|
||||||
|
<Stars radius={420} depth={140} count={5000} factor={4.5} saturation={0} fade speed={0.35} />
|
||||||
|
</group>
|
||||||
|
{cameraMode === 'follow' ? (
|
||||||
|
<>
|
||||||
|
<LaunchRack state={state} />
|
||||||
|
<EarthSurface texture={earthTexture} state={state} />
|
||||||
|
<FollowVehicle rocket={rocket} state={state} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<GlobalEarthView rocket={rocket} state={state} texture={earthTexture} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RocketFlightScene({ rocket, state, cameraMode, viewScale, viewScaleResetTrigger, onViewScaleChange }: RocketFlightSceneProps) {
|
||||||
|
const initialSky = useMemo(() => skyColor(0), []);
|
||||||
|
return (
|
||||||
|
<div className="rocket-flight-scene" aria-label="火箭飞行三维动画">
|
||||||
|
<Canvas
|
||||||
|
shadows
|
||||||
|
dpr={[1, 2]}
|
||||||
|
camera={{ position: [0, 7, 32], fov: 42, near: 0.1, far: 4000 }}
|
||||||
|
gl={{ antialias: true }}
|
||||||
|
onCreated={({ scene, camera }) => {
|
||||||
|
scene.background = new THREE.Color(initialSky);
|
||||||
|
camera.lookAt(0, 5.2, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SceneContents rocket={rocket} state={state} cameraMode={cameraMode} viewScale={viewScale} viewScaleResetTrigger={viewScaleResetTrigger} onViewScaleChange={onViewScaleChange} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { RocketConfig, SimulationState } from './types';
|
||||||
|
import { engineState, separationAge, separationTransform, stageGeometry } from './sceneMath';
|
||||||
|
|
||||||
|
const DARK = '#20262d';
|
||||||
|
const METAL = '#8b96a1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated exhaust plume. The whole plume is anchored at the engine bell (local
|
||||||
|
* y = 0) and grows DOWNWARD; we scale an anchored group (not a center-origin
|
||||||
|
* mesh) so the flame root always stays glued to the nozzle regardless of
|
||||||
|
* throttle/flicker. `nozzleY` is where the engine bell sits in the parent.
|
||||||
|
*/
|
||||||
|
function EnginePlume({
|
||||||
|
radius,
|
||||||
|
length,
|
||||||
|
throttle,
|
||||||
|
active,
|
||||||
|
running,
|
||||||
|
nozzleY = 0,
|
||||||
|
}: {
|
||||||
|
radius: number;
|
||||||
|
length: number;
|
||||||
|
throttle: number;
|
||||||
|
active: boolean;
|
||||||
|
running: boolean;
|
||||||
|
nozzleY?: number;
|
||||||
|
}) {
|
||||||
|
const anchorRef = useRef<THREE.Group>(null);
|
||||||
|
const lightRef = useRef<THREE.PointLight>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!anchorRef.current || !running) return;
|
||||||
|
const flicker = 0.85 + Math.sin(clock.elapsedTime * 45) * 0.12 + Math.random() * 0.06;
|
||||||
|
const stretch = flicker * (0.5 + throttle * 0.7);
|
||||||
|
// Only Y is animated; the group origin stays at the nozzle so the flame
|
||||||
|
// never detaches from the tail.
|
||||||
|
anchorRef.current.scale.set(0.75 + throttle * 0.35, stretch, 0.75 + throttle * 0.35);
|
||||||
|
if (lightRef.current) lightRef.current.intensity = 5 + flicker * 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
// Cones are built with their tip up and base down, positioned so the base
|
||||||
|
// (top of cone) sits exactly at the nozzle and the tip points away.
|
||||||
|
return (
|
||||||
|
<group ref={anchorRef} position={[0, nozzleY, 0]}>
|
||||||
|
<mesh position={[0, -length * 0.9, 0]} rotation={[Math.PI, 0, 0]}>
|
||||||
|
<coneGeometry args={[radius * 2.1, length * 1.8, 20, 1, true]} />
|
||||||
|
<meshBasicMaterial color="#ff7a2a" transparent opacity={0.32} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -length * 0.5, 0]} rotation={[Math.PI, 0, 0]}>
|
||||||
|
<coneGeometry args={[radius * 1.15, length, 20, 1, true]} />
|
||||||
|
<meshBasicMaterial color="#fff2c2" transparent opacity={0.9} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
<pointLight ref={lightRef} color="#ff9a3c" intensity={6} distance={radius * 30} decay={2} position={[0, -length * 0.4, 0]} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nozzle cluster drawn as a dark boat-tail plus a few nozzle cones. */
|
||||||
|
function EngineCluster({ radius, count }: { radius: number; count: number }) {
|
||||||
|
const nozzles = Math.min(count, 9);
|
||||||
|
const ring = useMemo(() => {
|
||||||
|
if (nozzles <= 1) return [[0, 0]] as Array<[number, number]>;
|
||||||
|
const points: Array<[number, number]> = [[0, 0]];
|
||||||
|
const outer = nozzles - 1;
|
||||||
|
for (let i = 0; i < outer; i += 1) {
|
||||||
|
const angle = (i / outer) * Math.PI * 2;
|
||||||
|
points.push([Math.cos(angle) * radius * 0.5, Math.sin(angle) * radius * 0.5]);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}, [nozzles, radius]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh position={[0, radius * 0.4, 0]}>
|
||||||
|
<cylinderGeometry args={[radius * 0.82, radius, radius * 0.8, 24]} />
|
||||||
|
<meshStandardMaterial color={DARK} metalness={0.6} roughness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
{ring.map(([x, z], i) => (
|
||||||
|
<mesh key={i} position={[x, -radius * 0.2, z]} rotation={[Math.PI, 0, 0]}>
|
||||||
|
<coneGeometry args={[radius * 0.16, radius * 0.55, 12, 1, true]} />
|
||||||
|
<meshStandardMaterial color="#3a4149" metalness={0.7} roughness={0.35} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grid-fin / stabiliser fins around the base of the first stage. */
|
||||||
|
function Fins({ radius, height }: { radius: number; height: number }) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<mesh key={i} position={[0, height * 0.5, 0]} rotation={[0, (i / 4) * Math.PI * 2, 0]}>
|
||||||
|
<boxGeometry args={[radius * 0.12, height, radius * 2.4]} />
|
||||||
|
<meshStandardMaterial color={DARK} metalness={0.5} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StageProps {
|
||||||
|
radius: number;
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-stage airframe with fins and engine cluster; base sits at local y=0. */
|
||||||
|
function FirstStageBody({ radius, height, color, engineCount }: StageProps & { engineCount: number }) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh position={[0, height * 0.5, 0]}>
|
||||||
|
<cylinderGeometry args={[radius, radius, height, 32]} />
|
||||||
|
<meshStandardMaterial color={color} metalness={0.35} roughness={0.45} />
|
||||||
|
</mesh>
|
||||||
|
{/* Livery band near the top of the stage */}
|
||||||
|
<mesh position={[0, height * 0.9, 0]}>
|
||||||
|
<cylinderGeometry args={[radius * 1.005, radius * 1.005, height * 0.05, 32]} />
|
||||||
|
<meshStandardMaterial color={DARK} metalness={0.4} roughness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<group position={[0, height * 0.16, 0]}>
|
||||||
|
<Fins radius={radius} height={height * 0.22} />
|
||||||
|
</group>
|
||||||
|
<EngineCluster radius={radius} count={engineCount} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interstage + second stage + payload fairing/nose; base sits at local y=0. */
|
||||||
|
function UpperStack({
|
||||||
|
radius,
|
||||||
|
interstageHeight,
|
||||||
|
stage2Height,
|
||||||
|
noseHeight,
|
||||||
|
color,
|
||||||
|
engineCount,
|
||||||
|
}: {
|
||||||
|
radius: number;
|
||||||
|
interstageHeight: number;
|
||||||
|
stage2Height: number;
|
||||||
|
noseHeight: number;
|
||||||
|
color: string;
|
||||||
|
engineCount: number;
|
||||||
|
}) {
|
||||||
|
const stage2Base = interstageHeight;
|
||||||
|
const noseBase = interstageHeight + stage2Height;
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Interstage (dark) */}
|
||||||
|
<mesh position={[0, interstageHeight * 0.5, 0]}>
|
||||||
|
<cylinderGeometry args={[radius * 0.98, radius, interstageHeight, 32]} />
|
||||||
|
<meshStandardMaterial color={DARK} metalness={0.5} roughness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
{/* Second-stage engine bell tucked under the interstage */}
|
||||||
|
<group position={[0, stage2Base, 0]}>
|
||||||
|
<EngineCluster radius={radius * 0.7} count={Math.min(engineCount, 3)} />
|
||||||
|
</group>
|
||||||
|
{/* Second-stage body */}
|
||||||
|
<mesh position={[0, stage2Base + stage2Height * 0.5, 0]}>
|
||||||
|
<cylinderGeometry args={[radius * 0.96, radius * 0.98, stage2Height, 32]} />
|
||||||
|
<meshStandardMaterial color={color} metalness={0.35} roughness={0.45} />
|
||||||
|
</mesh>
|
||||||
|
{/* Payload fairing / nose cone */}
|
||||||
|
<mesh position={[0, noseBase + noseHeight * 0.5, 0]}>
|
||||||
|
<coneGeometry args={[radius * 0.96, noseHeight, 32]} />
|
||||||
|
<meshStandardMaterial color={METAL} metalness={0.4} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PayloadSatellite({ radius, panelProgress }: { radius: number; panelProgress: number }) {
|
||||||
|
const satelliteRef = useRef<THREE.Group>(null);
|
||||||
|
const easedProgress = 1 - Math.pow(1 - panelProgress, 3);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (satelliteRef.current) satelliteRef.current.rotation.y += delta * 0.18;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={satelliteRef}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[radius * 1.35, radius * 1.15, radius * 1.2]} />
|
||||||
|
<meshStandardMaterial color="#d2a84f" metalness={0.65} roughness={0.38} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, radius * 0.76, 0]}>
|
||||||
|
<cylinderGeometry args={[radius * 0.28, radius * 0.42, radius * 0.42, 20]} />
|
||||||
|
<meshStandardMaterial color="#d9ddda" metalness={0.65} roughness={0.28} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, radius * 1.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<sphereGeometry args={[radius * 0.54, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||||
|
<meshStandardMaterial color="#eff2ef" metalness={0.35} roughness={0.4} side={THREE.BackSide} />
|
||||||
|
</mesh>
|
||||||
|
{[-1, 1].map((direction) => (
|
||||||
|
<group key={direction} position={[direction * radius * 0.78, 0, 0]}>
|
||||||
|
<mesh
|
||||||
|
position={[direction * radius * 1.55 * easedProgress, 0, 0]}
|
||||||
|
scale={[Math.max(0.04, easedProgress), 1, 1]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[radius * 3.1, radius * 0.72, radius * 0.08]} />
|
||||||
|
<meshStandardMaterial color="#24507a" metalness={0.25} roughness={0.55} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[direction * radius * 0.08, 0, 0]}>
|
||||||
|
<boxGeometry args={[radius * 0.18, radius * 0.18, radius * 0.16]} />
|
||||||
|
<meshStandardMaterial color={DARK} metalness={0.7} roughness={0.35} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RocketModelProps {
|
||||||
|
rocket: RocketConfig;
|
||||||
|
state: SimulationState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full launch vehicle. Each discarded stage drifts away from the active stack;
|
||||||
|
* after orbital insertion, the payload separates and deploys its solar arrays.
|
||||||
|
*/
|
||||||
|
export function RocketModel({ rocket, state }: RocketModelProps) {
|
||||||
|
const geo = useMemo(() => stageGeometry(rocket), [rocket]);
|
||||||
|
const engines = engineState(state);
|
||||||
|
const age = separationAge(state);
|
||||||
|
const sep = separationTransform(age);
|
||||||
|
const deployEvent = state.events.find((event) => event.id === 'deploy');
|
||||||
|
const deployAge = deployEvent ? Math.max(0, state.time - deployEvent.time) : null;
|
||||||
|
const deployProgress = deployAge === null ? 0 : Math.min(1, deployAge / 2.4);
|
||||||
|
|
||||||
|
const firstStageRef = useRef<THREE.Group>(null);
|
||||||
|
const upperStackRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
// The upper stack stays anchored until payload deployment. The first stage
|
||||||
|
// sits directly below it (base at local y=0) until stage separation.
|
||||||
|
const upperBaseY = geo.stage1Height;
|
||||||
|
const payloadY = upperBaseY + geo.interstageHeight + geo.stage2Height + geo.noseHeight * 0.58;
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (firstStageRef.current) {
|
||||||
|
if (sep) {
|
||||||
|
firstStageRef.current.position.set(sep.drift, -sep.drop, 0);
|
||||||
|
firstStageRef.current.rotation.set(sep.tumble * 0.6, sep.tumble * 0.3, sep.tumble);
|
||||||
|
firstStageRef.current.visible = sep.opacity > 0.02;
|
||||||
|
} else {
|
||||||
|
firstStageRef.current.position.set(0, 0, 0);
|
||||||
|
firstStageRef.current.rotation.set(0, 0, 0);
|
||||||
|
firstStageRef.current.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upperStackRef.current) {
|
||||||
|
if (deployAge === null) {
|
||||||
|
upperStackRef.current.position.set(0, upperBaseY, 0);
|
||||||
|
upperStackRef.current.rotation.set(0, 0, 0);
|
||||||
|
upperStackRef.current.visible = true;
|
||||||
|
} else {
|
||||||
|
upperStackRef.current.position.set(-deployAge * 0.72, upperBaseY - deployAge * 0.82, deployAge * 0.18);
|
||||||
|
upperStackRef.current.rotation.set(deployAge * 0.16, deployAge * 0.08, -deployAge * 0.3);
|
||||||
|
upperStackRef.current.visible = deployAge < 5.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Upper stack: active through insertion, then discarded after deployment. */}
|
||||||
|
<group ref={upperStackRef} position={[0, upperBaseY, 0]}>
|
||||||
|
<UpperStack
|
||||||
|
radius={geo.radius}
|
||||||
|
interstageHeight={geo.interstageHeight}
|
||||||
|
stage2Height={geo.stage2Height}
|
||||||
|
noseHeight={geo.noseHeight}
|
||||||
|
color={rocket.color}
|
||||||
|
engineCount={rocket.stage_2.engine_count}
|
||||||
|
/>
|
||||||
|
{/* Stage-2 plume: fires from the second-stage bell (above interstage) */}
|
||||||
|
<group position={[0, geo.interstageHeight, 0]}>
|
||||||
|
<EnginePlume
|
||||||
|
radius={geo.radius * 0.7}
|
||||||
|
length={geo.radius * 7}
|
||||||
|
nozzleY={-geo.radius * 0.7 * 0.4}
|
||||||
|
throttle={state.throttle}
|
||||||
|
active={engines.stage2}
|
||||||
|
running={state.isRunning}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{deployAge !== null && (
|
||||||
|
<group position={[deployProgress * 1.8, payloadY + deployProgress * 2.6, 0]} scale={1.25}>
|
||||||
|
<PayloadSatellite radius={geo.radius} panelProgress={deployProgress} />
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* First stage: attached below the stack, or detached and falling */}
|
||||||
|
<group ref={firstStageRef}>
|
||||||
|
<FirstStageBody
|
||||||
|
radius={geo.radius}
|
||||||
|
height={geo.stage1Height}
|
||||||
|
color={rocket.color}
|
||||||
|
engineCount={rocket.stage_1.engine_count}
|
||||||
|
/>
|
||||||
|
{/* Stage-1 plume: fires from the engine cluster at the base (y≈0) */}
|
||||||
|
<EnginePlume
|
||||||
|
radius={geo.radius}
|
||||||
|
length={geo.radius * 10}
|
||||||
|
nozzleY={-geo.radius * 0.4}
|
||||||
|
throttle={state.throttle}
|
||||||
|
active={engines.stage1}
|
||||||
|
running={state.isRunning}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { TelemetryPoint } from './types';
|
||||||
|
|
||||||
|
export function TelemetryChart({ history }: { history: TelemetryPoint[] }) {
|
||||||
|
if (history.length < 2) {
|
||||||
|
return <div className="telemetry-chart-empty">点火后开始记录飞行曲线</div>;
|
||||||
|
}
|
||||||
|
const points = history.slice(-100);
|
||||||
|
const maxAltitude = Math.max(...points.map((point) => point.altitude), 1);
|
||||||
|
const maxVelocity = Math.max(...points.map((point) => point.velocity), 1);
|
||||||
|
const altitudeLine = points.map((point, index) => `${index / (points.length - 1) * 100},${38 - point.altitude / maxAltitude * 34}`).join(' ');
|
||||||
|
const velocityLine = points.map((point, index) => `${index / (points.length - 1) * 100},${38 - point.velocity / maxVelocity * 34}`).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="telemetry-chart">
|
||||||
|
<div className="chart-legend"><span className="altitude-key">高度</span><span className="velocity-key">速度</span></div>
|
||||||
|
<svg viewBox="0 0 100 40" preserveAspectRatio="none" aria-label="高度和速度曲线">
|
||||||
|
<line x1="0" y1="38" x2="100" y2="38" stroke="#43606a" strokeWidth="0.35" />
|
||||||
|
<line x1="0" y1="21" x2="100" y2="21" stroke="#30434a" strokeWidth="0.2" strokeDasharray="2 2" />
|
||||||
|
<polyline points={altitudeLine} fill="none" stroke="#5eead4" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<polyline points={velocityLine} fill="none" stroke="#fbbf24" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
import type { RocketConfig } from './types';
|
||||||
|
|
||||||
|
export async function fetchRocketConfigs(): Promise<RocketConfig[]> {
|
||||||
|
const response = await api.get<RocketConfig[]>('/rockets');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,925 @@
|
||||||
|
.rocket-simulator-page {
|
||||||
|
--panel: rgba(10, 12, 13, 0.88);
|
||||||
|
--panel-solid: #0b0d0e;
|
||||||
|
--line: rgba(255, 255, 255, 0.16);
|
||||||
|
--line-strong: rgba(255, 255, 255, 0.3);
|
||||||
|
--text: #f4f6f5;
|
||||||
|
--muted: #929998;
|
||||||
|
--green: #2ea66f;
|
||||||
|
--green-bright: #54d497;
|
||||||
|
--amber: #f2b84b;
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
background: #020304;
|
||||||
|
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rocket-simulator-page button,
|
||||||
|
.rocket-simulator-page select {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rocket-flight-scene {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #020304;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rocket-flight-scene canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-icon-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #dfe3e2;
|
||||||
|
background: rgba(13, 16, 17, 0.82);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 150ms, background 150ms, color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-icon-btn:hover,
|
||||||
|
.sx-icon-btn:focus-visible {
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
background: #202425;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-topbar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: rgba(5, 7, 8, 0.92);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-brand > svg {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-brand div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-brand strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-brand span {
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status {
|
||||||
|
height: 38px;
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-left: 2px solid #606766;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status > span {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #68706e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status > .sx-live {
|
||||||
|
background: var(--green-bright);
|
||||||
|
box-shadow: 0 0 0 4px rgba(84, 212, 151, 0.12);
|
||||||
|
animation: sxPulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status small {
|
||||||
|
color: #777f7d;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status strong {
|
||||||
|
margin-top: 1px;
|
||||||
|
color: #e9edec;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sxPulse {
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-topbar-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-select {
|
||||||
|
width: 274px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-select > span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #777f7d;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-select > div {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-select select {
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
appearance: none;
|
||||||
|
padding: 0 28px 0 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: 0;
|
||||||
|
color: #edf0ef;
|
||||||
|
background: #111415;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-select select:hover,
|
||||||
|
.sx-select select:focus-visible {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-select svg {
|
||||||
|
position: absolute;
|
||||||
|
right: 9px;
|
||||||
|
color: #89908f;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-select option {
|
||||||
|
color: #edf0ef;
|
||||||
|
background: #111415;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-seg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-seg button {
|
||||||
|
height: 34px;
|
||||||
|
min-width: 68px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
color: #a9afad;
|
||||||
|
background: #111415;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
transition: background 150ms, color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-seg button:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-seg button:hover,
|
||||||
|
.sx-seg button:focus-visible {
|
||||||
|
color: #fff;
|
||||||
|
background: #222627;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-seg button.active {
|
||||||
|
color: #fff;
|
||||||
|
background: #2a6048;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-camera-slider {
|
||||||
|
height: 34px;
|
||||||
|
min-width: 172px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8c9592;
|
||||||
|
background: #111415;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-camera-slider input {
|
||||||
|
width: 72px;
|
||||||
|
height: 3px;
|
||||||
|
accent-color: var(--green-bright);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-camera-slider span {
|
||||||
|
width: 34px;
|
||||||
|
color: #c6ccca;
|
||||||
|
font-size: 9px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-environment {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
left: 20px;
|
||||||
|
top: 84px;
|
||||||
|
width: 190px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 2px solid var(--amber);
|
||||||
|
background: rgba(9, 11, 12, 0.72);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-environment span {
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-environment strong {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-environment small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-side {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
right: 20px;
|
||||||
|
top: 84px;
|
||||||
|
width: 208px;
|
||||||
|
padding: 0 15px 14px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-panel-title {
|
||||||
|
height: 44px;
|
||||||
|
margin: 0 -15px 8px;
|
||||||
|
padding: 0 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-panel-title span {
|
||||||
|
color: #7f8785;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-panel-title b {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-side-row {
|
||||||
|
min-height: 29px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-side-row span {
|
||||||
|
color: #939a98;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-side-row strong {
|
||||||
|
color: #f2f4f3;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-side-row strong em {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: #858c8a;
|
||||||
|
font-size: 9px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-side-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 9px 0 12px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-head {
|
||||||
|
margin-bottom: 7px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-head span {
|
||||||
|
color: #939a98;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-head strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-track {
|
||||||
|
height: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #292d2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-track i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
transition: width 200ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-track .s1 { background: var(--green-bright); }
|
||||||
|
.sx-fuel-track .s2 { background: var(--amber); }
|
||||||
|
|
||||||
|
.sx-fuel-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 25px minmax(0, 1fr) 30px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-row > span {
|
||||||
|
color: #8d9593;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-row > b {
|
||||||
|
color: #dde2df;
|
||||||
|
font-size: 9px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-total-row {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: #8d9593;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-fuel-total-row b {
|
||||||
|
color: #dfe4e1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
background: linear-gradient(0deg, rgba(4, 5, 6, 0.98) 52%, rgba(4, 5, 6, 0.72) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-track {
|
||||||
|
padding: 18px 20px 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-node {
|
||||||
|
position: relative;
|
||||||
|
min-width: 88px;
|
||||||
|
padding-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-node::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-node:last-child::before { display: none; }
|
||||||
|
|
||||||
|
.sx-node i {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 1px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 2px solid #626866;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #16191a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-node b {
|
||||||
|
color: #737a78;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-node time {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #676d6c;
|
||||||
|
font-size: 9px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-node.reached::before { background: var(--green); }
|
||||||
|
.sx-node.reached i { border-color: #98e3bd; background: var(--green-bright); }
|
||||||
|
.sx-node.reached b { color: #dff5e9; }
|
||||||
|
.sx-node.reached time { color: #8bc6a7; }
|
||||||
|
.sx-node.next i { border-color: #ffe0a1; background: var(--amber); animation: sxPulse 1s ease-in-out infinite; }
|
||||||
|
.sx-node.next b { color: #f4d391; }
|
||||||
|
|
||||||
|
.sx-hud {
|
||||||
|
min-height: 104px;
|
||||||
|
padding: 2px 24px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-gauge-cluster {
|
||||||
|
width: 292px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-gauge-cluster-right {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-arc-gauge {
|
||||||
|
position: relative;
|
||||||
|
width: 124px;
|
||||||
|
height: 92px;
|
||||||
|
flex: 0 0 124px;
|
||||||
|
color: #f4f6f5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-arc-gauge svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto auto 2px;
|
||||||
|
width: 120px;
|
||||||
|
height: 72px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-arc-gauge path {
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: square;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-arc-track { stroke: rgba(255, 255, 255, 0.22); }
|
||||||
|
.sx-arc-value { stroke: #e7ecea; }
|
||||||
|
.sx-arc-needle { stroke: var(--amber); stroke-width: 1.5; }
|
||||||
|
.sx-arc-gauge circle { fill: var(--amber); }
|
||||||
|
|
||||||
|
.sx-arc-gauge > span,
|
||||||
|
.sx-arc-gauge > strong,
|
||||||
|
.sx-arc-gauge > small {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-arc-gauge > span {
|
||||||
|
top: 21px;
|
||||||
|
color: #929a97;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-arc-gauge > strong {
|
||||||
|
top: 33px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 420;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-arc-gauge > small {
|
||||||
|
top: 61px;
|
||||||
|
color: #9da4a2;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-engine-gauge {
|
||||||
|
position: relative;
|
||||||
|
width: 124px;
|
||||||
|
height: 92px;
|
||||||
|
flex: 0 0 124px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-engine-ring {
|
||||||
|
--engine-radius: 23px;
|
||||||
|
position: relative;
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 3px solid rgba(235, 240, 238, 0.8);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-engine-ring i {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 1px solid #747c79;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #262b29;
|
||||||
|
transition: background 180ms, border-color 180ms, box-shadow 180ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-engine-center {
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-engine-outer {
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--engine-angle)) translateY(calc(var(--engine-radius) * -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-engine-ring i.active {
|
||||||
|
border-color: #ffe2a4;
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 6px rgba(242, 184, 75, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-engine-gauge > span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #858d8a;
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-gauge {
|
||||||
|
position: relative;
|
||||||
|
width: 124px;
|
||||||
|
height: 92px;
|
||||||
|
flex: 0 0 124px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-dial {
|
||||||
|
position: relative;
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid rgba(235, 240, 238, 0.72);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, rgba(50, 63, 69, 0.72) 0 49%, rgba(21, 25, 27, 0.88) 49% 100%);
|
||||||
|
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-axis {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(230, 235, 233, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-axis.horizontal {
|
||||||
|
top: 50%;
|
||||||
|
left: 7px;
|
||||||
|
right: 7px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-axis.vertical {
|
||||||
|
top: 7px;
|
||||||
|
bottom: 7px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-vehicle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
color: #f4f7f5;
|
||||||
|
filter: drop-shadow(0 0 5px rgba(242, 184, 75, 0.55));
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 120ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-gauge > span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #858d8a;
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-attitude-gauge > strong {
|
||||||
|
position: absolute;
|
||||||
|
right: 13px;
|
||||||
|
bottom: 14px;
|
||||||
|
color: #dfe4e1;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 650;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-clock {
|
||||||
|
width: 176px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-clock-time {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-realtime {
|
||||||
|
color: #8e9593;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-controls {
|
||||||
|
margin-top: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-launch {
|
||||||
|
width: 122px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #3eaa78;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
background: #237b55;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: border-color 150ms, background 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-launch:hover,
|
||||||
|
.sx-launch:focus-visible {
|
||||||
|
border-color: #67d59f;
|
||||||
|
background: #2b9165;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rocket-page-message {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
color: #eef1f0;
|
||||||
|
background: #050607;
|
||||||
|
font: 14px Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rocket-page-message svg { color: var(--amber, #f2b84b); }
|
||||||
|
.rocket-page-message a { color: #62ce98; }
|
||||||
|
.loading-rocket { animation: loadingRocket 1.2s ease-in-out infinite alternate; }
|
||||||
|
|
||||||
|
@keyframes loadingRocket {
|
||||||
|
to { transform: translateY(-12px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.sx-brand { min-width: 160px; }
|
||||||
|
.sx-brand span { max-width: 145px; }
|
||||||
|
.sx-mission-status { min-width: 128px; }
|
||||||
|
.sx-select { width: 274px; }
|
||||||
|
.sx-side { width: 180px; }
|
||||||
|
.sx-hud { gap: 15px; }
|
||||||
|
.sx-gauge-cluster { width: 250px; gap: 6px; }
|
||||||
|
.sx-node { min-width: 70px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.sx-topbar {
|
||||||
|
height: 56px;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-brand {
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-brand div,
|
||||||
|
.sx-select,
|
||||||
|
.sx-camera-slider,
|
||||||
|
.sx-side {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status {
|
||||||
|
min-width: 116px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-mission-status small { display: none; }
|
||||||
|
.sx-mission-status strong { font-size: 10px; }
|
||||||
|
.sx-topbar-right { gap: 7px; }
|
||||||
|
|
||||||
|
.sx-seg button {
|
||||||
|
min-width: 38px;
|
||||||
|
width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-seg button svg { width: 15px; height: 15px; }
|
||||||
|
|
||||||
|
.sx-environment {
|
||||||
|
top: 70px;
|
||||||
|
left: 12px;
|
||||||
|
width: 154px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-environment strong { font-size: 11px; }
|
||||||
|
.sx-environment small { font-size: 8px; }
|
||||||
|
|
||||||
|
.sx-bottom { padding-bottom: 10px; }
|
||||||
|
|
||||||
|
.sx-track {
|
||||||
|
padding: 13px 8px 7px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-track::-webkit-scrollbar { display: none; }
|
||||||
|
.sx-node { min-width: 64px; flex: 0 0 64px; }
|
||||||
|
.sx-node b { font-size: 8px; }
|
||||||
|
.sx-node time { display: none; }
|
||||||
|
|
||||||
|
.sx-hud {
|
||||||
|
min-height: 75px;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 3px 9px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sx-gauge-cluster { width: 112px; gap: 2px; }
|
||||||
|
.sx-arc-gauge,
|
||||||
|
.sx-engine-gauge,
|
||||||
|
.sx-attitude-gauge { width: 55px; height: 64px; flex-basis: 55px; }
|
||||||
|
.sx-arc-gauge svg { left: 0; width: 55px; height: 42px; }
|
||||||
|
.sx-arc-gauge > span { top: 12px; font-size: 6px; }
|
||||||
|
.sx-arc-gauge > strong { top: 21px; font-size: 14px; }
|
||||||
|
.sx-arc-gauge > small { top: 39px; font-size: 6px; }
|
||||||
|
.sx-engine-ring { --engine-radius: 14px; width: 43px; height: 43px; border-width: 2px; }
|
||||||
|
.sx-engine-ring i { width: 5px; height: 5px; }
|
||||||
|
.sx-engine-gauge > span { font-size: 5px; }
|
||||||
|
.sx-attitude-dial { width: 43px; height: 43px; border-width: 1px; }
|
||||||
|
.sx-attitude-axis.horizontal { left: 5px; right: 5px; }
|
||||||
|
.sx-attitude-axis.vertical { top: 5px; bottom: 5px; }
|
||||||
|
.sx-attitude-vehicle { width: 18px; height: 18px; }
|
||||||
|
.sx-attitude-vehicle svg { width: 18px; height: 18px; }
|
||||||
|
.sx-attitude-gauge > span { font-size: 5px; }
|
||||||
|
.sx-attitude-gauge > strong { right: 0; bottom: 12px; font-size: 7px; }
|
||||||
|
|
||||||
|
.sx-clock { width: 126px; gap: 4px; }
|
||||||
|
.sx-clock-time { font-size: 21px; }
|
||||||
|
.sx-realtime { font-size: 7px; }
|
||||||
|
.sx-launch { width: 98px; padding: 0 9px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.sx-hud { gap: 3px; padding-inline: 4px; }
|
||||||
|
.sx-clock { width: 96px; }
|
||||||
|
.sx-clock-time { font-size: 18px; }
|
||||||
|
.sx-launch { width: 82px; font-size: 9px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 650px) {
|
||||||
|
.sx-environment { top: 70px; }
|
||||||
|
.sx-side { top: 74px; }
|
||||||
|
.sx-panel-title { height: 36px; }
|
||||||
|
.sx-side-row { min-height: 24px; }
|
||||||
|
.sx-track { padding-top: 10px; }
|
||||||
|
.sx-hud { min-height: 72px; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import type { RocketConfig, SimulationState } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual constants for the 3D flight scene. The rocket is always drawn at a
|
||||||
|
* fixed on-screen height (VEHICLE_HEIGHT world units); real-world dimensions
|
||||||
|
* only drive proportions, never absolute size.
|
||||||
|
*/
|
||||||
|
export const VEHICLE_HEIGHT = 12;
|
||||||
|
|
||||||
|
export interface StageGeometry {
|
||||||
|
totalHeight: number;
|
||||||
|
radius: number;
|
||||||
|
stage1Height: number;
|
||||||
|
stage2Height: number;
|
||||||
|
interstageHeight: number;
|
||||||
|
noseHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive drawable proportions from a rocket config. length_ratio on each stage
|
||||||
|
* describes how much of the airframe that stage occupies; the remainder is the
|
||||||
|
* nose/fairing. Diameter maps to a slender radius so tall vehicles read well.
|
||||||
|
*/
|
||||||
|
export function stageGeometry(rocket: RocketConfig): StageGeometry {
|
||||||
|
const s1 = Math.max(0.15, Math.min(0.85, rocket.stage_1.length_ratio));
|
||||||
|
const s2 = Math.max(0.1, Math.min(0.7, rocket.stage_2.length_ratio));
|
||||||
|
const slenderness = Math.max(6, Math.min(16, rocket.height_m / rocket.diameter_m));
|
||||||
|
const radius = VEHICLE_HEIGHT / slenderness / 2;
|
||||||
|
|
||||||
|
const stage1Height = VEHICLE_HEIGHT * s1;
|
||||||
|
const interstageHeight = VEHICLE_HEIGHT * 0.03;
|
||||||
|
const stage2Height = VEHICLE_HEIGHT * s2;
|
||||||
|
const noseHeight = Math.max(
|
||||||
|
VEHICLE_HEIGHT * 0.12,
|
||||||
|
VEHICLE_HEIGHT - stage1Height - interstageHeight - stage2Height,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalHeight: VEHICLE_HEIGHT,
|
||||||
|
radius,
|
||||||
|
stage1Height,
|
||||||
|
stage2Height,
|
||||||
|
interstageHeight,
|
||||||
|
noseHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp helper. */
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear interpolation. */
|
||||||
|
export function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixColor(a: [number, number, number], b: [number, number, number], t: number): string {
|
||||||
|
const c = a.map((channel, i) => Math.round(lerp(channel, b[i], clamp(t, 0, 1))));
|
||||||
|
return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sky/background color for a given altitude (metres). Fades day-blue → deep
|
||||||
|
* blue → near-black space as the vehicle climbs past the Karman line.
|
||||||
|
*/
|
||||||
|
export function skyColor(altitude: number): string {
|
||||||
|
const groundBlue: [number, number, number] = [91, 151, 196];
|
||||||
|
const lowerStratosphere: [number, number, number] = [28, 72, 125];
|
||||||
|
const upperStratosphere: [number, number, number] = [7, 20, 43];
|
||||||
|
const space: [number, number, number] = [2, 4, 8];
|
||||||
|
|
||||||
|
if (altitude < 12_000) {
|
||||||
|
return mixColor(groundBlue, lowerStratosphere, altitude / 12_000);
|
||||||
|
}
|
||||||
|
if (altitude < 42_000) {
|
||||||
|
return mixColor(lowerStratosphere, upperStratosphere, (altitude - 12_000) / 30_000);
|
||||||
|
}
|
||||||
|
return mixColor(upperStratosphere, space, (altitude - 42_000) / 58_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fog density fades out as the atmosphere thins, so space looks crisp. */
|
||||||
|
export function fogColor(altitude: number): string {
|
||||||
|
return skyColor(altitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the ground/launch-pad plane sits below the vehicle, in world units.
|
||||||
|
* Uses a compressed (sqrt) mapping so the pad stays visible for the first few
|
||||||
|
* hundred metres, then recedes out of frame by ~4 km.
|
||||||
|
*/
|
||||||
|
export function groundDrop(altitude: number): number {
|
||||||
|
return Math.sqrt(altitude) * 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Star field opacity: invisible at sea level, full above ~60 km. */
|
||||||
|
export function starOpacity(altitude: number): number {
|
||||||
|
return clamp((altitude - 32_000) / 48_000, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds elapsed since the first stage physically separated, or null if it is
|
||||||
|
* still attached. Reads the recorded 'separation' event so it survives pauses.
|
||||||
|
*/
|
||||||
|
export function separationAge(state: SimulationState): number | null {
|
||||||
|
const attached = ['ready', 'stage1_burn', 'stage1_cutoff'].includes(state.phase);
|
||||||
|
if (attached) return null;
|
||||||
|
const event = state.events.find((e) => e.id === 'separation');
|
||||||
|
if (!event) return Math.max(0, state.time - state.phaseStartedAt);
|
||||||
|
return Math.max(0, state.time - event.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeparationTransform {
|
||||||
|
drop: number;
|
||||||
|
drift: number;
|
||||||
|
tumble: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position/rotation/fade of the discarded first stage relative to the still
|
||||||
|
* climbing upper stage. It accelerates away (drag + relative deceleration),
|
||||||
|
* drifts sideways, tumbles, and fades once it is well clear of frame.
|
||||||
|
*/
|
||||||
|
export function separationTransform(age: number | null): SeparationTransform | null {
|
||||||
|
if (age === null) return null;
|
||||||
|
const drop = 1.5 + age * 3.2 + 0.5 * 1.1 * age * age;
|
||||||
|
const drift = Math.sin(age * 0.6) * (1.2 + age * 0.25);
|
||||||
|
const tumble = age * 0.9;
|
||||||
|
const opacity = clamp(1 - (drop - 26) / 14, 0, 1);
|
||||||
|
return { drop, drift, tumble, opacity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which engines should be visibly firing in the current phase. */
|
||||||
|
export function engineState(state: SimulationState): { stage1: boolean; stage2: boolean } {
|
||||||
|
return {
|
||||||
|
stage1: state.phase === 'stage1_burn',
|
||||||
|
stage2: state.phase === 'stage2_ignition' || state.phase === 'stage2_burn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
export interface RocketStage {
|
||||||
|
name: string;
|
||||||
|
dry_mass_kg: number;
|
||||||
|
fuel_mass_kg: number;
|
||||||
|
max_thrust_n: number;
|
||||||
|
specific_impulse_s: number;
|
||||||
|
engine_count: number;
|
||||||
|
length_ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RocketConfig {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
name_zh: string | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
country: string | null;
|
||||||
|
launch_site_name: string;
|
||||||
|
launch_latitude_deg: number;
|
||||||
|
launch_longitude_deg: number;
|
||||||
|
description: string | null;
|
||||||
|
color: string;
|
||||||
|
height_m: number;
|
||||||
|
diameter_m: number;
|
||||||
|
payload_mass_kg: number;
|
||||||
|
drag_coefficient: number;
|
||||||
|
reference_area_m2: number;
|
||||||
|
target_orbit_km: number;
|
||||||
|
target_velocity_mps: number;
|
||||||
|
separation_delay_seconds: number;
|
||||||
|
second_stage_ignition_delay_seconds: number;
|
||||||
|
stage_1: RocketStage;
|
||||||
|
stage_2: RocketStage;
|
||||||
|
is_active: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlightPhase =
|
||||||
|
| 'ready'
|
||||||
|
| 'stage1_burn'
|
||||||
|
| 'stage1_cutoff'
|
||||||
|
| 'separating'
|
||||||
|
| 'stage2_ignition'
|
||||||
|
| 'stage2_burn'
|
||||||
|
| 'orbit'
|
||||||
|
| 'payload_deploy'
|
||||||
|
| 'mission_complete';
|
||||||
|
|
||||||
|
export interface FlightEvent {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryPoint {
|
||||||
|
time: number;
|
||||||
|
altitude: number;
|
||||||
|
downrange: number;
|
||||||
|
velocity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationState {
|
||||||
|
time: number;
|
||||||
|
altitude: number;
|
||||||
|
downrange: number;
|
||||||
|
velocity: number;
|
||||||
|
horizontalVelocity: number;
|
||||||
|
verticalVelocity: number;
|
||||||
|
acceleration: number;
|
||||||
|
pitch: number;
|
||||||
|
dynamicPressure: number;
|
||||||
|
stage1Fuel: number;
|
||||||
|
stage2Fuel: number;
|
||||||
|
throttle: number;
|
||||||
|
phase: FlightPhase;
|
||||||
|
phaseStartedAt: number;
|
||||||
|
isRunning: boolean;
|
||||||
|
events: FlightEvent[];
|
||||||
|
history: TelemetryPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHASE_LABELS: Record<FlightPhase, string> = {
|
||||||
|
ready: '等待点火',
|
||||||
|
stage1_burn: '一级动力飞行',
|
||||||
|
stage1_cutoff: '一级发动机关机',
|
||||||
|
separating: '一二级分离',
|
||||||
|
stage2_ignition: '二级发动机点火',
|
||||||
|
stage2_burn: '二级动力飞行',
|
||||||
|
orbit: '轨道注入',
|
||||||
|
payload_deploy: '载荷部署',
|
||||||
|
mission_complete: '任务完成',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { FlightEvent, FlightPhase, RocketConfig, SimulationState } from './types';
|
||||||
|
|
||||||
|
const G0 = 9.80665;
|
||||||
|
const EARTH_RADIUS = 6_371_000;
|
||||||
|
const SEA_LEVEL_DENSITY = 1.225;
|
||||||
|
const SCALE_HEIGHT = 8_500;
|
||||||
|
|
||||||
|
function initialState(rocket: RocketConfig): SimulationState {
|
||||||
|
return {
|
||||||
|
time: 0,
|
||||||
|
altitude: 0,
|
||||||
|
downrange: 0,
|
||||||
|
velocity: 0,
|
||||||
|
horizontalVelocity: 0,
|
||||||
|
verticalVelocity: 0,
|
||||||
|
acceleration: 0,
|
||||||
|
pitch: 90,
|
||||||
|
dynamicPressure: 0,
|
||||||
|
stage1Fuel: rocket.stage_1.fuel_mass_kg,
|
||||||
|
stage2Fuel: rocket.stage_2.fuel_mass_kg,
|
||||||
|
throttle: 1,
|
||||||
|
phase: 'ready',
|
||||||
|
phaseStartedAt: 0,
|
||||||
|
isRunning: false,
|
||||||
|
events: [],
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent(events: FlightEvent[], id: string, label: string, time: number) {
|
||||||
|
if (events.some((event) => event.id === id)) return events;
|
||||||
|
return [...events, { id, label, time }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRocketSimulation(rocket: RocketConfig) {
|
||||||
|
const [state, setState] = useState(() => initialState(rocket));
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
const rocketRef = useRef(rocket);
|
||||||
|
const frameRef = useRef(0);
|
||||||
|
const lastFrameRef = useRef(0);
|
||||||
|
const maxQRef = useRef(0);
|
||||||
|
|
||||||
|
const commit = useCallback((next: SimulationState) => {
|
||||||
|
stateRef.current = next;
|
||||||
|
setState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
const next = initialState(rocketRef.current);
|
||||||
|
lastFrameRef.current = performance.now();
|
||||||
|
maxQRef.current = 0;
|
||||||
|
commit(next);
|
||||||
|
}, [commit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rocketRef.current = rocket;
|
||||||
|
reset();
|
||||||
|
}, [rocket, reset]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
const current = stateRef.current;
|
||||||
|
if (current.phase === 'mission_complete') {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const launching = current.phase === 'ready';
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
phase: launching ? 'stage1_burn' as FlightPhase : current.phase,
|
||||||
|
phaseStartedAt: launching ? current.time : current.phaseStartedAt,
|
||||||
|
isRunning: !current.isRunning,
|
||||||
|
events: launching
|
||||||
|
? addEvent(current.events, 'liftoff', '点火起飞', current.time)
|
||||||
|
: current.events,
|
||||||
|
};
|
||||||
|
lastFrameRef.current = performance.now();
|
||||||
|
commit(next);
|
||||||
|
}, [commit, reset]);
|
||||||
|
|
||||||
|
const setThrottle = useCallback((throttle: number) => {
|
||||||
|
commit({ ...stateRef.current, throttle: Math.max(0.4, Math.min(1, throttle)) });
|
||||||
|
}, [commit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = (timestamp: number) => {
|
||||||
|
const current = stateRef.current;
|
||||||
|
if (!current.isRunning) {
|
||||||
|
lastFrameRef.current = timestamp;
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.min((timestamp - lastFrameRef.current) / 1000, 0.25);
|
||||||
|
lastFrameRef.current = timestamp;
|
||||||
|
const steps = Math.max(1, Math.ceil(elapsed / 0.04));
|
||||||
|
const dt = elapsed / steps;
|
||||||
|
const next = { ...current, events: [...current.events], history: [...current.history] };
|
||||||
|
const config = rocketRef.current;
|
||||||
|
|
||||||
|
for (let index = 0; index < steps; index += 1) {
|
||||||
|
const phaseElapsed = next.time - next.phaseStartedAt;
|
||||||
|
let activeStage = null;
|
||||||
|
|
||||||
|
if (next.phase === 'stage1_burn') activeStage = config.stage_1;
|
||||||
|
if (next.phase === 'stage2_ignition' || next.phase === 'stage2_burn') activeStage = config.stage_2;
|
||||||
|
|
||||||
|
if (next.phase === 'stage1_cutoff' && phaseElapsed >= 0.8) {
|
||||||
|
next.phase = 'separating';
|
||||||
|
next.phaseStartedAt = next.time;
|
||||||
|
next.events = addEvent(next.events, 'separation', '一二级分离', next.time);
|
||||||
|
} else if (next.phase === 'separating' && phaseElapsed >= config.separation_delay_seconds) {
|
||||||
|
next.phase = 'stage2_ignition';
|
||||||
|
next.phaseStartedAt = next.time;
|
||||||
|
next.events = addEvent(next.events, 'ses1', '二级发动机点火', next.time);
|
||||||
|
} else if (
|
||||||
|
next.phase === 'stage2_ignition' &&
|
||||||
|
phaseElapsed >= config.second_stage_ignition_delay_seconds
|
||||||
|
) {
|
||||||
|
next.phase = 'stage2_burn';
|
||||||
|
next.phaseStartedAt = next.time;
|
||||||
|
} else if (next.phase === 'orbit' && phaseElapsed >= 2) {
|
||||||
|
next.phase = 'payload_deploy';
|
||||||
|
next.phaseStartedAt = next.time;
|
||||||
|
next.events = addEvent(next.events, 'deploy', '载荷分离与太阳翼展开', next.time);
|
||||||
|
} else if (next.phase === 'payload_deploy' && phaseElapsed >= 6) {
|
||||||
|
next.phase = 'mission_complete';
|
||||||
|
next.isRunning = false;
|
||||||
|
next.events = addEvent(next.events, 'complete', '模拟任务完成', next.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage1Attached = next.phase === 'stage1_burn' || next.phase === 'stage1_cutoff';
|
||||||
|
const stage1Mass = stage1Attached ? config.stage_1.dry_mass_kg + next.stage1Fuel : 0;
|
||||||
|
const totalMass = Math.max(
|
||||||
|
1,
|
||||||
|
stage1Mass + config.stage_2.dry_mass_kg + next.stage2Fuel + config.payload_mass_kg,
|
||||||
|
);
|
||||||
|
|
||||||
|
let thrust = 0;
|
||||||
|
if (activeStage) {
|
||||||
|
const ignitionRamp = next.phase === 'stage2_ignition'
|
||||||
|
? Math.min(1, phaseElapsed / Math.max(0.25, config.second_stage_ignition_delay_seconds))
|
||||||
|
: 1;
|
||||||
|
thrust = activeStage.max_thrust_n * next.throttle * ignitionRamp;
|
||||||
|
const burn = thrust / (activeStage.specific_impulse_s * G0) * dt;
|
||||||
|
if (activeStage === config.stage_1) {
|
||||||
|
next.stage1Fuel = Math.max(0, next.stage1Fuel - burn);
|
||||||
|
} else {
|
||||||
|
next.stage2Fuel = Math.max(0, next.stage2Fuel - burn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.phase === 'stage1_burn' && next.stage1Fuel <= 0) {
|
||||||
|
next.phase = 'stage1_cutoff';
|
||||||
|
next.phaseStartedAt = next.time;
|
||||||
|
next.events = addEvent(next.events, 'meco', '一级发动机关机', next.time);
|
||||||
|
thrust = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAltitude = config.target_orbit_km * 1000;
|
||||||
|
const gravityTurnProgress = Math.sqrt(
|
||||||
|
Math.min(1, Math.max(0, next.altitude - 500) / Math.max(1, targetAltitude * 0.58)),
|
||||||
|
);
|
||||||
|
next.pitch = next.altitude < 500 ? 90 : Math.max(3, 90 * (1 - gravityTurnProgress));
|
||||||
|
|
||||||
|
const velocity = Math.hypot(next.horizontalVelocity, next.verticalVelocity);
|
||||||
|
const density = next.altitude > 120_000
|
||||||
|
? 0
|
||||||
|
: SEA_LEVEL_DENSITY * Math.exp(-next.altitude / SCALE_HEIGHT);
|
||||||
|
next.dynamicPressure = 0.5 * density * velocity * velocity;
|
||||||
|
|
||||||
|
// MAX-Q: the moment aerodynamic pressure peaks and begins to fall on
|
||||||
|
// ascent. Track the running peak, then latch the event once q drops
|
||||||
|
// meaningfully below it (after a sensible threshold to avoid noise).
|
||||||
|
if (next.dynamicPressure > maxQRef.current) {
|
||||||
|
maxQRef.current = next.dynamicPressure;
|
||||||
|
} else if (
|
||||||
|
maxQRef.current > 8000 &&
|
||||||
|
next.dynamicPressure < maxQRef.current * 0.92 &&
|
||||||
|
!next.events.some((event) => event.id === 'maxq')
|
||||||
|
) {
|
||||||
|
next.events = addEvent(next.events, 'maxq', '最大动压 MAX-Q', next.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragForce = next.dynamicPressure * config.drag_coefficient * config.reference_area_m2;
|
||||||
|
const pitchRadians = next.pitch * Math.PI / 180;
|
||||||
|
const gravity = G0 * Math.pow(EARTH_RADIUS / (EARTH_RADIUS + next.altitude), 2);
|
||||||
|
const dragX = velocity > 0 ? dragForce * next.horizontalVelocity / velocity : 0;
|
||||||
|
const dragY = velocity > 0 ? dragForce * next.verticalVelocity / velocity : 0;
|
||||||
|
const ax = (thrust * Math.cos(pitchRadians) - dragX) / totalMass;
|
||||||
|
const ay = (thrust * Math.sin(pitchRadians) - dragY) / totalMass - gravity;
|
||||||
|
|
||||||
|
next.horizontalVelocity = Math.max(0, next.horizontalVelocity + ax * dt);
|
||||||
|
next.verticalVelocity += ay * dt;
|
||||||
|
next.downrange += next.horizontalVelocity * dt;
|
||||||
|
next.altitude = Math.max(0, next.altitude + next.verticalVelocity * dt);
|
||||||
|
next.velocity = Math.hypot(next.horizontalVelocity, next.verticalVelocity);
|
||||||
|
next.acceleration = Math.hypot(ax, ay);
|
||||||
|
next.time += dt;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(next.phase === 'stage2_burn' && next.stage2Fuel <= 0) ||
|
||||||
|
(next.altitude >= targetAltitude && next.horizontalVelocity >= config.target_velocity_mps)
|
||||||
|
) {
|
||||||
|
next.phase = 'orbit';
|
||||||
|
next.phaseStartedAt = next.time;
|
||||||
|
next.events = addEvent(next.events, 'seco', '二级发动机关机', next.time);
|
||||||
|
next.events = addEvent(next.events, 'orbit', '进入目标轨道', next.time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPoint = next.history.at(-1);
|
||||||
|
if (!lastPoint || next.time - lastPoint.time >= 1) {
|
||||||
|
next.history = [...next.history.slice(-299), {
|
||||||
|
time: next.time,
|
||||||
|
altitude: next.altitude,
|
||||||
|
downrange: next.downrange,
|
||||||
|
velocity: next.velocity,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
commit(next);
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frameRef.current);
|
||||||
|
}, [commit]);
|
||||||
|
|
||||||
|
return { state, toggle, reset, setThrottle };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export function useOptionalTexture(path?: string | null): THREE.Texture | null {
|
||||||
|
const [loaded, setLoaded] = useState<{ path: string; texture: THREE.Texture } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!path) {
|
||||||
|
// Clearing the path: drop any previously loaded texture and free its GPU memory.
|
||||||
|
setLoaded((prev) => {
|
||||||
|
prev?.texture.dispose();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
let localTexture: THREE.Texture | null = null;
|
||||||
|
|
||||||
|
new THREE.TextureLoader().loadAsync(path)
|
||||||
|
.then((texture) => {
|
||||||
|
if (active) {
|
||||||
|
localTexture = texture;
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
// Replace and dispose the previous texture to avoid leaking GPU memory.
|
||||||
|
setLoaded((prev) => {
|
||||||
|
if (prev && prev.texture !== texture) {
|
||||||
|
prev.texture.dispose();
|
||||||
|
}
|
||||||
|
return { path, texture };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Effect was torn down before load finished: dispose immediately.
|
||||||
|
texture.dispose();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(`Texture unavailable, using fallback material: ${path}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
// If the texture already resolved and belongs to this (now-stale) effect, dispose it.
|
||||||
|
if (localTexture) {
|
||||||
|
localTexture.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
return loaded && loaded.path === path ? loaded.texture : null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { useEffect, useState, type CSSProperties } from 'react';
|
||||||
|
import { ArrowLeft, Camera, ChevronDown, Clock3, Orbit, Pause, Play, RotateCcw, Rocket, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { fetchRocketConfigs } from '../features/rocket-simulator/api';
|
||||||
|
import { PHASE_LABELS, type FlightPhase, type RocketConfig } from '../features/rocket-simulator/types';
|
||||||
|
import { useRocketSimulation } from '../features/rocket-simulator/useRocketSimulation';
|
||||||
|
import { RocketFlightScene, type CameraMode } from '../features/rocket-simulator/RocketFlightScene';
|
||||||
|
import '../features/rocket-simulator/rocket-simulator.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical flight milestones, in order. Each maps to an event id emitted by
|
||||||
|
* the physics loop. This single list is the SOLE source of truth for both the
|
||||||
|
* bottom milestone track and the phase status readout, so they can never
|
||||||
|
* disagree.
|
||||||
|
*/
|
||||||
|
const MILESTONES: Array<{ id: string; code: string; label: string }> = [
|
||||||
|
{ id: 'liftoff', code: 'LIFTOFF', label: '点火起飞' },
|
||||||
|
{ id: 'maxq', code: 'MAX-Q', label: '最大动压' },
|
||||||
|
{ id: 'meco', code: 'MECO', label: '一级关机' },
|
||||||
|
{ id: 'separation', code: 'SEP', label: '级间分离' },
|
||||||
|
{ id: 'ses1', code: 'SES-1', label: '二级点火' },
|
||||||
|
{ id: 'seco', code: 'SECO-1', label: '二级关机' },
|
||||||
|
{ id: 'orbit', code: 'ORBIT', label: '轨道注入' },
|
||||||
|
{ id: 'deploy', code: 'PAYLOAD', label: '载荷部署' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const sign = seconds < 0 ? '-' : '+';
|
||||||
|
const abs = Math.abs(Math.floor(seconds));
|
||||||
|
const h = Math.floor(abs / 3600);
|
||||||
|
const m = Math.floor((abs % 3600) / 60);
|
||||||
|
const s = abs % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `T${sign}${h > 0 ? `${pad(h)}:` : ''}${pad(m)}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function environmentAt(altitude: number, targetOrbitKm: number) {
|
||||||
|
if (altitude < 1_000) return { code: 'GROUND', label: '地面与近地层', detail: '对流层 · 稠密大气' };
|
||||||
|
if (altitude < 12_000) return { code: 'TROPOSPHERE', label: '对流层', detail: '气动载荷区' };
|
||||||
|
if (altitude < 50_000) return { code: 'STRATOSPHERE', label: '平流层', detail: '大气快速变稀' };
|
||||||
|
if (altitude < 100_000) return { code: 'MESOSPHERE', label: '高层大气', detail: '接近卡门线' };
|
||||||
|
if (altitude < targetOrbitKm * 950) return { code: 'SPACE', label: '近地太空', detail: '卡门线以上' };
|
||||||
|
return { code: 'LEO', label: '低地球轨道', detail: `目标高度 ${targetOrbitKm.toFixed(0)} km` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArcGauge({ label, value, unit, fill }: { label: string; value: string; unit: string; fill: number }) {
|
||||||
|
const progress = Math.max(0, Math.min(1, fill));
|
||||||
|
const needleAngle = -90 + progress * 180;
|
||||||
|
return (
|
||||||
|
<div className="sx-arc-gauge">
|
||||||
|
<svg viewBox="0 0 120 72" aria-hidden="true">
|
||||||
|
<path className="sx-arc-track" d="M 10 62 A 50 50 0 0 1 110 62" pathLength="100" />
|
||||||
|
<path className="sx-arc-value" d="M 10 62 A 50 50 0 0 1 110 62" pathLength="100" style={{ strokeDasharray: `${progress * 100} 100` }} />
|
||||||
|
<line className="sx-arc-needle" x1="60" y1="62" x2="60" y2="21" transform={`rotate(${needleAngle} 60 62)`} />
|
||||||
|
<circle cx="60" cy="62" r="3" />
|
||||||
|
</svg>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
<small>{unit}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EngineGauge({ stage, total, active }: { stage: 1 | 2; total: number; active: number }) {
|
||||||
|
const outerCount = Math.max(0, total - 1);
|
||||||
|
return (
|
||||||
|
<div className="sx-engine-gauge" aria-label={`${stage}级发动机 ${active}/${total} 点火`}>
|
||||||
|
<div className="sx-engine-ring">
|
||||||
|
<i className={`sx-engine-center ${active > 0 ? 'active' : ''}`} />
|
||||||
|
{Array.from({ length: outerCount }, (_, index) => (
|
||||||
|
<i
|
||||||
|
key={index}
|
||||||
|
className={`sx-engine-outer ${index + 1 < active ? 'active' : ''}`}
|
||||||
|
style={{ '--engine-angle': `${index / outerCount * 360}deg` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span>{stage === 1 ? '一级发动机' : '二级发动机'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttitudeGauge({ pitch }: { pitch: number }) {
|
||||||
|
const vehicleRotation = 45 - pitch;
|
||||||
|
return (
|
||||||
|
<div className="sx-attitude-gauge" aria-label={`火箭俯仰角 ${pitch.toFixed(0)} 度`}>
|
||||||
|
<div className="sx-attitude-dial">
|
||||||
|
<i className="sx-attitude-axis horizontal" />
|
||||||
|
<i className="sx-attitude-axis vertical" />
|
||||||
|
<div className="sx-attitude-vehicle" style={{ transform: `translate(-50%, -50%) rotate(${vehicleRotation}deg)` }}>
|
||||||
|
<Rocket size={24} strokeWidth={1.6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span>PITCH</span>
|
||||||
|
<strong>{pitch.toFixed(0)}°</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RocketMission({ rockets }: { rockets: RocketConfig[] }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [selectedCode, setSelectedCode] = useState(rockets[0].code);
|
||||||
|
const [cameraMode, setCameraMode] = useState<CameraMode>('follow');
|
||||||
|
const [viewScale, setViewScale] = useState(100);
|
||||||
|
const [viewScaleResetTrigger, setViewScaleResetTrigger] = useState(0);
|
||||||
|
const rocket = rockets.find((item) => item.code === selectedCode) || rockets[0];
|
||||||
|
const { state, toggle, reset } = useRocketSimulation(rocket);
|
||||||
|
const environment = environmentAt(state.altitude, rocket.target_orbit_km);
|
||||||
|
|
||||||
|
const totalFuel = rocket.stage_1.fuel_mass_kg + rocket.stage_2.fuel_mass_kg;
|
||||||
|
const remainingFuel = state.stage1Fuel + state.stage2Fuel;
|
||||||
|
const stage1FuelPct = Math.max(0, state.stage1Fuel / rocket.stage_1.fuel_mass_kg * 100);
|
||||||
|
const stage2FuelPct = Math.max(0, state.stage2Fuel / rocket.stage_2.fuel_mass_kg * 100);
|
||||||
|
const totalFuelPct = Math.max(0, remainingFuel / totalFuel * 100);
|
||||||
|
const eventTime = (id: string) => state.events.find((event) => event.id === id)?.time ?? null;
|
||||||
|
const nextMilestone = MILESTONES.find((m) => eventTime(m.id) === null);
|
||||||
|
const stage: 1 | 2 = ['separating', 'stage2_ignition', 'stage2_burn', 'orbit', 'payload_deploy', 'mission_complete'].includes(state.phase) ? 2 : 1;
|
||||||
|
const stageConfig = stage === 1 ? rocket.stage_1 : rocket.stage_2;
|
||||||
|
const stageFiring = stage === 1
|
||||||
|
? state.phase === 'stage1_burn'
|
||||||
|
: state.phase === 'stage2_ignition' || state.phase === 'stage2_burn';
|
||||||
|
const activeEngines = stageFiring ? stageConfig.engine_count : 0;
|
||||||
|
|
||||||
|
const launchLabel = state.phase === 'mission_complete'
|
||||||
|
? '重新模拟'
|
||||||
|
: state.isRunning
|
||||||
|
? '暂停'
|
||||||
|
: state.phase === 'ready'
|
||||||
|
? '执行点火'
|
||||||
|
: '继续';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="rocket-simulator-page">
|
||||||
|
<RocketFlightScene
|
||||||
|
rocket={rocket}
|
||||||
|
state={state}
|
||||||
|
cameraMode={cameraMode}
|
||||||
|
viewScale={viewScale}
|
||||||
|
viewScaleResetTrigger={viewScaleResetTrigger}
|
||||||
|
onViewScaleChange={setViewScale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top bar: brand + mission clock + rocket selector + view controls */}
|
||||||
|
<header className="sx-topbar">
|
||||||
|
<button className="sx-icon-btn" onClick={() => navigate('/')} title="返回首页" aria-label="返回首页"><ArrowLeft size={18} /></button>
|
||||||
|
<div className="sx-brand"><Rocket size={18} /><div><strong>COSMO LAUNCH</strong><span>{rocket.name_zh || rocket.name} · {rocket.name}</span></div></div>
|
||||||
|
|
||||||
|
<div className="sx-mission-status">
|
||||||
|
<span className={state.isRunning ? 'sx-live' : ''} />
|
||||||
|
<div><small>MISSION STATUS</small><strong>{PHASE_LABELS[state.phase as FlightPhase]}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-topbar-right">
|
||||||
|
<label className="sx-select">
|
||||||
|
<span>运载器 / VEHICLE</span>
|
||||||
|
<div><select value={selectedCode} onChange={(event) => setSelectedCode(event.target.value)}>
|
||||||
|
{rockets.map((item) => <option key={item.code} value={item.code}>{item.name_zh || item.name}</option>)}
|
||||||
|
</select><ChevronDown size={13} /></div>
|
||||||
|
</label>
|
||||||
|
<div className="sx-seg" role="group" aria-label="视角模式">
|
||||||
|
<button className={cameraMode === 'follow' ? 'active' : ''} onClick={() => setCameraMode('follow')} title="跟随视角"><Camera size={14} />跟随</button>
|
||||||
|
<button className={cameraMode === 'global' ? 'active' : ''} onClick={() => setCameraMode('global')} title="全局视角"><Orbit size={14} />全局</button>
|
||||||
|
</div>
|
||||||
|
<label className="sx-camera-slider" title="调整画面缩放比例">
|
||||||
|
<ZoomOut size={13} />
|
||||||
|
<input aria-label="画面缩放比例" type="range" min="50" max="250" step="5" value={viewScale} onChange={(event) => { setViewScale(Number(event.target.value)); setViewScaleResetTrigger((value) => value + 1); }} />
|
||||||
|
<span>{viewScale}%</span>
|
||||||
|
<ZoomIn size={13} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="sx-environment" aria-live="polite">
|
||||||
|
<span>{environment.code}</span>
|
||||||
|
<strong>{environment.label}</strong>
|
||||||
|
<small>{rocket.launch_site_name} · {environment.detail}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Minimal telemetry side panel */}
|
||||||
|
<aside className="sx-side">
|
||||||
|
<div className="sx-panel-title"><span>FLIGHT DATA</span><b>飞行遥测</b></div>
|
||||||
|
<div className="sx-side-row"><span>加速度</span><strong>{(state.acceleration / 9.80665).toFixed(2)}<em>g</em></strong></div>
|
||||||
|
<div className="sx-side-row"><span>动压 Q</span><strong>{(state.dynamicPressure / 1000).toFixed(1)}<em>kPa</em></strong></div>
|
||||||
|
<div className="sx-side-row"><span>地面射程</span><strong>{(state.downrange / 1000).toFixed(1)}<em>km</em></strong></div>
|
||||||
|
<div className="sx-side-divider" />
|
||||||
|
<div className="sx-fuel">
|
||||||
|
<div className="sx-fuel-head"><span>推进剂余量</span><strong>{totalFuelPct.toFixed(0)}%</strong></div>
|
||||||
|
<div className="sx-fuel-row"><span>一级</span><div className="sx-fuel-track"><i className="s1" style={{ width: `${stage1FuelPct}%` }} /></div><b>{stage1FuelPct.toFixed(0)}%</b></div>
|
||||||
|
<div className="sx-fuel-row"><span>二级</span><div className="sx-fuel-track"><i className="s2" style={{ width: `${stage2FuelPct}%` }} /></div><b>{stage2FuelPct.toFixed(0)}%</b></div>
|
||||||
|
<div className="sx-fuel-total-row"><span>总推进剂</span><b>{totalFuelPct.toFixed(0)}%</b></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Bottom: milestone track (single source of truth) + telemetry strip + controls */}
|
||||||
|
<div className="sx-bottom">
|
||||||
|
<div className="sx-track" aria-label="飞行里程碑">
|
||||||
|
{MILESTONES.map((m) => {
|
||||||
|
const time = eventTime(m.id);
|
||||||
|
const reached = time !== null;
|
||||||
|
const isNext = nextMilestone?.id === m.id && state.isRunning;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={`sx-node ${reached ? 'reached' : ''} ${isNext ? 'next' : ''}`}>
|
||||||
|
<i />
|
||||||
|
<b>{m.code}</b>
|
||||||
|
<time>{reached ? formatTime(time as number).replace('T+', '+') : '--:--'}</time>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-hud">
|
||||||
|
<div className="sx-gauge-cluster">
|
||||||
|
<ArcGauge label="SPEED" value={(state.velocity * 3.6).toFixed(0)} unit="KM/H" fill={state.velocity * 3.6 / 28000} />
|
||||||
|
<ArcGauge label="ALTITUDE" value={(state.altitude / 1000).toFixed(1)} unit="KM" fill={state.altitude / (rocket.target_orbit_km * 1000)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-clock">
|
||||||
|
<div className="sx-clock-time">{formatTime(state.time)}</div>
|
||||||
|
<div className="sx-realtime"><Clock3 size={12} />REAL TIME</div>
|
||||||
|
<div className="sx-controls">
|
||||||
|
<button className="sx-icon-btn" onClick={reset} title="重置" aria-label="重置"><RotateCcw size={16} /></button>
|
||||||
|
<button className="sx-launch" onClick={toggle}>{state.isRunning ? <Pause size={15} /> : <Play size={15} />}{launchLabel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-gauge-cluster sx-gauge-cluster-right">
|
||||||
|
<AttitudeGauge pitch={state.pitch} />
|
||||||
|
<EngineGauge stage={stage} total={stageConfig.engine_count} active={activeEngines} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RocketSimulator() {
|
||||||
|
const [rockets, setRockets] = useState<RocketConfig[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRocketConfigs().then(setRockets).catch(() => setError('火箭配置加载失败,请确认数据库升级脚本已执行。'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <div className="rocket-page-message"><Rocket size={36} /><strong>{error}</strong><a href="/">返回首页</a></div>;
|
||||||
|
if (rockets.length === 0) return <div className="rocket-page-message"><Rocket className="loading-rocket" size={36} /><strong>正在载入发射任务</strong></div>;
|
||||||
|
return <RocketMission rockets={rockets} />;
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
RocketOutlined,
|
RocketOutlined,
|
||||||
AppstoreOutlined,
|
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
|
|
@ -22,7 +21,7 @@ import {
|
||||||
StarOutlined,
|
StarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { authAPI, API_BASE_URL } from '../../utils/request';
|
import { authAPI } from '../../utils/request';
|
||||||
import { auth } from '../../utils/auth';
|
import { auth } from '../../utils/auth';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
|
||||||
|
|
@ -40,12 +39,12 @@ const iconMap: Record<string, any> = {
|
||||||
sliders: <ControlOutlined />,
|
sliders: <ControlOutlined />,
|
||||||
profile: <IdcardOutlined />,
|
profile: <IdcardOutlined />,
|
||||||
star: <StarOutlined />,
|
star: <StarOutlined />,
|
||||||
|
rocket: <RocketOutlined />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [menus, setMenus] = useState<any[]>([]);
|
const [menus, setMenus] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [user, setUser] = useState<any>(auth.getUser());
|
const [user, setUser] = useState<any>(auth.getUser());
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -90,8 +89,6 @@ export function AdminLayout() {
|
||||||
setMenus(data);
|
setMenus(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('加载菜单失败');
|
toast.error('加载菜单失败');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,189 +1,104 @@
|
||||||
/**
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
* NASA Data Download Page
|
import { Badge, Modal } from 'antd';
|
||||||
* Downloads position data for celestial bodies from NASA Horizons API
|
|
||||||
*/
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
DatePicker,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Spin,
|
|
||||||
Typography,
|
|
||||||
Collapse,
|
|
||||||
Space,
|
|
||||||
Progress,
|
|
||||||
Calendar,
|
|
||||||
Alert,
|
|
||||||
Tag,
|
|
||||||
Modal,
|
|
||||||
Table
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
DownloadOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
LoadingOutlined,
|
|
||||||
DeleteOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
import isBetween from 'dayjs/plugin/isBetween';
|
||||||
import { request } from '../../utils/request';
|
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useDataCutoffDate } from '../../hooks/useDataCutoffDate';
|
import { useDataCutoffDate } from '../../hooks/useDataCutoffDate';
|
||||||
|
import { request } from '../../utils/request';
|
||||||
|
import { NasaDownloadView } from './nasa-download/NasaDownloadView';
|
||||||
|
import type { CelestialBody, DateRange, GroupedBodies, PositionRow, ViewingDateData } from './nasa-download/types';
|
||||||
|
|
||||||
// Extend dayjs with isBetween plugin
|
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
|
|
||||||
interface CelestialBody {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
name_zh: string;
|
|
||||||
type: string;
|
|
||||||
is_active: boolean;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedBodies {
|
|
||||||
[type: string]: CelestialBody[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NASADownload() {
|
export function NASADownload() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [bodies, setBodies] = useState<GroupedBodies>({});
|
const [bodies, setBodies] = useState<GroupedBodies>({});
|
||||||
const [selectedBodies, setSelectedBodies] = useState<string[]>([]);
|
const [selectedBodies, setSelectedBodies] = useState<string[]>([]);
|
||||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
const [dateRange, setDateRange] = useState<DateRange>([dayjs().startOf('month'), dayjs().endOf('month')]);
|
||||||
dayjs().startOf('month'),
|
|
||||||
dayjs().endOf('month')
|
|
||||||
]);
|
|
||||||
const [availableDates, setAvailableDates] = useState<Map<string, Set<string>>>(new Map());
|
const [availableDates, setAvailableDates] = useState<Map<string, Set<string>>>(new Map());
|
||||||
const [loadingDates, setLoadingDates] = useState(false);
|
const [loadingDates, setLoadingDates] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [downloadProgress, setDownloadProgress] = useState({ current: 0, total: 0 });
|
const [downloadProgress, setDownloadProgress] = useState({ current: 0, total: 0 });
|
||||||
const [activeBodyForCalendar, setActiveBodyForCalendar] = useState<string | null>(null);
|
const [activeBodyForCalendar, setActiveBodyForCalendar] = useState<string | null>(null);
|
||||||
const [viewingDateData, setViewingDateData] = useState<{date: string; bodies: any[]} | null>(null);
|
const [viewingDateData, setViewingDateData] = useState<ViewingDateData | null>(null);
|
||||||
const [loadingDateData, setLoadingDateData] = useState(false);
|
const [loadingDateData, setLoadingDateData] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// Get data cutoff date
|
|
||||||
const { cutoffDate } = useDataCutoffDate();
|
const { cutoffDate } = useDataCutoffDate();
|
||||||
|
|
||||||
// Type name mapping
|
const loadBodies = useCallback(async () => {
|
||||||
const typeNames: Record<string, string> = {
|
|
||||||
star: '恒星',
|
|
||||||
planet: '行星',
|
|
||||||
dwarf_planet: '矮行星',
|
|
||||||
satellite: '卫星',
|
|
||||||
comet: '彗星',
|
|
||||||
probe: '探测器',
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeOrder = ['star', 'planet', 'dwarf_planet', 'satellite', 'comet', 'probe'];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadBodies();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedBodies.length > 0) {
|
|
||||||
loadAvailableDates();
|
|
||||||
// Set first selected body as active for calendar display
|
|
||||||
if (!activeBodyForCalendar || !selectedBodies.includes(activeBodyForCalendar)) {
|
|
||||||
setActiveBodyForCalendar(selectedBodies[0]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAvailableDates(new Map());
|
|
||||||
setActiveBodyForCalendar(null);
|
|
||||||
}
|
|
||||||
}, [selectedBodies, dateRange]);
|
|
||||||
|
|
||||||
const loadBodies = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await request.get('/celestial/positions/download/bodies');
|
const { data } = await request.get('/celestial/positions/download/bodies');
|
||||||
|
|
||||||
// 只显示激活的天体 (is_active = true)
|
|
||||||
const activeBodies: GroupedBodies = {};
|
const activeBodies: GroupedBodies = {};
|
||||||
Object.keys(data.bodies || {}).forEach((type) => {
|
Object.keys(data.bodies || {}).forEach((type) => {
|
||||||
const bodiesOfType = data.bodies[type].filter((body: CelestialBody) => body.is_active);
|
const active = data.bodies[type].filter((body: CelestialBody) => body.is_active);
|
||||||
if (bodiesOfType.length > 0) {
|
if (active.length > 0) activeBodies[type] = active;
|
||||||
activeBodies[type] = bodiesOfType;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setBodies(activeBodies);
|
setBodies(activeBodies);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('加载天体列表失败');
|
toast.error('加载天体列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [toast]);
|
||||||
|
|
||||||
const loadAvailableDates = async () => {
|
const loadAvailableDates = useCallback(async () => {
|
||||||
if (selectedBodies.length === 0) return;
|
if (selectedBodies.length === 0) return;
|
||||||
|
|
||||||
setLoadingDates(true);
|
setLoadingDates(true);
|
||||||
try {
|
try {
|
||||||
const newAvailableDates = new Map<string, Set<string>>();
|
const datesByBody = new Map<string, Set<string>>();
|
||||||
|
|
||||||
// Load available dates for ALL selected bodies
|
|
||||||
for (const bodyId of selectedBodies) {
|
for (const bodyId of selectedBodies) {
|
||||||
const startDate = dateRange[0].format('YYYY-MM-DD');
|
|
||||||
const endDate = dateRange[1].format('YYYY-MM-DD');
|
|
||||||
|
|
||||||
const { data } = await request.get('/celestial/positions/download/status', {
|
const { data } = await request.get('/celestial/positions/download/status', {
|
||||||
params: {
|
params: { body_id: bodyId, start_date: dateRange[0].format('YYYY-MM-DD'), end_date: dateRange[1].format('YYYY-MM-DD') },
|
||||||
body_id: bodyId,
|
|
||||||
start_date: startDate,
|
|
||||||
end_date: endDate
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
datesByBody.set(bodyId, new Set<string>(data.available_dates));
|
||||||
const bodyDates = new Set<string>();
|
|
||||||
data.available_dates.forEach((date: string) => {
|
|
||||||
bodyDates.add(date);
|
|
||||||
});
|
|
||||||
newAvailableDates.set(bodyId, bodyDates);
|
|
||||||
}
|
}
|
||||||
|
setAvailableDates(datesByBody);
|
||||||
setAvailableDates(newAvailableDates);
|
} catch {
|
||||||
} catch (error) {
|
|
||||||
toast.error('加载数据状态失败');
|
toast.error('加载数据状态失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDates(false);
|
setLoadingDates(false);
|
||||||
}
|
}
|
||||||
};
|
}, [dateRange, selectedBodies, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBodies();
|
||||||
|
}, [loadBodies]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBodies.length === 0) {
|
||||||
|
setAvailableDates(new Map());
|
||||||
|
setActiveBodyForCalendar(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadAvailableDates();
|
||||||
|
if (!activeBodyForCalendar || !selectedBodies.includes(activeBodyForCalendar)) setActiveBodyForCalendar(selectedBodies[0]);
|
||||||
|
}, [activeBodyForCalendar, dateRange, loadAvailableDates, selectedBodies]);
|
||||||
|
|
||||||
const handleBodySelect = (bodyId: string, checked: boolean) => {
|
const handleBodySelect = (bodyId: string, checked: boolean) => {
|
||||||
setSelectedBodies(prev =>
|
setSelectedBodies((current) => checked ? [...current, bodyId] : current.filter((id) => id !== bodyId));
|
||||||
checked ? [...prev, bodyId] : prev.filter(id => id !== bodyId)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTypeSelectAll = (type: string, checked: boolean) => {
|
const handleTypeSelectAll = (type: string, checked: boolean) => {
|
||||||
const typeBodyIds = bodies[type].map(b => b.id);
|
const bodyIds = bodies[type].map((body) => body.id);
|
||||||
setSelectedBodies(prev => {
|
setSelectedBodies((current) => checked ? [...new Set([...current, ...bodyIds])] : current.filter((id) => !bodyIds.includes(id)));
|
||||||
if (checked) {
|
|
||||||
return [...new Set([...prev, ...typeBodyIds])];
|
|
||||||
} else {
|
|
||||||
return prev.filter(id => !typeBodyIds.includes(id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (dates: any) => {
|
const handleDateRangeChange = (dates: [Dayjs | null, Dayjs | null] | null) => {
|
||||||
if (dates && dates[0] && dates[1]) {
|
if (dates?.[0] && dates[1]) setDateRange([dates[0], dates[1]]);
|
||||||
setDateRange([dates[0], dates[1]]);
|
};
|
||||||
|
|
||||||
|
const getDatesInRange = (excludeFuture: boolean) => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = dateRange[0];
|
||||||
|
while (current.isBefore(dateRange[1]) || current.isSame(dateRange[1], 'day')) {
|
||||||
|
if (!excludeFuture || !current.isAfter(dayjs(), 'day')) result.push(current.format('YYYY-MM-DD'));
|
||||||
|
current = current.add(1, 'day');
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (selectedDate?: Dayjs) => {
|
const handleDownload = async (selectedDate?: Dayjs) => {
|
||||||
|
|
@ -191,65 +106,34 @@ export function NASADownload() {
|
||||||
toast.warning('请先选择至少一个天体');
|
toast.warning('请先选择至少一个天体');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedDate?.isAfter(dayjs(), 'day')) {
|
||||||
|
toast.warning('不能下载未来日期的数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let datesToDownload: string[] = [];
|
const dates = selectedDate ? [selectedDate.format('YYYY-MM-DD')] : getDatesInRange(true);
|
||||||
const today = dayjs();
|
if (dates.length === 0) {
|
||||||
|
toast.warning('所选日期范围内没有可下载的数据');
|
||||||
if (selectedDate) {
|
return;
|
||||||
// Download single date - check if it's not in the future
|
|
||||||
if (selectedDate.isAfter(today, 'day')) {
|
|
||||||
toast.warning('不能下载未来日期的数据');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
datesToDownload = [selectedDate.format('YYYY-MM-DD')];
|
|
||||||
} else {
|
|
||||||
// Download all dates in range - exclude future dates
|
|
||||||
const start = dateRange[0];
|
|
||||||
const end = dateRange[1];
|
|
||||||
let current = start;
|
|
||||||
|
|
||||||
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
|
||||||
// Only include dates up to today
|
|
||||||
if (!current.isAfter(today, 'day')) {
|
|
||||||
datesToDownload.push(current.format('YYYY-MM-DD'));
|
|
||||||
}
|
|
||||||
current = current.add(1, 'day');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (datesToDownload.length === 0) {
|
|
||||||
toast.warning('所选日期范围内没有可下载的数据(不能下载未来日期)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
setDownloadProgress({ current: 0, total: datesToDownload.length });
|
setDownloadProgress({ current: 0, total: dates.length });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
// Sync download for single date
|
const { data } = await request.post('/celestial/positions/download', { body_ids: selectedBodies, dates });
|
||||||
const { data } = await request.post('/celestial/positions/download', {
|
setDownloadProgress({ current: dates.length, total: dates.length });
|
||||||
body_ids: selectedBodies,
|
|
||||||
dates: datesToDownload
|
|
||||||
});
|
|
||||||
|
|
||||||
setDownloadProgress({ current: datesToDownload.length, total: datesToDownload.length });
|
|
||||||
|
|
||||||
if (data.total_success > 0) {
|
if (data.total_success > 0) {
|
||||||
toast.success(`成功下载 ${data.total_success} 条数据${data.total_failed > 0 ? `,${data.total_failed} 条失败` : ''}`);
|
toast.success(`成功下载 ${data.total_success} 条数据${data.total_failed > 0 ? `,${data.total_failed} 条失败` : ''}`);
|
||||||
loadAvailableDates();
|
await loadAvailableDates();
|
||||||
} else {
|
} else {
|
||||||
toast.error('下载失败');
|
toast.error('下载失败');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Async download for range
|
await request.post('/celestial/positions/download-async', { body_ids: selectedBodies, dates });
|
||||||
await request.post('/celestial/positions/download-async', {
|
|
||||||
body_ids: selectedBodies,
|
|
||||||
dates: datesToDownload
|
|
||||||
});
|
|
||||||
toast.success('批量下载任务已启动,请前往“系统任务”查看进度');
|
toast.success('批量下载任务已启动,请前往“系统任务”查看进度');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('请求失败');
|
toast.error('请求失败');
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
|
|
@ -257,423 +141,103 @@ export function NASADownload() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
if (selectedBodies.length === 0) {
|
if (selectedBodies.length === 0) {
|
||||||
toast.warning('请先选择至少一个天体');
|
toast.warning('请先选择至少一个天体');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const dates = getDatesInRange(false);
|
||||||
const start = dateRange[0];
|
if (dates.length === 0) return;
|
||||||
const end = dateRange[1];
|
|
||||||
const datesToDelete: string[] = [];
|
|
||||||
let current = start;
|
|
||||||
|
|
||||||
// Build list of all dates in range (including future dates)
|
|
||||||
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
|
||||||
datesToDelete.push(current.format('YYYY-MM-DD'));
|
|
||||||
current = current.add(1, 'day');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (datesToDelete.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: `确定要删除 ${selectedBodies.length} 个天体在 ${datesToDelete.length} 天内的所有位置数据吗?此操作不可恢复。`,
|
content: `确定要删除 ${selectedBodies.length} 个天体在 ${dates.length} 天内的所有位置数据吗?此操作不可恢复。`,
|
||||||
okText: '确认删除',
|
okText: '确认删除',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await request.post('/celestial/positions/delete', {
|
const { data } = await request.post('/celestial/positions/delete', { body_ids: selectedBodies, dates });
|
||||||
body_ids: selectedBodies,
|
|
||||||
dates: datesToDelete
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(data.message || '数据已删除');
|
toast.success(data.message || '数据已删除');
|
||||||
loadAvailableDates(); // Refresh status
|
await loadAvailableDates();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('删除失败');
|
toast.error('删除失败');
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom calendar cell renderer
|
|
||||||
const dateCellRender = (value: Dayjs) => {
|
const dateCellRender = (value: Dayjs) => {
|
||||||
const dateStr = value.format('YYYY-MM-DD');
|
if (!activeBodyForCalendar || !value.isBetween(dateRange[0], dateRange[1], 'day', '[]')) return null;
|
||||||
const inRange = value.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
return <div style={{ textAlign: 'center', padding: '4px 0' }}><Badge status={availableDates.get(activeBodyForCalendar)?.has(value.format('YYYY-MM-DD')) ? 'success' : 'default'} text="" /></div>;
|
||||||
|
|
||||||
if (!inRange || !activeBodyForCalendar) return null;
|
|
||||||
|
|
||||||
const bodyDates = availableDates.get(activeBodyForCalendar);
|
|
||||||
const hasData = bodyDates?.has(dateStr) || false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '4px 0' }}>
|
|
||||||
{hasData ? (
|
|
||||||
<Badge status="success" text="" />
|
|
||||||
) : (
|
|
||||||
<Badge status="default" text="" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabledDate = (current: Dayjs) => {
|
const loadDateData = async (date: string) => {
|
||||||
// Cannot select dates in the future
|
|
||||||
return current && current.isAfter(dayjs(), 'day');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCalendarDateClick = async (date: Dayjs) => {
|
|
||||||
const dateStr = date.format('YYYY-MM-DD');
|
|
||||||
const inRange = date.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
|
||||||
|
|
||||||
if (!inRange) {
|
|
||||||
toast.warning('请选择在日期范围内的日期');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (date.isAfter(dayjs(), 'day')) {
|
|
||||||
toast.warning('不能下载未来日期的数据');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!activeBodyForCalendar) {
|
|
||||||
toast.warning('请先选择天体');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyDates = availableDates.get(activeBodyForCalendar);
|
|
||||||
const hasData = bodyDates?.has(dateStr) || false;
|
|
||||||
|
|
||||||
if (hasData) {
|
|
||||||
// Load and display data for this date
|
|
||||||
await loadDateData(dateStr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBodies.length === 0) {
|
|
||||||
toast.warning('请先选择天体');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDownload(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadDateData = async (dateStr: string) => {
|
|
||||||
setLoadingDateData(true);
|
setLoadingDateData(true);
|
||||||
try {
|
try {
|
||||||
// Query all selected bodies at once (comma-separated)
|
const bodyIds = selectedBodies.filter((bodyId) => availableDates.get(bodyId)?.has(date));
|
||||||
const bodyIdsToQuery = selectedBodies.filter(bodyId => {
|
if (bodyIds.length === 0) {
|
||||||
const bodyDates = availableDates.get(bodyId);
|
setViewingDateData({ date, bodies: [] });
|
||||||
return bodyDates?.has(dateStr);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bodyIdsToQuery.length === 0) {
|
|
||||||
setViewingDateData({ date: dateStr, bodies: [] });
|
|
||||||
setLoadingDateData(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { data } = await request.get('/celestial/positions', {
|
||||||
// Query all bodies in one request
|
params: { body_ids: bodyIds.join(','), start_time: `${date}T00:00:00Z`, end_time: `${date}T00:00:00Z` },
|
||||||
const { data } = await request.get('/celestial/positions', {
|
});
|
||||||
params: {
|
const allBodies = Object.values(bodies).flat();
|
||||||
body_ids: bodyIdsToQuery.join(','),
|
const rows: PositionRow[] = [];
|
||||||
start_time: `${dateStr}T00:00:00Z`,
|
data.bodies?.forEach((bodyData: { id: string; name?: string; positions?: PositionRow[] }) => {
|
||||||
end_time: `${dateStr}T00:00:00Z`
|
const position = bodyData.positions?.[0];
|
||||||
}
|
if (!position) return;
|
||||||
});
|
const body = allBodies.find((item) => item.id === bodyData.id);
|
||||||
|
rows.push({ ...position, id: bodyData.id, name: body?.name_zh || body?.name || bodyData.name || bodyData.id });
|
||||||
console.log(`Response for bodies ${bodyIdsToQuery.join(',')}:`, data);
|
});
|
||||||
|
setViewingDateData({ date, bodies: rows });
|
||||||
const bodiesData = [];
|
} catch {
|
||||||
const allBodies = Object.values(bodies).flat();
|
toast.error('加载数据失败');
|
||||||
|
|
||||||
// Process each body in the response
|
|
||||||
if (data.bodies && data.bodies.length > 0) {
|
|
||||||
for (const bodyData of data.bodies) {
|
|
||||||
if (bodyData.positions && bodyData.positions.length > 0) {
|
|
||||||
const body = allBodies.find(b => b.id === bodyData.id);
|
|
||||||
const pos = bodyData.positions[0];
|
|
||||||
bodiesData.push({
|
|
||||||
id: bodyData.id,
|
|
||||||
name: body?.name_zh || body?.name || bodyData.name || bodyData.id,
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
z: pos.z,
|
|
||||||
vx: pos.vx,
|
|
||||||
vy: pos.vy,
|
|
||||||
vz: pos.vz,
|
|
||||||
time: pos.time
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Processed bodies data:', bodiesData);
|
|
||||||
setViewingDateData({ date: dateStr, bodies: bodiesData });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to load data:`, error);
|
|
||||||
toast.error('加载数据失败');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDateData(false);
|
setLoadingDateData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCalendarDateClick = async (date: Dayjs) => {
|
||||||
|
if (!date.isBetween(dateRange[0], dateRange[1], 'day', '[]')) {
|
||||||
|
toast.warning('请选择在日期范围内的日期');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (date.isAfter(dayjs(), 'day') || !activeBodyForCalendar) return;
|
||||||
|
const dateString = date.format('YYYY-MM-DD');
|
||||||
|
if (availableDates.get(activeBodyForCalendar)?.has(dateString)) await loadDateData(dateString);
|
||||||
|
else await handleDownload(date);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<NasaDownloadView
|
||||||
{/* Data Cutoff Date Display */}
|
cutoffDate={cutoffDate}
|
||||||
{cutoffDate && (
|
loading={loading}
|
||||||
<Alert
|
bodies={bodies}
|
||||||
title={`数据截止日期: ${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`}
|
selectedBodies={selectedBodies}
|
||||||
description="选择左侧天体,右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据(00:00 UTC)。"
|
dateRange={dateRange}
|
||||||
type="success"
|
loadingDates={loadingDates}
|
||||||
showIcon
|
downloading={downloading}
|
||||||
style={{ marginBottom: 16 }}
|
deleting={deleting}
|
||||||
/>
|
downloadProgress={downloadProgress}
|
||||||
)}
|
activeBodyForCalendar={activeBodyForCalendar}
|
||||||
|
viewingDateData={viewingDateData}
|
||||||
<Row gutter={16}>
|
loadingDateData={loadingDateData}
|
||||||
{/* Left: Body Selection */}
|
onBodySelect={handleBodySelect}
|
||||||
<Col span={8}>
|
onTypeSelectAll={handleTypeSelectAll}
|
||||||
<Card
|
onDateRangeChange={handleDateRangeChange}
|
||||||
title="选择天体"
|
onDelete={handleDelete}
|
||||||
loading={loading}
|
onDownload={() => void handleDownload()}
|
||||||
extra={
|
onActiveBodyChange={setActiveBodyForCalendar}
|
||||||
<Text type="secondary">
|
onCalendarDateClick={(date) => void handleCalendarDateClick(date)}
|
||||||
已选择: {selectedBodies.length}
|
onDateCellRender={dateCellRender}
|
||||||
</Text>
|
onCloseDateData={() => setViewingDateData(null)}
|
||||||
}
|
disabledDate={(date) => date.isAfter(dayjs(), 'day')}
|
||||||
>
|
/>
|
||||||
<Collapse
|
|
||||||
defaultActiveKey={[]}
|
|
||||||
items={typeOrder
|
|
||||||
.filter(type => bodies[type] && bodies[type].length > 0)
|
|
||||||
.map((type) => {
|
|
||||||
const typeBodies = bodies[type];
|
|
||||||
return {
|
|
||||||
key: type,
|
|
||||||
label: (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span>{typeNames[type] || type}</span>
|
|
||||||
<Checkbox
|
|
||||||
checked={typeBodies.every(b => selectedBodies.includes(b.id))}
|
|
||||||
indeterminate={
|
|
||||||
typeBodies.some(b => selectedBodies.includes(b.id)) &&
|
|
||||||
!typeBodies.every(b => selectedBodies.includes(b.id))
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleTypeSelectAll(type, e.target.checked);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
全选
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
|
||||||
{typeBodies.map((body) => (
|
|
||||||
<Checkbox
|
|
||||||
key={body.id}
|
|
||||||
checked={selectedBodies.includes(body.id)}
|
|
||||||
onChange={(e) => handleBodySelect(body.id, e.target.checked)}
|
|
||||||
>
|
|
||||||
{body.name_zh || body.name} ({body.id})
|
|
||||||
{!body.is_active && <Badge status="default" text="(未激活)" style={{ marginLeft: 8 }} />}
|
|
||||||
</Checkbox>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* Right: Date Selection and Calendar */}
|
|
||||||
<Col span={16}>
|
|
||||||
<Card
|
|
||||||
title="选择日期"
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<RangePicker
|
|
||||||
value={dateRange}
|
|
||||||
onChange={handleDateRangeChange}
|
|
||||||
disabledDate={disabledDate}
|
|
||||||
format="YYYY-MM-DD"
|
|
||||||
allowClear={false}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={selectedBodies.length === 0 || downloading || deleting}
|
|
||||||
loading={deleting}
|
|
||||||
>
|
|
||||||
清空区段数据
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
onClick={() => handleDownload()}
|
|
||||||
disabled={selectedBodies.length === 0 || downloading || deleting}
|
|
||||||
loading={downloading}
|
|
||||||
>
|
|
||||||
下载区段数据
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Spin spinning={loadingDates} indicator={<LoadingOutlined spin />}>
|
|
||||||
{/* Body selector for calendar view when multiple bodies selected */}
|
|
||||||
{selectedBodies.length > 1 && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Text strong>天体列表:</Text>
|
|
||||||
<Space wrap style={{ marginLeft: 8 }}>
|
|
||||||
{selectedBodies.map(bodyId => {
|
|
||||||
const allBodies = Object.values(bodies).flat();
|
|
||||||
const body = allBodies.find(b => b.id === bodyId);
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
key={bodyId}
|
|
||||||
color={bodyId === activeBodyForCalendar ? 'blue' : 'default'}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => setActiveBodyForCalendar(bodyId)}
|
|
||||||
>
|
|
||||||
{body?.name_zh || body?.name || bodyId} ({bodyId})
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
<div style={{ marginTop: 8, fontSize: '12px', color: '#888' }}>
|
|
||||||
点击标签切换查看不同天体的数据状态
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedBodies.length === 1 && activeBodyForCalendar && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Text strong>当前天体:</Text>
|
|
||||||
<Tag color="blue" style={{ marginLeft: 8 }}>
|
|
||||||
{(() => {
|
|
||||||
const allBodies = Object.values(bodies).flat();
|
|
||||||
const body = allBodies.find(b => b.id === activeBodyForCalendar);
|
|
||||||
return `${body?.name_zh || body?.name || activeBodyForCalendar} (${activeBodyForCalendar})`;
|
|
||||||
})()}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Space>
|
|
||||||
<Badge status="success" text="已有数据" />
|
|
||||||
<Badge status="default" text="无数据(点击下载)" />
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{downloading && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Progress
|
|
||||||
percent={Math.round((downloadProgress.current / downloadProgress.total) * 100)}
|
|
||||||
status="active"
|
|
||||||
/>
|
|
||||||
<Text type="secondary">
|
|
||||||
正在下载: {downloadProgress.current} / {downloadProgress.total}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Calendar
|
|
||||||
fullscreen={false}
|
|
||||||
value={dateRange[0]}
|
|
||||||
onSelect={handleCalendarDateClick}
|
|
||||||
cellRender={dateCellRender}
|
|
||||||
disabledDate={disabledDate}
|
|
||||||
validRange={[dateRange[0], dateRange[1]]}
|
|
||||||
/>
|
|
||||||
</Spin>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* Data Viewer Modal */}
|
|
||||||
<Modal
|
|
||||||
title={`位置数据 - ${viewingDateData?.date || ''}`}
|
|
||||||
open={!!viewingDateData}
|
|
||||||
onCancel={() => setViewingDateData(null)}
|
|
||||||
footer={null}
|
|
||||||
width={900}
|
|
||||||
>
|
|
||||||
{viewingDateData && (
|
|
||||||
<Spin spinning={loadingDateData}>
|
|
||||||
<Table
|
|
||||||
dataSource={viewingDateData.bodies}
|
|
||||||
rowKey="id"
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: true }}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: '天体',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'X (AU)',
|
|
||||||
dataIndex: 'x',
|
|
||||||
key: 'x',
|
|
||||||
render: (val: number) => val?.toFixed(6) || '-'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Y (AU)',
|
|
||||||
dataIndex: 'y',
|
|
||||||
key: 'y',
|
|
||||||
render: (val: number) => val?.toFixed(6) || '-'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Z (AU)',
|
|
||||||
dataIndex: 'z',
|
|
||||||
key: 'z',
|
|
||||||
render: (val: number) => val?.toFixed(6) || '-'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'VX (AU/day)',
|
|
||||||
dataIndex: 'vx',
|
|
||||||
key: 'vx',
|
|
||||||
render: (val: number) => val?.toFixed(8) || '-'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'VY (AU/day)',
|
|
||||||
dataIndex: 'vy',
|
|
||||||
key: 'vy',
|
|
||||||
render: (val: number) => val?.toFixed(8) || '-'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'VZ (AU/day)',
|
|
||||||
dataIndex: 'vz',
|
|
||||||
key: 'vz',
|
|
||||||
render: (val: number) => val?.toFixed(8) || '-'
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{viewingDateData.bodies.length === 0 && (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
|
|
||||||
该日期暂无数据
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Spin>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Col, Form, Input, InputNumber, Modal, Row, Switch, Tabs, Tag } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { DataTable } from '../../components/admin/DataTable';
|
||||||
|
import type { RocketConfig } from '../../features/rocket-simulator/types';
|
||||||
|
import { request } from '../../utils/request';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
|
||||||
|
const defaultStage1 = { name: '一级推进器', dry_mass_kg: 25000, fuel_mass_kg: 400000, max_thrust_n: 7500000, specific_impulse_s: 285, engine_count: 9, length_ratio: 0.65 };
|
||||||
|
const defaultStage2 = { name: '二级推进器', dry_mass_kg: 4000, fuel_mass_kg: 100000, max_thrust_n: 1000000, specific_impulse_s: 350, engine_count: 1, length_ratio: 0.35 };
|
||||||
|
const createDefaults = { color: '#f8fafc', launch_site_name: '发射场', launch_latitude_deg: 0, launch_longitude_deg: 0, payload_mass_kg: 15000, drag_coefficient: 0.4, target_orbit_km: 200, target_velocity_mps: 7800, separation_delay_seconds: 2, second_stage_ignition_delay_seconds: 1, is_active: true, sort_order: 0, stage_1: defaultStage1, stage_2: defaultStage2 };
|
||||||
|
|
||||||
|
function NumberField({ name, label, suffix, min = 0, max, step }: { name: string | (string | number)[]; label: string; suffix?: string; min?: number; max?: number; step?: number }) {
|
||||||
|
return <Form.Item name={name} label={label} rules={[{ required: true, message: `请输入${label}` }]}><InputNumber min={min} max={max} step={step} style={{ width: '100%' }} addonAfter={suffix} /></Form.Item>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageFields({ index }: { index: 1 | 2 }) {
|
||||||
|
const prefix = `stage_${index}`;
|
||||||
|
return (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={24}><Form.Item name={[prefix, 'name']} label="级名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
|
||||||
|
<Col xs={24} md={12}><NumberField name={[prefix, 'dry_mass_kg']} label="干质量" suffix="kg" /></Col>
|
||||||
|
<Col xs={24} md={12}><NumberField name={[prefix, 'fuel_mass_kg']} label="推进剂质量" suffix="kg" /></Col>
|
||||||
|
<Col xs={24} md={12}><NumberField name={[prefix, 'max_thrust_n']} label="最大推力" suffix="N" /></Col>
|
||||||
|
<Col xs={24} md={12}><NumberField name={[prefix, 'specific_impulse_s']} label="比冲" suffix="s" /></Col>
|
||||||
|
<Col xs={24} md={12}><NumberField name={[prefix, 'engine_count']} label="发动机数量" min={1} /></Col>
|
||||||
|
<Col xs={24} md={12}><NumberField name={[prefix, 'length_ratio']} label="箭体长度占比" min={0.01} step={0.05} /></Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Rockets() {
|
||||||
|
const [data, setData] = useState<RocketConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<RocketConfig | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await request.get('/rockets/admin', { params: { skip: (page - 1) * pageSize, limit: pageSize, search: search || undefined } });
|
||||||
|
setData(response.data.rockets);
|
||||||
|
setTotal(response.data.total);
|
||||||
|
} catch {
|
||||||
|
toast.error('加载火箭配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [page, pageSize, search]);
|
||||||
|
|
||||||
|
const syncFormValues = (visible: boolean) => {
|
||||||
|
if (!visible) return;
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue(editing ?? createDefaults);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showEdit = (record: RocketConfig) => {
|
||||||
|
setEditing(record);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
if (editing) await request.put(`/rockets/admin/${editing.id}`, values);
|
||||||
|
else await request.post('/rockets/admin', values);
|
||||||
|
toast.success(editing ? '火箭配置已更新' : '火箭配置已创建');
|
||||||
|
setOpen(false);
|
||||||
|
void load();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!error.errorFields) toast.error(error.response?.data?.detail || '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (record: RocketConfig) => {
|
||||||
|
try {
|
||||||
|
await request.delete(`/rockets/admin/${record.id}`);
|
||||||
|
toast.success('删除成功');
|
||||||
|
void load();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.detail || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<RocketConfig> = [
|
||||||
|
{ title: '排序', dataIndex: 'sort_order', width: 70 },
|
||||||
|
{ title: '火箭', key: 'name', width: 210, render: (_, record) => <div><strong>{record.name_zh || record.name}</strong><div style={{ color: '#8c8c8c', fontSize: 12 }}>{record.name} · {record.code}</div></div> },
|
||||||
|
{ title: '制造方', dataIndex: 'manufacturer', width: 180, render: (value) => value || '-' },
|
||||||
|
{ title: '发射场', key: 'launch_site', width: 180, render: (_, record) => <div><strong>{record.launch_site_name}</strong><div style={{ color: '#8c8c8c', fontSize: 12 }}>{record.launch_latitude_deg.toFixed(4)}, {record.launch_longitude_deg.toFixed(4)}</div></div> },
|
||||||
|
{ title: '总体尺寸', key: 'size', width: 130, render: (_, record) => <span>{record.height_m} m × {record.diameter_m} m</span> },
|
||||||
|
{ title: '一级', key: 'stage1', width: 170, render: (_, record) => <span>{record.stage_1.engine_count} 台 / {(record.stage_1.max_thrust_n / 1_000_000).toFixed(2)} MN</span> },
|
||||||
|
{ title: '二级', key: 'stage2', width: 170, render: (_, record) => <span>{record.stage_2.engine_count} 台 / {(record.stage_2.max_thrust_n / 1_000_000).toFixed(2)} MN</span> },
|
||||||
|
{ title: '目标轨道', dataIndex: 'target_orbit_km', width: 110, render: (value) => `${value} km` },
|
||||||
|
{ title: '状态', dataIndex: 'is_active', width: 90, render: (value) => <Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable title="火箭数据管理" columns={columns} dataSource={data} loading={loading} rowKey="id" total={total} currentPage={page} pageSize={pageSize} onPageChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} onSearch={(value) => { setSearch(value); setPage(1); }} onAdd={showCreate} onEdit={showEdit} onDelete={remove} />
|
||||||
|
<Modal title={editing ? '编辑火箭配置' : '新增火箭配置'} open={open} onCancel={() => setOpen(false)} afterOpenChange={syncFormValues} width={820} footer={[<Button key="cancel" onClick={() => setOpen(false)}>取消</Button>, <Button key="save" type="primary" onClick={() => void save()}>保存</Button>]} destroyOnHidden>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Tabs items={[
|
||||||
|
{ key: 'base', label: '基本信息', children: <Row gutter={16}><Col xs={24} md={12}><Form.Item name="code" label="唯一编码" rules={[{ required: true }, { pattern: /^[a-z0-9][a-z0-9-]*$/, message: '使用小写字母、数字和连字符' }]}><Input placeholder="long-march-5" /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="name" label="英文名称" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="name_zh" label="中文名称"><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="manufacturer" label="制造方"><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="country" label="国家/地区"><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="color" label="箭体颜色" rules={[{ required: true }]}><Input type="color" style={{ height: 32 }} /></Form.Item></Col><Col xs={24} md={8}><NumberField name="height_m" label="箭体高度" suffix="m" /></Col><Col xs={24} md={8}><NumberField name="diameter_m" label="箭体直径" suffix="m" /></Col><Col xs={24} md={8}><NumberField name="payload_mass_kg" label="载荷质量" suffix="kg" /></Col><Col span={24}><Form.Item name="description" label="简介"><Input.TextArea rows={3} /></Form.Item></Col></Row> },
|
||||||
|
{ key: 'mission', label: '发射与任务', children: <Row gutter={16}><Col span={24}><Form.Item name="launch_site_name" label="发射场名称" rules={[{ required: true, message: '请输入发射场名称' }]}><Input placeholder="例如:某某航天发射场" /></Form.Item></Col><Col xs={24} md={12}><NumberField name="launch_latitude_deg" label="发射场纬度" suffix="°" min={-90} max={90} step={0.0001} /></Col><Col xs={24} md={12}><NumberField name="launch_longitude_deg" label="发射场经度" suffix="°" min={-180} max={180} step={0.0001} /></Col><Col xs={24} md={12}><NumberField name="drag_coefficient" label="阻力系数 Cd" min={0.01} step={0.01} /></Col><Col xs={24} md={12}><NumberField name="reference_area_m2" label="参考面积" suffix="m²" /></Col><Col xs={24} md={12}><NumberField name="target_orbit_km" label="目标轨道高度" suffix="km" /></Col><Col xs={24} md={12}><NumberField name="target_velocity_mps" label="目标速度" suffix="m/s" /></Col><Col xs={24} md={12}><NumberField name="separation_delay_seconds" label="级间分离持续时间" suffix="s" /></Col><Col xs={24} md={12}><NumberField name="second_stage_ignition_delay_seconds" label="二级点火延时" suffix="s" /></Col><Col xs={24} md={12}><NumberField name="sort_order" label="排序" /></Col><Col xs={24} md={12}><Form.Item name="is_active" label="前台启用" valuePropName="checked"><Switch /></Form.Item></Col></Row> },
|
||||||
|
{ key: 'stage1', label: '一级参数', children: <StageFields index={1} /> },
|
||||||
|
{ key: 'stage2', label: '二级参数', children: <StageFields index={2} /> },
|
||||||
|
]} />
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,48 +1,26 @@
|
||||||
/**
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
* Scheduled Jobs Management Page
|
import { Badge, Button, Form, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
||||||
*/
|
import { DeleteOutlined, EditOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Modal, Form, Input, Switch, Button, Space, Popconfirm, Tag, Tooltip, Badge, Tabs, Select, Row, Col, Card, Alert } from 'antd';
|
|
||||||
import { PlayCircleOutlined, EditOutlined, DeleteOutlined, QuestionCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
import { DataTable } from '../../components/admin/DataTable';
|
import { DataTable } from '../../components/admin/DataTable';
|
||||||
import { request } from '../../utils/request';
|
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { request } from '../../utils/request';
|
||||||
|
import { ScheduledJobModal } from './scheduled-jobs/ScheduledJobModal';
|
||||||
|
import type { AvailableTask, ScheduledJob } from './scheduled-jobs/types';
|
||||||
|
|
||||||
interface ScheduledJob {
|
function getErrorMessage(error: unknown) {
|
||||||
id: number;
|
if (typeof error !== 'object' || error === null || !('response' in error)) return null;
|
||||||
name: string;
|
const detail = (error as { response?: { data?: { detail?: unknown } } }).response?.data?.detail;
|
||||||
job_type: 'predefined' | 'custom_code';
|
if (typeof detail === 'string') return detail;
|
||||||
predefined_function?: string;
|
if (typeof detail === 'object' && detail !== null && 'message' in detail) return String(detail.message);
|
||||||
function_params?: Record<string, any>;
|
return null;
|
||||||
cron_expression: string;
|
|
||||||
python_code?: string;
|
|
||||||
is_active: boolean;
|
|
||||||
description: string;
|
|
||||||
last_run_at: string | null;
|
|
||||||
last_run_status: 'success' | 'failed' | null;
|
|
||||||
next_run_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AvailableTask {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
parameters: Array<{
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
required: boolean;
|
|
||||||
default: any;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduledJobs() {
|
export function ScheduledJobs() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<ScheduledJob[]>([]);
|
const [data, setData] = useState<ScheduledJob[]>([]);
|
||||||
const [filteredData, setFilteredData] = useState<ScheduledJob[]>([]);
|
const [keyword, setKeyword] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingRecord, setEditingRecord] = useState<ScheduledJob | null>(null);
|
const [editingRecord, setEditingRecord] = useState<ScheduledJob | null>(null);
|
||||||
const [activeTabKey, setActiveTabKey] = useState('basic');
|
const [activeTabKey, setActiveTabKey] = useState('basic');
|
||||||
|
|
@ -51,89 +29,68 @@ export function ScheduledJobs() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const jobType = Form.useWatch('job_type', form);
|
const jobType = Form.useWatch<ScheduledJob['job_type']>('job_type', form);
|
||||||
const predefinedFunction = Form.useWatch('predefined_function', form);
|
const predefinedFunction = Form.useWatch<string>('predefined_function', form);
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
const normalized = keyword.toLowerCase();
|
||||||
|
return data.filter((item) => item.name.toLowerCase().includes(normalized) || item.description?.toLowerCase().includes(normalized));
|
||||||
|
}, [data, keyword]);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback(async () => {
|
||||||
loadData();
|
|
||||||
loadAvailableTasks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// When predefined function changes, update selected task
|
|
||||||
if (predefinedFunction && availableTasks.length > 0) {
|
|
||||||
const task = availableTasks.find(t => t.name === predefinedFunction);
|
|
||||||
setSelectedTask(task || null);
|
|
||||||
|
|
||||||
// Set default parameter values only if not editing
|
|
||||||
if (task && !editingRecord) {
|
|
||||||
const defaultParams: Record<string, any> = {};
|
|
||||||
task.parameters.forEach(param => {
|
|
||||||
if (param.default !== null && param.default !== undefined) {
|
|
||||||
defaultParams[param.name] = param.default;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
form.setFieldsValue({ function_params: defaultParams });
|
|
||||||
} else if (task && editingRecord) {
|
|
||||||
// When editing, just set the selected task, don't override params
|
|
||||||
setSelectedTask(task);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSelectedTask(null);
|
|
||||||
}
|
|
||||||
}, [predefinedFunction, availableTasks]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data: result } = await request.get('/scheduled-jobs');
|
const { data: result } = await request.get('/scheduled-jobs');
|
||||||
setData(result || []);
|
setData(result || []);
|
||||||
setFilteredData(result || []);
|
} catch {
|
||||||
} catch (error) {
|
|
||||||
toast.error('加载数据失败');
|
toast.error('加载数据失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [toast]);
|
||||||
|
|
||||||
const loadAvailableTasks = async () => {
|
const loadAvailableTasks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { data: result } = await request.get('/scheduled-jobs/available-tasks');
|
const { data: result } = await request.get('/scheduled-jobs/available-tasks');
|
||||||
setAvailableTasks(result || []);
|
setAvailableTasks(result || []);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('加载可用任务列表失败');
|
toast.error('加载可用任务列表失败');
|
||||||
}
|
}
|
||||||
};
|
}, [toast]);
|
||||||
|
|
||||||
const handleSearch = (keyword: string) => {
|
useEffect(() => {
|
||||||
const lowerKeyword = keyword.toLowerCase();
|
void loadData();
|
||||||
const filtered = data.filter(
|
void loadAvailableTasks();
|
||||||
(item) =>
|
}, [loadAvailableTasks, loadData]);
|
||||||
item.name.toLowerCase().includes(lowerKeyword) ||
|
|
||||||
item.description?.toLowerCase().includes(lowerKeyword)
|
useEffect(() => {
|
||||||
);
|
if (!predefinedFunction || availableTasks.length === 0) {
|
||||||
setFilteredData(filtered);
|
setSelectedTask(null);
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = availableTasks.find((item) => item.name === predefinedFunction) || null;
|
||||||
|
setSelectedTask(task);
|
||||||
|
if (task && !editingRecord) {
|
||||||
|
const defaults: Record<string, unknown> = {};
|
||||||
|
task.parameters.forEach((parameter) => {
|
||||||
|
if (parameter.default !== null && parameter.default !== undefined) defaults[parameter.name] = parameter.default;
|
||||||
|
});
|
||||||
|
form.setFieldsValue({ function_params: defaults });
|
||||||
|
}
|
||||||
|
}, [availableTasks, editingRecord, form, predefinedFunction]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
setEditingRecord(null);
|
setEditingRecord(null);
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({ job_type: 'predefined', is_active: true, function_params: {} });
|
||||||
job_type: 'predefined',
|
|
||||||
is_active: true,
|
|
||||||
function_params: {}
|
|
||||||
});
|
|
||||||
setActiveTabKey('basic');
|
setActiveTabKey('basic');
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: ScheduledJob) => {
|
const handleEdit = (record: ScheduledJob) => {
|
||||||
setEditingRecord(record);
|
setEditingRecord(record);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({ ...record, function_params: record.function_params || {} });
|
||||||
...record,
|
|
||||||
function_params: record.function_params || {}
|
|
||||||
});
|
|
||||||
setActiveTabKey('basic');
|
setActiveTabKey('basic');
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
@ -142,8 +99,8 @@ export function ScheduledJobs() {
|
||||||
try {
|
try {
|
||||||
await request.delete(`/scheduled-jobs/${record.id}`);
|
await request.delete(`/scheduled-jobs/${record.id}`);
|
||||||
toast.success('删除成功');
|
toast.success('删除成功');
|
||||||
loadData();
|
await loadData();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('删除失败');
|
toast.error('删除失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -152,8 +109,8 @@ export function ScheduledJobs() {
|
||||||
try {
|
try {
|
||||||
await request.post(`/scheduled-jobs/${record.id}/run`);
|
await request.post(`/scheduled-jobs/${record.id}/run`);
|
||||||
toast.success('定时任务已触发,请前往"系统任务"中查看进度');
|
toast.success('定时任务已触发,请前往"系统任务"中查看进度');
|
||||||
setTimeout(loadData, 1000);
|
window.setTimeout(() => void loadData(), 1000);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('触发失败');
|
toast.error('触发失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -161,8 +118,6 @@ export function ScheduledJobs() {
|
||||||
const handleModalOk = async () => {
|
const handleModalOk = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
// Clean up data based on job_type
|
|
||||||
if (values.job_type === 'predefined') {
|
if (values.job_type === 'predefined') {
|
||||||
delete values.python_code;
|
delete values.python_code;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -177,124 +132,44 @@ export function ScheduledJobs() {
|
||||||
await request.post('/scheduled-jobs', values);
|
await request.post('/scheduled-jobs', values);
|
||||||
toast.success('创建成功');
|
toast.success('创建成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
loadData();
|
await loadData();
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error.response?.data?.detail) {
|
const message = getErrorMessage(error);
|
||||||
const detail = error.response.data.detail;
|
if (message) toast.error(message);
|
||||||
if (typeof detail === 'object' && detail.message) {
|
|
||||||
toast.error(detail.message);
|
|
||||||
} else {
|
|
||||||
toast.error(detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<ScheduledJob> = [
|
const columns: ColumnsType<ScheduledJob> = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||||
{
|
{
|
||||||
title: 'ID',
|
title: '任务名称', dataIndex: 'name', width: 200,
|
||||||
dataIndex: 'id',
|
render: (text, record) => <div><div style={{ fontWeight: 500 }}>{text}</div>{record.description && <div style={{ fontSize: 12, color: '#888' }}>{record.description}</div>}</div>,
|
||||||
width: 60,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '任务名称',
|
title: '类型', dataIndex: 'job_type', width: 120,
|
||||||
dataIndex: 'name',
|
render: (type) => <Tag color={type === 'predefined' ? 'blue' : 'purple'}>{type === 'predefined' ? '内置任务' : '自定义代码'}</Tag>,
|
||||||
width: 200,
|
|
||||||
render: (text, record) => (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{text}</div>
|
|
||||||
{record.description && (
|
|
||||||
<div style={{ fontSize: 12, color: '#888' }}>{record.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '任务函数', dataIndex: 'predefined_function', width: 200,
|
||||||
dataIndex: 'job_type',
|
render: (func, record) => record.job_type === 'predefined' ? <Tag color="cyan">{func}</Tag> : <span style={{ color: '#ccc' }}>-</span>,
|
||||||
width: 120,
|
},
|
||||||
render: (type) => (
|
{ title: 'Cron 表达式', dataIndex: 'cron_expression', width: 130, render: (text) => <Tag color="green">{text}</Tag> },
|
||||||
<Tag color={type === 'predefined' ? 'blue' : 'purple'}>
|
{ title: '状态', dataIndex: 'is_active', width: 80, render: (active) => <Badge status={active ? 'success' : 'default'} text={active ? '启用' : '禁用'} /> },
|
||||||
{type === 'predefined' ? '内置任务' : '自定义代码'}
|
{
|
||||||
</Tag>
|
title: '上次执行', width: 200,
|
||||||
),
|
render: (_, record) => record.last_run_at ? (
|
||||||
|
<div><div>{new Date(record.last_run_at).toLocaleString()}</div><Tag color={record.last_run_status === 'success' ? 'green' : 'red'}>{record.last_run_status === 'success' ? '成功' : '失败'}</Tag></div>
|
||||||
|
) : <span style={{ color: '#ccc' }}>从未执行</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '任务函数',
|
title: '操作', key: 'action', width: 150,
|
||||||
dataIndex: 'predefined_function',
|
|
||||||
width: 200,
|
|
||||||
render: (func, record) => {
|
|
||||||
if (record.job_type === 'predefined') {
|
|
||||||
return <Tag color="cyan">{func}</Tag>;
|
|
||||||
}
|
|
||||||
return <span style={{ color: '#ccc' }}>-</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Cron 表达式',
|
|
||||||
dataIndex: 'cron_expression',
|
|
||||||
width: 130,
|
|
||||||
render: (text) => <Tag color="green">{text}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'is_active',
|
|
||||||
width: 80,
|
|
||||||
render: (active) => (
|
|
||||||
<Badge status={active ? 'success' : 'default'} text={active ? '启用' : '禁用'} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '上次执行',
|
|
||||||
width: 200,
|
|
||||||
render: (_, record) => (
|
|
||||||
<div>
|
|
||||||
{record.last_run_at ? (
|
|
||||||
<>
|
|
||||||
<div>{new Date(record.last_run_at).toLocaleString()}</div>
|
|
||||||
<Tag color={record.last_run_status === 'success' ? 'green' : 'red'}>
|
|
||||||
{record.last_run_status === 'success' ? '成功' : '失败'}
|
|
||||||
</Tag>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#ccc' }}>从未执行</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
width: 150,
|
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Tooltip title="立即执行">
|
<Tooltip title="立即执行"><Button type="text" icon={<PlayCircleOutlined />} onClick={() => handleRunNow(record)} style={{ color: '#52c41a' }} /></Tooltip>
|
||||||
<Button
|
<Tooltip title="编辑"><Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} style={{ color: '#1890ff' }} /></Tooltip>
|
||||||
type="text"
|
<Popconfirm title="确认删除该任务?" onConfirm={() => handleDelete(record)} okText="删除" cancelText="取消">
|
||||||
icon={<PlayCircleOutlined />}
|
<Tooltip title="删除"><Button type="text" danger icon={<DeleteOutlined />} /></Tooltip>
|
||||||
onClick={() => handleRunNow(record)}
|
|
||||||
style={{ color: '#52c41a' }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="编辑">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
style={{ color: '#1890ff' }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Popconfirm
|
|
||||||
title="确认删除该任务?"
|
|
||||||
onConfirm={() => handleDelete(record)}
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
|
||||||
</Tooltip>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
|
|
@ -309,278 +184,24 @@ export function ScheduledJobs() {
|
||||||
dataSource={filteredData}
|
dataSource={filteredData}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
total={filteredData.length}
|
total={filteredData.length}
|
||||||
onSearch={handleSearch}
|
onSearch={setKeyword}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pageSize={10}
|
pageSize={10}
|
||||||
/>
|
/>
|
||||||
|
<ScheduledJobModal
|
||||||
<Modal
|
form={form}
|
||||||
title={editingRecord ? '编辑任务' : '新增任务'}
|
record={editingRecord}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onOk={handleModalOk}
|
onOk={handleModalOk}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
width={900}
|
activeTab={activeTabKey}
|
||||||
destroyOnClose
|
onActiveTabChange={setActiveTabKey}
|
||||||
>
|
jobType={jobType}
|
||||||
<Form
|
availableTasks={availableTasks}
|
||||||
form={form}
|
selectedTask={selectedTask}
|
||||||
layout="vertical"
|
onClearSelectedTask={() => setSelectedTask(null)}
|
||||||
>
|
/>
|
||||||
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
|
|
||||||
{/* 基础配置 Tab */}
|
|
||||||
<Tabs.TabPane tab="基础配置" key="basic">
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="任务名称"
|
|
||||||
rules={[{ required: true, message: '请输入任务名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="例如:每日数据同步" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="job_type"
|
|
||||||
label="任务类型"
|
|
||||||
rules={[{ required: true, message: '请选择任务类型' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ label: '内置任务', value: 'predefined' },
|
|
||||||
{ label: '自定义代码', value: 'custom_code' }
|
|
||||||
]}
|
|
||||||
onChange={() => {
|
|
||||||
// Clear related fields when type changes
|
|
||||||
form.setFieldsValue({
|
|
||||||
predefined_function: undefined,
|
|
||||||
function_params: {},
|
|
||||||
python_code: undefined
|
|
||||||
});
|
|
||||||
setSelectedTask(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="cron_expression"
|
|
||||||
label={
|
|
||||||
<Space>
|
|
||||||
<span>Cron 表达式</span>
|
|
||||||
<Tooltip title="格式:分 时 日 月 周 (例如: 0 0 * * * 表示每天零点)">
|
|
||||||
<QuestionCircleOutlined style={{ color: '#888' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
rules={[{ required: true, message: '请输入 Cron 表达式' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="0 0 * * *" style={{ fontFamily: 'monospace' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="is_active"
|
|
||||||
label="是否启用"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="description"
|
|
||||||
label="描述"
|
|
||||||
>
|
|
||||||
<Input.TextArea rows={3} placeholder="任务描述" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 内置任务配置 */}
|
|
||||||
{jobType === 'predefined' && (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
name="predefined_function"
|
|
||||||
label="选择预定义任务"
|
|
||||||
rules={[{ required: true, message: '请选择预定义任务' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="请选择任务"
|
|
||||||
options={availableTasks.map(task => ({
|
|
||||||
label: `${task.name} - ${task.description}`,
|
|
||||||
value: task.name
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{selectedTask && (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<InfoCircleOutlined />
|
|
||||||
<span>任务参数配置</span>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
<Alert
|
|
||||||
message={selectedTask.description}
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTask.parameters.map(param => (
|
|
||||||
<Form.Item
|
|
||||||
key={param.name}
|
|
||||||
name={['function_params', param.name]}
|
|
||||||
label={
|
|
||||||
<Space>
|
|
||||||
<span>{param.name}</span>
|
|
||||||
{!param.required && <Tag color="orange">可选</Tag>}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
tooltip={param.description}
|
|
||||||
rules={[
|
|
||||||
{ required: param.required, message: `请输入${param.name}` }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{param.type === 'integer' ? (
|
|
||||||
<Input type="number" placeholder={`默认: ${param.default}`} />
|
|
||||||
) : param.type === 'boolean' ? (
|
|
||||||
<Switch />
|
|
||||||
) : param.type === 'array' ? (
|
|
||||||
<Select mode="tags" placeholder="输入后回车添加" />
|
|
||||||
) : (
|
|
||||||
<Input placeholder={`默认: ${param.default || '无'}`} />
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Tabs.TabPane>
|
|
||||||
|
|
||||||
{/* Python 代码 Tab - 仅在自定义代码模式下显示 */}
|
|
||||||
{jobType === 'custom_code' && (
|
|
||||||
<Tabs.TabPane tab="Python 代码" key="code">
|
|
||||||
<Alert
|
|
||||||
message="自定义代码执行环境"
|
|
||||||
description={
|
|
||||||
<div>
|
|
||||||
<p>可用变量:</p>
|
|
||||||
<ul style={{ marginBottom: 0 }}>
|
|
||||||
<li><code>db</code>: AsyncSession - 数据库会话</li>
|
|
||||||
<li><code>logger</code>: Logger - 日志记录器</li>
|
|
||||||
<li><code>task_id</code>: int - 任务ID</li>
|
|
||||||
<li><code>asyncio</code>: 异步IO模块</li>
|
|
||||||
</ul>
|
|
||||||
<p style={{ marginTop: 8, marginBottom: 0 }}>
|
|
||||||
⚠️ 注意:代码在异步环境中执行,可以使用 <code>await</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="python_code"
|
|
||||||
rules={[{ required: jobType === 'custom_code', message: '请输入执行脚本' }]}
|
|
||||||
>
|
|
||||||
<CodeEditor
|
|
||||||
placeholder={`# 动态任务脚本示例
|
|
||||||
# 可用变量: db (AsyncSession), logger (Logger), task_id (int)
|
|
||||||
# 必须是异步环境,可以使用 await
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
logger.info(f"任务开始执行: {datetime.now()}")
|
|
||||||
|
|
||||||
# 你的业务逻辑...
|
|
||||||
|
|
||||||
return "执行成功"`}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Tabs.TabPane>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple Code Editor with Line Numbers
|
|
||||||
const CodeEditor = ({
|
|
||||||
value = '',
|
|
||||||
onChange,
|
|
||||||
placeholder = ''
|
|
||||||
}: {
|
|
||||||
value?: string;
|
|
||||||
onChange?: (e: any) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}) => {
|
|
||||||
const displayValue = value || placeholder;
|
|
||||||
const lineCount = displayValue.split('\n').length;
|
|
||||||
const lineNumbers = Array.from({ length: lineCount }, (_, i) => i + 1).join('\n');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
border: '1px solid #d9d9d9',
|
|
||||||
borderRadius: 6,
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: '#fafafa'
|
|
||||||
}}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
backgroundColor: '#f0f0f0',
|
|
||||||
borderRight: '1px solid #d9d9d9',
|
|
||||||
color: '#999',
|
|
||||||
textAlign: 'right',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
fontSize: '14px',
|
|
||||||
userSelect: 'none',
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lineNumbers}
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={placeholder}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 0,
|
|
||||||
resize: 'none',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
fontSize: '14px',
|
|
||||||
padding: '4px 8px',
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#fafafa',
|
|
||||||
color: value ? '#333' : '#999'
|
|
||||||
}}
|
|
||||||
rows={20}
|
|
||||||
spellCheck={false}
|
|
||||||
wrap="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,19 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Modal, Form, Input, InputNumber, Select, Button, Space, Popconfirm, Tag, Descriptions, Tabs } from 'antd';
|
import { Button, Form, Popconfirm, Space, Tag } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import MdEditor from 'react-markdown-editor-lite';
|
|
||||||
import MarkdownIt from 'markdown-it';
|
|
||||||
import 'react-markdown-editor-lite/lib/index.css';
|
|
||||||
|
|
||||||
import { DataTable } from '../../components/admin/DataTable';
|
import { DataTable } from '../../components/admin/DataTable';
|
||||||
import { request } from '../../utils/request';
|
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { request } from '../../utils/request';
|
||||||
|
import { StarSystemDetailsModal } from './star-systems/StarSystemDetailsModal';
|
||||||
|
import { StarSystemModal } from './star-systems/StarSystemModal';
|
||||||
|
import type { StarSystem, StarSystemWithBodies } from './star-systems/types';
|
||||||
|
|
||||||
const MdEditorParser = new MarkdownIt();
|
function getErrorDetail(error: unknown, fallback: string) {
|
||||||
|
if (typeof error !== 'object' || error === null || !('response' in error)) return fallback;
|
||||||
interface StarSystem {
|
const detail = (error as { response?: { data?: { detail?: unknown } } }).response?.data?.detail;
|
||||||
id: number;
|
return typeof detail === 'string' ? detail : fallback;
|
||||||
name: string;
|
|
||||||
name_zh: string;
|
|
||||||
host_star_name: string;
|
|
||||||
distance_pc: number | null;
|
|
||||||
distance_ly: number | null;
|
|
||||||
ra: number | null;
|
|
||||||
dec: number | null;
|
|
||||||
position_x: number | null;
|
|
||||||
position_y: number | null;
|
|
||||||
position_z: number | null;
|
|
||||||
spectral_type: string | null;
|
|
||||||
radius_solar: number | null;
|
|
||||||
mass_solar: number | null;
|
|
||||||
temperature_k: number | null;
|
|
||||||
magnitude: number | null;
|
|
||||||
luminosity_solar: number | null;
|
|
||||||
color: string | null;
|
|
||||||
planet_count: number;
|
|
||||||
star_count: number; // 恒星数量
|
|
||||||
description: string | null;
|
|
||||||
details: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StarSystemWithBodies extends StarSystem {
|
|
||||||
bodies: any[];
|
|
||||||
body_count: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StarSystems() {
|
export function StarSystems() {
|
||||||
|
|
@ -55,49 +27,38 @@ export function StarSystems() {
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
const [editingRecord, setEditingRecord] = useState<StarSystem | null>(null);
|
const [editingRecord, setEditingRecord] = useState<StarSystem | null>(null);
|
||||||
const [detailData, setDetailData] = useState<StarSystemWithBodies | null>(null);
|
const [detailData, setDetailData] = useState<StarSystemWithBodies | null>(null);
|
||||||
const [activeTabKey, setActiveTabKey] = useState('basic'); // 添加tab状态
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback(async () => {
|
||||||
loadData();
|
|
||||||
}, [currentPage, pageSize, searchKeyword]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data: result } = await request.get('/star-systems', {
|
const { data: result } = await request.get('/star-systems', {
|
||||||
params: {
|
params: { skip: (currentPage - 1) * pageSize, limit: pageSize, search: searchKeyword || undefined, exclude_solar: false },
|
||||||
skip: (currentPage - 1) * pageSize,
|
|
||||||
limit: pageSize,
|
|
||||||
search: searchKeyword || undefined,
|
|
||||||
exclude_solar: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
setData(result.systems || []);
|
setData(result.systems || []);
|
||||||
setTotal(result.total || 0);
|
setTotal(result.total || 0);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('加载恒星系统数据失败');
|
toast.error('加载恒星系统数据失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [currentPage, pageSize, searchKeyword, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
setEditingRecord(null);
|
setEditingRecord(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({ color: '#FFFFFF', planet_count: 0 });
|
||||||
color: '#FFFFFF',
|
|
||||||
planet_count: 0,
|
|
||||||
});
|
|
||||||
setActiveTabKey('basic'); // 重置到基础信息tab
|
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: StarSystem) => {
|
const handleEdit = (record: StarSystem) => {
|
||||||
setEditingRecord(record);
|
setEditingRecord(record);
|
||||||
form.setFieldsValue(record);
|
form.setFieldsValue(record);
|
||||||
setActiveTabKey('basic'); // 重置到基础信息tab
|
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,7 +67,7 @@ export function StarSystems() {
|
||||||
const { data: result } = await request.get(`/star-systems/${record.id}/bodies`);
|
const { data: result } = await request.get(`/star-systems/${record.id}/bodies`);
|
||||||
setDetailData(result);
|
setDetailData(result);
|
||||||
setIsDetailModalOpen(true);
|
setIsDetailModalOpen(true);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('加载恒星系统详情失败');
|
toast.error('加载恒星系统详情失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -116,161 +77,60 @@ export function StarSystems() {
|
||||||
toast.error('不能删除太阳系');
|
toast.error('不能删除太阳系');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request.delete(`/star-systems/${id}`);
|
await request.delete(`/star-systems/${id}`);
|
||||||
toast.success('删除成功');
|
toast.success('删除成功');
|
||||||
loadData();
|
await loadData();
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
toast.error(error.response?.data?.detail || '删除失败');
|
toast.error(getErrorDetail(error, '删除失败'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
if (editingRecord) {
|
if (editingRecord) {
|
||||||
// Update
|
|
||||||
await request.put(`/star-systems/${editingRecord.id}`, values);
|
await request.put(`/star-systems/${editingRecord.id}`, values);
|
||||||
toast.success('更新成功');
|
toast.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
// Create
|
|
||||||
await request.post('/star-systems', values);
|
await request.post('/star-systems', values);
|
||||||
toast.success('创建成功');
|
toast.success('创建成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
loadData();
|
await loadData();
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error.errorFields) {
|
if (typeof error === 'object' && error !== null && 'errorFields' in error) {
|
||||||
toast.error('请填写必填字段');
|
toast.error('请填写必填字段');
|
||||||
} else {
|
} else {
|
||||||
toast.error(error.response?.data?.detail || '操作失败');
|
toast.error(getErrorDetail(error, '操作失败'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
|
||||||
setSearchKeyword(value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnsType<StarSystem> = [
|
const columns: ColumnsType<StarSystem> = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||||
{
|
{
|
||||||
title: 'ID',
|
title: '系统名称', dataIndex: 'name', key: 'name', width: 200,
|
||||||
dataIndex: 'id',
|
render: (text, record) => <div><div className="font-medium">{text}</div>{record.name_zh && <div className="text-gray-500 text-xs">{record.name_zh}</div>}</div>,
|
||||||
key: 'id',
|
|
||||||
width: 60,
|
|
||||||
},
|
},
|
||||||
|
{ title: '主恒星', dataIndex: 'host_star_name', key: 'host_star_name', width: 150 },
|
||||||
{
|
{
|
||||||
title: '系统名称',
|
title: '距离', dataIndex: 'distance_pc', key: 'distance', width: 120,
|
||||||
dataIndex: 'name',
|
render: (pc, record) => pc ? <div><div>{pc.toFixed(2)} pc</div><div className="text-gray-500 text-xs">{(record.distance_ly || pc * 3.26).toFixed(2)} ly</div></div> : '-',
|
||||||
key: 'name',
|
|
||||||
width: 200,
|
|
||||||
render: (text, record) => (
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{text}</div>
|
|
||||||
{record.name_zh && <div className="text-gray-500 text-xs">{record.name_zh}</div>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
|
{ title: '光谱类型', dataIndex: 'spectral_type', key: 'spectral_type', width: 100, render: (text) => text || '-' },
|
||||||
{
|
{
|
||||||
title: '主恒星',
|
title: '恒星参数', key: 'stellar_params', width: 150,
|
||||||
dataIndex: 'host_star_name',
|
render: (_, record) => <div className="text-xs">{record.radius_solar && <div>R: {record.radius_solar.toFixed(2)} R☉</div>}{record.mass_solar && <div>M: {record.mass_solar.toFixed(2)} M☉</div>}{record.temperature_k && <div>T: {record.temperature_k.toFixed(0)} K</div>}</div>,
|
||||||
key: 'host_star_name',
|
|
||||||
width: 150,
|
|
||||||
},
|
},
|
||||||
|
{ title: '恒星数量', dataIndex: 'star_count', key: 'star_count', width: 100, render: (count) => <Tag color={count > 1 ? 'gold' : 'default'}>{count}颗</Tag> },
|
||||||
{
|
{
|
||||||
title: '距离',
|
title: '操作', key: 'actions', fixed: 'right', width: 180,
|
||||||
dataIndex: 'distance_pc',
|
render: (_, record) => <Space size="small">
|
||||||
key: 'distance',
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetails(record)}>详情</Button>
|
||||||
width: 120,
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
render: (pc, record) => {
|
{record.id !== 1 && <Popconfirm title="确定要删除这个恒星系统吗?" description="这将同时删除该系统的所有天体!" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消" okButtonProps={{ danger: true }}><Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button></Popconfirm>}
|
||||||
if (!pc) return '-';
|
</Space>,
|
||||||
const ly = record.distance_ly || pc * 3.26;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>{pc.toFixed(2)} pc</div>
|
|
||||||
<div className="text-gray-500 text-xs">{ly.toFixed(2)} ly</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '光谱类型',
|
|
||||||
dataIndex: 'spectral_type',
|
|
||||||
key: 'spectral_type',
|
|
||||||
width: 100,
|
|
||||||
render: (text) => text || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '恒星参数',
|
|
||||||
key: 'stellar_params',
|
|
||||||
width: 150,
|
|
||||||
render: (_, record) => (
|
|
||||||
<div className="text-xs">
|
|
||||||
{record.radius_solar && <div>R: {record.radius_solar.toFixed(2)} R☉</div>}
|
|
||||||
{record.mass_solar && <div>M: {record.mass_solar.toFixed(2)} M☉</div>}
|
|
||||||
{record.temperature_k && <div>T: {record.temperature_k.toFixed(0)} K</div>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '恒星数量',
|
|
||||||
dataIndex: 'star_count',
|
|
||||||
key: 'star_count',
|
|
||||||
width: 100,
|
|
||||||
render: (count) => (
|
|
||||||
<Tag color={count > 1 ? 'gold' : 'default'}>
|
|
||||||
{count}颗
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
fixed: 'right',
|
|
||||||
width: 180,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space size="small">
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<EyeOutlined />}
|
|
||||||
onClick={() => handleViewDetails(record)}
|
|
||||||
>
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
{record.id !== 1 && (
|
|
||||||
<Popconfirm
|
|
||||||
title="确定要删除这个恒星系统吗?"
|
|
||||||
description="这将同时删除该系统的所有天体!"
|
|
||||||
onConfirm={() => handleDelete(record.id)}
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
okButtonProps={{ danger: true }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -285,301 +145,20 @@ export function StarSystems() {
|
||||||
total={total}
|
total={total}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={(page, size) => {
|
onPageChange={(page, size) => { setCurrentPage(page); setPageSize(size); }}
|
||||||
setCurrentPage(page);
|
|
||||||
setPageSize(size);
|
|
||||||
}}
|
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onSearch={handleSearch}
|
onSearch={(value) => { setSearchKeyword(value); setCurrentPage(1); }}
|
||||||
searchPlaceholder="搜索恒星系统名称..."
|
searchPlaceholder="搜索恒星系统名称..."
|
||||||
/>
|
/>
|
||||||
|
<StarSystemModal
|
||||||
{/* 创建/编辑 Modal */}
|
key={`${editingRecord?.id || 'new'}-${isModalOpen}`}
|
||||||
<Modal
|
form={form}
|
||||||
title={editingRecord ? '编辑恒星系统' : '创建恒星系统'}
|
record={editingRecord}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
width={1200}
|
/>
|
||||||
okText="保存"
|
<StarSystemDetailsModal record={detailData} open={isDetailModalOpen} onClose={() => setIsDetailModalOpen(false)} />
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
{editingRecord ? (
|
|
||||||
// 编辑模式:双tab
|
|
||||||
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
|
|
||||||
<Tabs.TabPane tab="基础信息" key="basic">
|
|
||||||
{/* 第一行:系统名称、中文名称、主恒星名称 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="系统名称"
|
|
||||||
rules={[{ required: true, message: '请输入系统名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="例如: Proxima Cen System" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="name_zh" label="中文名称">
|
|
||||||
<Input placeholder="例如: 比邻星系统" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="host_star_name"
|
|
||||||
label="主恒星名称"
|
|
||||||
rules={[{ required: true, message: '请输入主恒星名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="例如: Proxima Cen" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第二行:距离、赤经、赤纬 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="distance_pc" label="距离 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} placeholder="秒差距" step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="ra" label="赤经 (度)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} max={360} step={0.001} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="dec" label="赤纬 (度)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={-90} max={90} step={0.001} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第三行:X/Y/Z坐标 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="position_x" label="X坐标 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="position_y" label="Y坐标 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="position_z" label="Z坐标 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第四行:光谱类型、恒星半径、恒星质量 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="spectral_type" label="光谱类型">
|
|
||||||
<Input placeholder="例如: M5.5 V" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="radius_solar" label="恒星半径 (R☉)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="mass_solar" label="恒星质量 (M☉)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第五行:表面温度、视星等、光度 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="temperature_k" label="表面温度 (K)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={100} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="magnitude" label="视星等">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.1} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="luminosity_solar" label="光度 (L☉)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第六行:距离(ly)、显示颜色 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="distance_ly" label="距离 (ly)">
|
|
||||||
<InputNumber style={{ width: '100%' }} placeholder="光年" step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="color" label="显示颜色">
|
|
||||||
<Input type="color" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述(全宽) */}
|
|
||||||
<Form.Item name="description" label="描述">
|
|
||||||
<Input.TextArea rows={3} placeholder="恒星系统简短描述..." />
|
|
||||||
</Form.Item>
|
|
||||||
</Tabs.TabPane>
|
|
||||||
|
|
||||||
<Tabs.TabPane tab="详细信息" key="details">
|
|
||||||
<Form.Item name="details" style={{ marginBottom: 0 }}>
|
|
||||||
<MdEditor
|
|
||||||
value={form.getFieldValue('details') || ''}
|
|
||||||
style={{ height: '500px' }}
|
|
||||||
renderHTML={(text) => MdEditorParser.render(text)}
|
|
||||||
onChange={({ text }) => form.setFieldValue('details', text)}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Tabs.TabPane>
|
|
||||||
</Tabs>
|
|
||||||
) : (
|
|
||||||
// 新增模式:只显示基础信息
|
|
||||||
<>
|
|
||||||
{/* 第一行:系统名称、中文名称、主恒星名称 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="系统名称"
|
|
||||||
rules={[{ required: true, message: '请输入系统名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="例如: Proxima Cen System" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="name_zh" label="中文名称">
|
|
||||||
<Input placeholder="例如: 比邻星系统" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="host_star_name"
|
|
||||||
label="主恒星名称"
|
|
||||||
rules={[{ required: true, message: '请输入主恒星名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="例如: Proxima Cen" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第二行:距离、赤经、赤纬 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="distance_pc" label="距离 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} placeholder="秒差距" step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="ra" label="赤经 (度)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} max={360} step={0.001} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="dec" label="赤纬 (度)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={-90} max={90} step={0.001} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第三行:X/Y/Z坐标 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="position_x" label="X坐标 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="position_y" label="Y坐标 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="position_z" label="Z坐标 (pc)">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第四行:光谱类型、恒星半径、恒星质量 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="spectral_type" label="光谱类型">
|
|
||||||
<Input placeholder="例如: M5.5 V" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="radius_solar" label="恒星半径 (R☉)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="mass_solar" label="恒星质量 (M☉)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第五行:表面温度、视星等、光度 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="temperature_k" label="表面温度 (K)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={100} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="magnitude" label="视星等">
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.1} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="luminosity_solar" label="光度 (L☉)">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第六行:距离(ly)、显示颜色 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Form.Item name="distance_ly" label="距离 (ly)">
|
|
||||||
<InputNumber style={{ width: '100%' }} placeholder="光年" step={0.01} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="color" label="显示颜色">
|
|
||||||
<Input type="color" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述(全宽) */}
|
|
||||||
<Form.Item name="description" label="描述">
|
|
||||||
<Input.TextArea rows={3} placeholder="恒星系统简短描述..." />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 详情 Modal */}
|
|
||||||
<Modal
|
|
||||||
title={`恒星系统详情 - ${detailData?.name_zh || detailData?.name}`}
|
|
||||||
open={isDetailModalOpen}
|
|
||||||
onCancel={() => setIsDetailModalOpen(false)}
|
|
||||||
footer={null}
|
|
||||||
width={900}
|
|
||||||
>
|
|
||||||
{detailData && (
|
|
||||||
<div>
|
|
||||||
<Descriptions bordered column={2} size="small">
|
|
||||||
<Descriptions.Item label="系统ID">{detailData.id}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="主恒星">{detailData.host_star_name}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="距离">
|
|
||||||
{detailData.distance_pc ? `${detailData.distance_pc.toFixed(2)} pc (~${(detailData.distance_ly || detailData.distance_pc * 3.26).toFixed(2)} ly)` : '-'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="光谱类型">{detailData.spectral_type || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="恒星半径">{detailData.radius_solar ? `${detailData.radius_solar.toFixed(2)} R☉` : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="恒星质量">{detailData.mass_solar ? `${detailData.mass_solar.toFixed(2)} M☉` : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="表面温度">{detailData.temperature_k ? `${detailData.temperature_k.toFixed(0)} K` : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="天体数量">{detailData.body_count}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
|
|
||||||
{detailData.bodies && detailData.bodies.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<h4 className="text-lg font-semibold mb-2">天体列表</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{detailData.bodies.map((body: any) => (
|
|
||||||
<div key={body.id} className="border rounded p-3 bg-gray-50">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{body.name_zh || body.name}</div>
|
|
||||||
<div className="text-xs text-gray-500">{body.id}</div>
|
|
||||||
</div>
|
|
||||||
<Tag color="blue">{body.type}</Tag>
|
|
||||||
</div>
|
|
||||||
{body.description && (
|
|
||||||
<div className="text-sm text-gray-600 mt-2">{body.description}</div>
|
|
||||||
)}
|
|
||||||
{body.extra_data && (
|
|
||||||
<div className="text-xs text-gray-500 mt-2 grid grid-cols-3 gap-2">
|
|
||||||
{body.extra_data.semi_major_axis_au && <div>半长轴: {body.extra_data.semi_major_axis_au.toFixed(4)} AU</div>}
|
|
||||||
{body.extra_data.period_days && <div>周期: {body.extra_data.period_days.toFixed(2)} 天</div>}
|
|
||||||
{body.extra_data.radius_earth && <div>半径: {body.extra_data.radius_earth.toFixed(2)} R⊕</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Form, Input, InputNumber, Switch, Select, Button, Card, Descriptions, Badge, Space, Popconfirm, Alert, Divider } from 'antd';
|
import { Modal, Form, Input, InputNumber, Switch, Select, Button, Card, Badge, Space, Popconfirm, Alert, Divider } from 'antd';
|
||||||
import { ReloadOutlined, ClearOutlined, WarningOutlined, SyncOutlined } from '@ant-design/icons';
|
import { ClearOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { DataTable } from '../../components/admin/DataTable';
|
import { DataTable } from '../../components/admin/DataTable';
|
||||||
import { request } from '../../utils/request';
|
import { request } from '../../utils/request';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Tag, Progress, Button, Modal, Descriptions, Badge, Typography, Space } from 'antd';
|
import { Tag, Progress, Button, Modal, Descriptions, Badge, Typography } from 'antd';
|
||||||
import { ReloadOutlined, EyeOutlined } from '@ant-design/icons';
|
import { EyeOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { DataTable } from '../../components/admin/DataTable';
|
import { DataTable } from '../../components/admin/DataTable';
|
||||||
import { request } from '../../utils/request';
|
import { request } from '../../utils/request';
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
* 个人信息页面
|
* 个人信息页面
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Form, Input, Button, Card, Avatar, Space, Descriptions, Row, Col, Upload, message } from 'antd';
|
import { Form, Input, Button, Card, Avatar, Descriptions, Row, Col, Upload } from 'antd';
|
||||||
import { UserOutlined, MailOutlined, IdcardOutlined, UploadOutlined } from '@ant-design/icons';
|
import { UserOutlined, MailOutlined, IdcardOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import { request, API_BASE_URL } from '../../utils/request';
|
import { request } from '../../utils/request';
|
||||||
import { auth } from '../../utils/auth';
|
import { auth } from '../../utils/auth';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Button, Col, Form, Input, InputNumber, Modal, Row, Select, Space, Tabs } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import MdEditor from 'react-markdown-editor-lite';
|
||||||
|
import 'react-markdown-editor-lite/lib/index.css';
|
||||||
|
|
||||||
|
import type { ToastContextValue } from '../../../contexts/ToastContext';
|
||||||
|
import { ResourceManager } from './ResourceManager';
|
||||||
|
import type { CelestialBody } from './types';
|
||||||
|
|
||||||
|
const markdownParser = new MarkdownIt();
|
||||||
|
|
||||||
|
interface CelestialBodyModalProps {
|
||||||
|
form: FormInstance;
|
||||||
|
record: CelestialBody | null;
|
||||||
|
open: boolean;
|
||||||
|
onOk: () => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchQueryChange: (value: string) => void;
|
||||||
|
onNasaSearch: () => Promise<void>;
|
||||||
|
searching: boolean;
|
||||||
|
uploading: boolean;
|
||||||
|
refreshResources: number;
|
||||||
|
onResourceUpload: (file: File, resourceType: string) => Promise<boolean>;
|
||||||
|
onResourceDelete: (resourceId: number) => Promise<void>;
|
||||||
|
toast: ToastContextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyTypeOptions = [
|
||||||
|
{ value: 'star', label: '恒星' },
|
||||||
|
{ value: 'planet', label: '行星' },
|
||||||
|
{ value: 'dwarf_planet', label: '矮行星' },
|
||||||
|
{ value: 'satellite', label: '卫星' },
|
||||||
|
{ value: 'comet', label: '彗星' },
|
||||||
|
{ value: 'probe', label: '探测器' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function BasicFields({ editing }: { editing: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="id" label="JPL Horizons ID" rules={[{ required: true, message: '请输入JPL Horizons ID' }]}>
|
||||||
|
<Input disabled={editing} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="type" label="类型" rules={[{ required: true, message: '请选择类型' }]}>
|
||||||
|
<Select options={bodyTypeOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item name="system_id" hidden>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="name" label="英文名" rules={[{ required: true, message: '请输入英文名' }]}>
|
||||||
|
<Input placeholder="例如:Voyager 1" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="name_zh" label="中文名">
|
||||||
|
<Input placeholder="例如:旅行者1号" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="short_name" label="简称" tooltip="NASA SBDB API使用的短名称(如Jupiter的简称是Juptr)">
|
||||||
|
<Input placeholder="例如:Juptr" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhysicalAndOrbitFields() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item noStyle shouldUpdate={(previous, current) => previous.type !== current.type}>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const bodyType = getFieldValue('type');
|
||||||
|
if (!['star', 'planet', 'dwarf_planet', 'satellite'].includes(bodyType)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name={['extra_data', 'real_radius']}
|
||||||
|
label="真实半径 (km)"
|
||||||
|
tooltip="天体的物理半径,用于精确计算显示比例。若不填则使用类型默认值。"
|
||||||
|
>
|
||||||
|
<InputNumber<number>
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
placeholder="例如:6371 (地球)"
|
||||||
|
formatter={(value) => `${value ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||||
|
parser={(value) => Number(value?.replace(/,/g, '')) || 0}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(previous, current) => previous.type !== current.type || previous.orbit_info !== current.orbit_info}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const bodyType = getFieldValue('type');
|
||||||
|
const orbitInfo = getFieldValue('orbit_info');
|
||||||
|
if (!['planet', 'dwarf_planet'].includes(bodyType)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="轨道参数与信息"
|
||||||
|
description={(
|
||||||
|
<div>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name={['extra_data', 'orbit_period_days']} label="轨道周期(天)" tooltip="完整公转一周所需的天数">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} step={1} placeholder="例如:365.25(地球)" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name={['extra_data', 'orbit_color']} label="轨道颜色" tooltip="轨道线的显示颜色(HEX格式)">
|
||||||
|
<Input type="color" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{orbitInfo?.num_points && (
|
||||||
|
<div style={{ marginTop: 16, borderTop: '1px solid #d9d9d9', background: '#f0f9ff', padding: 12, borderRadius: 4 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8, color: '#1890ff' }}>已生成的轨道数据:</div>
|
||||||
|
<div style={{ display: 'flex', gap: 24 }}>
|
||||||
|
<div><strong>轨道点数:</strong> {orbitInfo.num_points.toLocaleString()} 个点</div>
|
||||||
|
{orbitInfo.period_days && <div><strong>实际周期:</strong> {orbitInfo.period_days.toFixed(2)} 天</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
type="info"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CelestialBodyModal({
|
||||||
|
form,
|
||||||
|
record,
|
||||||
|
open,
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onNasaSearch,
|
||||||
|
searching,
|
||||||
|
uploading,
|
||||||
|
refreshResources,
|
||||||
|
onResourceUpload,
|
||||||
|
onResourceDelete,
|
||||||
|
toast,
|
||||||
|
}: CelestialBodyModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
|
|
||||||
|
const basicContent = record ? (
|
||||||
|
<>
|
||||||
|
<BasicFields editing />
|
||||||
|
<PhysicalAndOrbitFields />
|
||||||
|
<ResourceManager
|
||||||
|
bodyId={record.id}
|
||||||
|
bodyType={record.type}
|
||||||
|
resources={record.resources}
|
||||||
|
onUpload={onResourceUpload}
|
||||||
|
onDelete={onResourceDelete}
|
||||||
|
uploading={uploading}
|
||||||
|
refreshTrigger={refreshResources}
|
||||||
|
toast={toast}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
title="智能搜索提示"
|
||||||
|
description={(
|
||||||
|
<div>
|
||||||
|
<p>推荐使用 <strong>JPL Horizons 数字 ID</strong> 进行搜索,可获得最准确的结果。</p>
|
||||||
|
<p style={{ marginTop: 4 }}>示例:Hubble 的 ID 是 <code>-48</code>,Voyager 1 的 ID 是 <code>-31</code></p>
|
||||||
|
<p style={{ marginTop: 4, fontSize: 12, color: '#666' }}>不知道 ID?可以先用名称搜索,系统会尽量提取 ID,或提示您手动确认。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Form.Item label="从 NASA 数据库搜索">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入数字 ID (推荐, 如: -48) 或名称 (如: Hubble)"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||||
|
onPressEnter={onNasaSearch}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} onClick={onNasaSearch} loading={searching}>搜索</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
<BasicFields editing={false} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={record ? '编辑天体' : '新增天体'} open={open} onOk={onOk} onCancel={onCancel} width={1000}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
{record ? (
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{ key: 'basic', label: '基础信息', children: basicContent },
|
||||||
|
{
|
||||||
|
key: 'details',
|
||||||
|
label: '详细信息',
|
||||||
|
children: (
|
||||||
|
<Form.Item name="details" style={{ marginBottom: 0 }}>
|
||||||
|
<MdEditor
|
||||||
|
value={form.getFieldValue('details')}
|
||||||
|
style={{ height: 500 }}
|
||||||
|
renderHTML={(text) => markdownParser.render(text)}
|
||||||
|
onChange={({ text }) => form.setFieldsValue({ details: text })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : basicContent}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue