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(reset_positions.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": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ from typing import Optional, Dict, Any
|
|||
from app.database import get_db
|
||||
from app.models.celestial import BodyInfo
|
||||
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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from typing import Optional
|
|||
|
||||
from app.database import get_db
|
||||
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.task_service import task_service
|
||||
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.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
|
||||
from app.services.system_settings_service import system_settings_service
|
||||
from app.services.db_service import (
|
||||
celestial_body_service,
|
||||
position_service,
|
||||
nasa_cache_service,
|
||||
)
|
||||
from app.services.celestial_body_service import celestial_body_service
|
||||
from app.services.nasa_cache_service import nasa_cache_service
|
||||
from app.services.position_service import position_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ from typing import Optional, Dict, Any
|
|||
|
||||
from app.database import get_db
|
||||
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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from pydantic import BaseModel
|
|||
from typing import Optional, Dict, Any
|
||||
|
||||
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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from pydantic import BaseModel
|
|||
|
||||
from app.database import get_db
|
||||
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.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.social import router as social_router # Import social_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.cache_preheat import preheat_all_caches
|
||||
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(social_router, prefix=settings.api_prefix)
|
||||
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
|
||||
upload_dir = Path(__file__).parent.parent / "upload"
|
||||
|
|
@ -235,4 +237,4 @@ if __name__ == "__main__":
|
|||
port=8000,
|
||||
reload=True,
|
||||
log_level="info",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .system_settings import SystemSettings
|
|||
from .task import Task
|
||||
from .user_follow import UserFollow
|
||||
from .celestial_event import CelestialEvent
|
||||
from .rocket import RocketConfig
|
||||
|
||||
__all__ = [
|
||||
"CelestialBody",
|
||||
|
|
@ -33,4 +34,5 @@ __all__ = [
|
|||
"Task",
|
||||
"UserFollow",
|
||||
"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.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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.database import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CelestialBodyService:
|
||||
"""Service for celestial body operations"""
|
||||
|
||||
@staticmethod
|
||||
async def get_all_bodies(
|
||||
session: Optional[AsyncSession] = None,
|
||||
body_type: Optional[str] = None,
|
||||
system_id: Optional[int] = None
|
||||
) -> List[CelestialBody]:
|
||||
"""
|
||||
Get all celestial bodies, optionally filtered by type and star system
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
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()
|
||||
from app.services.celestial_body_service import (
|
||||
CelestialBodyService,
|
||||
celestial_body_service,
|
||||
)
|
||||
from app.services.nasa_cache_service import NasaCacheService, nasa_cache_service
|
||||
from app.services.position_service import PositionService, position_service
|
||||
from app.services.resource_service import ResourceService, resource_service
|
||||
from app.services.static_data_service import StaticDataService, static_data_service
|
||||
|
||||
__all__ = [
|
||||
"CelestialBodyService",
|
||||
"NasaCacheService",
|
||||
"PositionService",
|
||||
"ResourceService",
|
||||
"StaticDataService",
|
||||
"celestial_body_service",
|
||||
"nasa_cache_service",
|
||||
"position_service",
|
||||
"resource_service",
|
||||
"static_data_service",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.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.orbit_service import orbit_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))
|
||||
|
||||
from app.services.horizons import horizons_service
|
||||
from app.services.db_service import (
|
||||
celestial_body_service,
|
||||
position_service,
|
||||
nasa_cache_service
|
||||
)
|
||||
from app.services.celestial_body_service import celestial_body_service
|
||||
from app.services.nasa_cache_service import nasa_cache_service
|
||||
from app.services.position_service import position_service
|
||||
from app.services.redis_cache import redis_cache, cache_nasa_response
|
||||
from app.config import settings
|
||||
|
||||
|
|
|
|||
|
|
@ -5420,7 +5420,8 @@ CREATE TABLE "public"."users" (
|
|||
"is_active" bool NOT NULL,
|
||||
"created_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";
|
||||
|
|
@ -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"."is_active" IS 'Account active status';
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.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):
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import os
|
|||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
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 sqlalchemy import select, update, 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 过期 |
|
||||
| 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/)
|
||||
|
||||
---
|
||||
|
|
@ -87,4 +87,4 @@
|
|||
3. **验证**:
|
||||
* 测试关注/取关。
|
||||
* 测试频道消息发送与接收(验证 Redis 存储)。
|
||||
* 测试 Horizons 缓存生效。
|
||||
* 测试 Horizons 缓存生效。
|
||||
|
|
@ -328,4 +328,4 @@ function getPlanetTexture(radius, temp) {
|
|||
// 4. 宜居带/岩石行星 (默认)
|
||||
return textureLoader.load(PATH + 'rocky_atmosphere.jpg');
|
||||
}
|
||||
```
|
||||
```
|
||||
|
|
@ -364,7 +364,7 @@ POSITIVE:
|
|||
- **Model Source**: `/Users/jiliu/WorkSpace/cosmo/backend/upload/model/`
|
||||
- **Texture Build Output**: `/Users/jiliu/WorkSpace/cosmo/frontend/dist/textures/`
|
||||
- **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
|
||||
3. Implement virtual scrolling for star catalog
|
||||
4. Add service worker for persistent caching
|
||||
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"baseline-browser-mapping": "^2.10.43",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { TimelineController } from './components/TimelineController';
|
|||
import { Loading } from './components/Loading';
|
||||
import { ControlPanel } from './components/ControlPanel';
|
||||
import { AuthModal } from './components/AuthModal';
|
||||
import { BodyDetailOverlay } from './components/BodyDetailOverlay';
|
||||
import { auth } from './utils/auth';
|
||||
import type { CelestialBody } from './types';
|
||||
import { useToast } from './contexts/ToastContext';
|
||||
|
|
@ -21,7 +22,6 @@ import { useToast } from './contexts/ToastContext';
|
|||
// Lazy load non-critical components for better performance
|
||||
const InterstellarTicker = lazy(() => import('./components/InterstellarTicker').then(m => ({ default: m.InterstellarTicker })));
|
||||
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
|
||||
const TIMELINE_DAYS = 30; // Total days in timeline range
|
||||
|
|
@ -223,6 +223,7 @@ function App() {
|
|||
setViewMode(prev => prev === 'solar' ? 'galaxy' : 'solar');
|
||||
setSelectedBody(null); // Reset selection when switching
|
||||
}}
|
||||
onOpenRocketSimulator={() => navigate('/rocket-simulator')}
|
||||
/>
|
||||
|
||||
{/* Auth Modal */}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,37 @@
|
|||
/**
|
||||
* Router configuration
|
||||
*/
|
||||
import { lazy, Suspense } from 'react';
|
||||
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 { 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
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -33,12 +45,14 @@ export function Router() {
|
|||
return (
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Main app (3D visualization) */}
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/rocket-simulator" element={<RocketSimulator />} />
|
||||
|
||||
{/* User routes (protected) */}
|
||||
<Route
|
||||
|
|
@ -73,11 +87,13 @@ export function Router() {
|
|||
<Route path="tasks" element={<Tasks />} />
|
||||
<Route path="scheduled-jobs" element={<ScheduledJobs />} />
|
||||
<Route path="settings" element={<SystemSettings />} />
|
||||
<Route path="rockets" element={<Rockets />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,13 +15,6 @@ interface BeltProps {
|
|||
rotationSpeed: number;
|
||||
}
|
||||
|
||||
interface BeltData {
|
||||
category: string;
|
||||
name: string;
|
||||
name_zh: string;
|
||||
data: BeltProps;
|
||||
}
|
||||
|
||||
function Belt({
|
||||
innerRadiusAU,
|
||||
outerRadiusAU,
|
||||
|
|
|
|||
|
|
@ -39,20 +39,30 @@ export function BodyDetailOverlay({ bodyId, preloadedData, onClose }: BodyDetail
|
|||
return;
|
||||
}
|
||||
|
||||
// Guard against out-of-order responses and setState-after-unmount.
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
request.get(`/celestial/info/${bodyId}`)
|
||||
.then(response => {
|
||||
setBodyData(response.data);
|
||||
if (active) setBodyData(response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
if (!active) return;
|
||||
console.error("Failed to fetch body details:", error);
|
||||
toast.error("加载天体详情失败");
|
||||
onClose(); // Close overlay on error
|
||||
})
|
||||
.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) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
|
||||
import { Mesh, Group, DoubleSide } 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 type { CelestialBody as CelestialBodyType } from '../types';
|
||||
import { fetchBodyResources } from '../utils/api';
|
||||
import { getCelestialSize } from '../config/celestialSizes';
|
||||
import { useSystemSetting } from '../hooks/useSystemSetting';
|
||||
import { useOptionalTexture } from '../hooks/useOptionalTexture';
|
||||
|
||||
interface BodyViewerProps {
|
||||
body: CelestialBodyType;
|
||||
|
|
@ -15,7 +16,7 @@ interface BodyViewerProps {
|
|||
|
||||
// Planetary Rings component - handles texture-based rings
|
||||
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
const texture = useOptionalTexture(texturePath);
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
|
||||
// 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
|
||||
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, ringTexturePath, meshRef, disableGlow = false }: {
|
||||
body: CelestialBodyType;
|
||||
|
|
@ -281,10 +418,10 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
|||
emissiveIntensity: number;
|
||||
texturePath: string | null;
|
||||
ringTexturePath?: string | null;
|
||||
meshRef: React.RefObject<Mesh>;
|
||||
meshRef: React.RefObject<Mesh | null>;
|
||||
disableGlow?: boolean;
|
||||
}) {
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
const texture = useOptionalTexture(texturePath);
|
||||
|
||||
// Rotation animation
|
||||
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 (
|
||||
<group>
|
||||
{/* 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 */}
|
||||
{texture ? (
|
||||
<meshStandardMaterial
|
||||
key="textured"
|
||||
map={texture}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
|
|
@ -448,6 +452,7 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
|||
/>
|
||||
) : (
|
||||
<meshStandardMaterial
|
||||
key="plain"
|
||||
color="#888888"
|
||||
emissive={emissive}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
/**
|
||||
* 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 { Vector3 } from 'three';
|
||||
import type { CelestialBody } from '../types';
|
||||
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 {
|
||||
focusTarget: CelestialBody | null;
|
||||
|
|
@ -13,6 +21,8 @@ interface CameraControllerProps {
|
|||
onAnimationComplete?: () => void;
|
||||
resetTrigger?: number;
|
||||
defaultPosition?: [number, number, number];
|
||||
typeConfigs?: CelestialTypeConfigs;
|
||||
controlsRef: RefObject<OrbitControlsLike | null>;
|
||||
}
|
||||
|
||||
export function CameraController({
|
||||
|
|
@ -20,83 +30,78 @@ export function CameraController({
|
|||
allBodies,
|
||||
onAnimationComplete,
|
||||
resetTrigger = 0,
|
||||
defaultPosition = [10, 8, 10] // Default closer to inner solar system
|
||||
defaultPosition = [10, 8, 10],
|
||||
typeConfigs,
|
||||
controlsRef,
|
||||
}: CameraControllerProps) {
|
||||
const { camera } = useThree();
|
||||
const targetPosition = useRef(new Vector3());
|
||||
const targetLookPosition = useRef(new Vector3());
|
||||
const isAnimating = useRef(false);
|
||||
const isResetting = useRef(false);
|
||||
const animationProgress = useRef(0);
|
||||
const startPosition = useRef(new Vector3());
|
||||
const startLookPosition = useRef(new Vector3());
|
||||
const trackedLookPosition = useRef(new Vector3());
|
||||
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
|
||||
useEffect(() => {
|
||||
if (resetTrigger !== lastResetTrigger.current) {
|
||||
lastResetTrigger.current = resetTrigger;
|
||||
// Force reset
|
||||
targetPosition.current.set(...defaultPosition);
|
||||
targetLookPosition.current.set(0, 0, 0);
|
||||
startPosition.current.copy(camera.position);
|
||||
startLookPosition.current.copy(controlsRef.current?.target ?? new Vector3());
|
||||
isAnimating.current = true;
|
||||
isResetting.current = true;
|
||||
animationProgress.current = 0;
|
||||
}
|
||||
}, [resetTrigger, camera, defaultPosition]); // Only run when resetTrigger changes
|
||||
}, [resetTrigger, camera, defaultPosition, controlsRef]); // Only run when resetTrigger changes
|
||||
|
||||
// Handle focus target changes
|
||||
// Trigger only on the SELECTED body changing (by id), not on every new bodies array.
|
||||
useEffect(() => {
|
||||
const bodies = allBodiesRef.current;
|
||||
if (focusTarget) {
|
||||
isResetting.current = false; // Cancel reset if target selected
|
||||
|
||||
|
||||
// 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 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
|
||||
const pos = focusTarget.positions[0];
|
||||
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
||||
const parentInfo = findParentPlanet(focusTarget, allBodies);
|
||||
|
||||
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
|
||||
);
|
||||
targetLookPosition.current.copy(currentTargetPos);
|
||||
targetPosition.current.copy(currentTargetPos)
|
||||
.addScaledVector(viewDirection, focusDistance)
|
||||
.add(new Vector3(0, focusDistance * 0.12, 0));
|
||||
|
||||
// Start animation to target
|
||||
startPosition.current.copy(camera.position);
|
||||
startLookPosition.current.copy(previousLook);
|
||||
trackedLookPosition.current.copy(currentTargetPos);
|
||||
isAnimating.current = true;
|
||||
animationProgress.current = 0;
|
||||
|
||||
|
|
@ -108,9 +113,11 @@ export function CameraController({
|
|||
animationProgress.current = 0;
|
||||
}
|
||||
}
|
||||
}, [focusTarget, allBodies, camera]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusTarget?.id, camera, controlsRef, typeConfigs]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const orbitControls = controlsRef.current;
|
||||
if (isAnimating.current) {
|
||||
// Smooth animation using easing
|
||||
animationProgress.current += delta * 0.8; // Animation speed
|
||||
|
|
@ -129,15 +136,24 @@ export function CameraController({
|
|||
// Interpolate camera position
|
||||
camera.position.lerpVectors(startPosition.current, targetPosition.current, eased);
|
||||
|
||||
// Look at target - use smart rendered position (only during animation)
|
||||
if (focusTarget) {
|
||||
const renderPos = calculateRenderPosition(focusTarget, allBodies);
|
||||
camera.lookAt(renderPos.x, renderPos.z, renderPos.y);
|
||||
} else {
|
||||
camera.lookAt(0, 0, 0);
|
||||
if (orbitControls) {
|
||||
orbitControls.target.lerpVectors(startLookPosition.current, targetLookPosition.current, eased);
|
||||
orbitControls.update();
|
||||
}
|
||||
camera.lookAt(targetLookPosition.current);
|
||||
} 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;
|
||||
|
|
|
|||
|
|
@ -5,24 +5,26 @@ import { useRef, useMemo, useState, useEffect } from 'react';
|
|||
import { Mesh, DoubleSide } from 'three'; // Removed AdditiveBlending here
|
||||
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
|
||||
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 { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
||||
import { fetchBodyResources } from '../utils/api';
|
||||
import { getCelestialSize } from '../config/celestialSizes';
|
||||
import { createLabelTexture } from '../utils/labelTexture';
|
||||
import { useOptionalTexture } from '../hooks/useOptionalTexture';
|
||||
|
||||
interface CelestialBodyProps {
|
||||
body: CelestialBodyType;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
showLabel?: boolean;
|
||||
onBodySelect?: (body: CelestialBodyType) => void;
|
||||
typeConfigs?: any;
|
||||
}
|
||||
|
||||
// Planetary Rings component - handles texture-based rings
|
||||
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
const texture = useOptionalTexture(texturePath);
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
|
||||
// Dynamic ring dimensions based on planet size
|
||||
|
|
@ -87,13 +89,14 @@ function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string |
|
|||
}
|
||||
|
||||
// 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;
|
||||
size: number;
|
||||
emissive: string;
|
||||
emissiveIntensity: number;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
showLabel?: boolean;
|
||||
onBodySelect?: (body: CelestialBodyType) => void;
|
||||
typeConfigs?: any;
|
||||
}) {
|
||||
|
|
@ -164,6 +167,7 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
|
|||
hasOffset={renderPosition.hasOffset}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
showLabel={showLabel}
|
||||
onBodySelect={onBodySelect}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -215,6 +219,7 @@ function IrregularNucleus({ size, texture, onClick }: { size: number; texture: T
|
|||
>
|
||||
{texture ? (
|
||||
<meshStandardMaterial
|
||||
key="textured"
|
||||
map={texture}
|
||||
roughness={0.9}
|
||||
metalness={0.1}
|
||||
|
|
@ -222,6 +227,7 @@ function IrregularNucleus({ size, texture, onClick }: { size: number; texture: T
|
|||
/>
|
||||
) : (
|
||||
<meshStandardMaterial
|
||||
key="plain"
|
||||
color="#6b6b6b"
|
||||
roughness={0.95}
|
||||
metalness={0.05}
|
||||
|
|
@ -309,7 +315,7 @@ function CometComa({ radius }: { radius: number }) {
|
|||
}
|
||||
|
||||
// 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;
|
||||
size: number;
|
||||
emissive: string;
|
||||
|
|
@ -318,14 +324,14 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
texturePath: string | null;
|
||||
ringTexturePath?: string | null;
|
||||
position: { x: number; y: number; z: number };
|
||||
meshRef: React.RefObject<Mesh>;
|
||||
meshRef: React.RefObject<Mesh | null>;
|
||||
hasOffset: boolean;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
showLabel?: boolean;
|
||||
onBodySelect?: (body: CelestialBodyType) => void;
|
||||
}) {
|
||||
// Load texture if path is provided
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
const texture = useOptionalTexture(texturePath);
|
||||
|
||||
// Slow rotation for visual effect
|
||||
useFrame((_, delta) => {
|
||||
|
|
@ -380,6 +386,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
<sphereGeometry args={[size, 32, 32]} />
|
||||
{texture ? (
|
||||
<meshStandardMaterial
|
||||
key="textured"
|
||||
map={texture}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
|
|
@ -390,6 +397,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
/>
|
||||
) : (
|
||||
<meshStandardMaterial
|
||||
key="plain"
|
||||
color="#888888"
|
||||
emissive={emissive}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
|
|
@ -463,7 +471,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
)}
|
||||
|
||||
{/* Name label using CanvasTexture */}
|
||||
{labelTexture && (
|
||||
{showLabel && labelTexture && (
|
||||
<Billboard
|
||||
position={[0, size + 0.8, 0]} // Slightly closer to body
|
||||
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)
|
||||
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
|
||||
// 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(() => {
|
||||
if (body.type === 'star') {
|
||||
return {
|
||||
|
|
@ -534,6 +537,13 @@ export function CelestialBody({ body, allBodies, isSelected = false, onBodySelec
|
|||
};
|
||||
}, [body, typeConfigs]);
|
||||
|
||||
if (!position) return null;
|
||||
|
||||
// Skip probes - they will use 3D models
|
||||
if (body.type === 'probe') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Planet
|
||||
body={body}
|
||||
|
|
@ -542,6 +552,7 @@ export function CelestialBody({ body, allBodies, isSelected = false, onBodySelec
|
|||
emissiveIntensity={appearance.emissiveIntensity}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
showLabel={showLabel}
|
||||
onBodySelect={onBodySelect}
|
||||
typeConfigs={typeConfigs}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
Calendar,
|
||||
Orbit,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
MessageSquare,
|
||||
|
|
@ -9,6 +8,7 @@ import {
|
|||
Camera,
|
||||
Globe,
|
||||
Sparkles
|
||||
,Rocket
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ControlPanelProps {
|
||||
|
|
@ -23,6 +23,7 @@ interface ControlPanelProps {
|
|||
onScreenshot: () => void;
|
||||
viewMode: 'solar' | 'galaxy';
|
||||
onToggleViewMode: () => void;
|
||||
onOpenRocketSimulator: () => void;
|
||||
}
|
||||
|
||||
export function ControlPanel({
|
||||
|
|
@ -37,6 +38,7 @@ export function ControlPanel({
|
|||
onScreenshot,
|
||||
viewMode,
|
||||
onToggleViewMode,
|
||||
onOpenRocketSimulator,
|
||||
}: ControlPanelProps) {
|
||||
const buttonClass = (isActive: boolean) => `
|
||||
p-2 rounded-lg transition-all duration-200 relative group
|
||||
|
|
@ -50,6 +52,15 @@ export function ControlPanel({
|
|||
|
||||
return (
|
||||
<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 */}
|
||||
<button
|
||||
onClick={onToggleViewMode}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
|
|||
`;
|
||||
|
||||
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">
|
||||
<style>{styles}</style>
|
||||
{/* Main Info Card */}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { UserAuth } from './UserAuth';
|
||||
import type { AuthUser } from '../utils/auth';
|
||||
|
||||
interface HeaderProps {
|
||||
selectedBodyName?: string;
|
||||
bodyCount: number;
|
||||
cutoffDate?: Date | null;
|
||||
user?: any;
|
||||
user?: AuthUser | null;
|
||||
onOpenAuth?: () => void;
|
||||
onLogout?: () => void;
|
||||
onNavigateToAdmin?: () => void;
|
||||
|
|
@ -72,4 +73,4 @@ export function Header({
|
|||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,17 +15,18 @@ interface ProbeProps {
|
|||
body: CelestialBody;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
showLabel?: boolean;
|
||||
onBodySelect?: (body: CelestialBody) => void;
|
||||
typeConfigs?: any;
|
||||
}
|
||||
|
||||
// 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;
|
||||
modelPath: string;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
onError: () => void;
|
||||
showLabel?: boolean;
|
||||
resourceScale?: number;
|
||||
onBodySelect?: (body: CelestialBody) => void;
|
||||
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)
|
||||
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||
|
||||
// Get offset description if this probe has one
|
||||
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
||||
|
||||
// Handle click event
|
||||
const handleClick = (e: any) => {
|
||||
e.stopPropagation();
|
||||
if (onBodySelect) {
|
||||
onBodySelect(body);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate label texture
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
// 6. Hook: Label texture (must be called before any early return to keep hook order stable)
|
||||
const labelTexture = useMemo(() => {
|
||||
return createLabelTexture(
|
||||
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]);
|
||||
|
||||
// Render Logic
|
||||
if (!scene || !configuredScene) return null;
|
||||
|
||||
// Handle click event
|
||||
const handleClick = (e: any) => {
|
||||
e.stopPropagation();
|
||||
if (onBodySelect) {
|
||||
onBodySelect(body);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group
|
||||
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
|
||||
|
|
@ -153,7 +153,7 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
|||
/>
|
||||
|
||||
{/* Name label with CanvasTexture */}
|
||||
{labelTexture && (
|
||||
{showLabel && labelTexture && (
|
||||
<Billboard
|
||||
position={[0, optimalScale * 2.5, 0]}
|
||||
follow={true}
|
||||
|
|
@ -178,10 +178,11 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
|||
}
|
||||
|
||||
// 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;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
showLabel?: boolean;
|
||||
onBodySelect?: (body: CelestialBody) => void;
|
||||
typeConfigs?: any;
|
||||
}) {
|
||||
|
|
@ -231,7 +232,7 @@ function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect, type
|
|||
</mesh>
|
||||
|
||||
{/* Name label with CanvasTexture */}
|
||||
{labelTexture && (
|
||||
{showLabel && labelTexture && (
|
||||
<Billboard
|
||||
position={[0, 1, 0]}
|
||||
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 [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
|
||||
const [loadError, setLoadError] = useState<boolean>(false);
|
||||
|
|
@ -312,11 +313,9 @@ export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeC
|
|||
modelPath={modelPath}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
showLabel={showLabel}
|
||||
resourceScale={resourceScale}
|
||||
onBodySelect={onBodySelect}
|
||||
onError={() => {
|
||||
setLoadError(true);
|
||||
}}
|
||||
typeConfigs={typeConfigs}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -325,6 +324,7 @@ export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeC
|
|||
body={body}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
showLabel={showLabel}
|
||||
onBodySelect={onBodySelect}
|
||||
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
|
||||
useEffect(() => {
|
||||
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
|
||||
setExpandedGroup(selectedBody.type);
|
||||
}
|
||||
|
|
@ -79,6 +75,10 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
setExpandedGroup(prev => prev === groupName ? null : groupName);
|
||||
};
|
||||
|
||||
const handleBodySelect = (body: CelestialBody) => {
|
||||
onBodySelect(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
|
|
@ -161,7 +161,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
isExpanded={expandedGroup === 'star'}
|
||||
onToggle={() => toggleGroup('star')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
onBodySelect={handleBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
isExpanded={expandedGroup === 'planet'}
|
||||
onToggle={() => toggleGroup('planet')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
onBodySelect={handleBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
isExpanded={expandedGroup === 'dwarf_planet'}
|
||||
onToggle={() => toggleGroup('dwarf_planet')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
onBodySelect={handleBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
isExpanded={expandedGroup === 'satellite'}
|
||||
onToggle={() => toggleGroup('satellite')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
onBodySelect={handleBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -217,7 +217,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
isExpanded={expandedGroup === 'comet'}
|
||||
onToggle={() => toggleGroup('comet')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
onBodySelect={handleBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -231,7 +231,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
isExpanded={expandedGroup === 'probe'}
|
||||
onToggle={() => toggleGroup('probe')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
onBodySelect={handleBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
*/
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
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 { Probe } from './Probe';
|
||||
import { CameraController } from './CameraController';
|
||||
|
|
@ -15,7 +17,6 @@ import { Galaxies } from './Galaxies';
|
|||
import { Nebulae } from './Nebulae';
|
||||
import { FocusInfo } from './FocusInfo';
|
||||
import { AsteroidBelts } from './AsteroidBelts';
|
||||
import { scalePosition } from '../utils/scaleDistance';
|
||||
import { calculateRenderPosition } from '../utils/renderPosition';
|
||||
import type { CelestialBody as CelestialBodyType, Position } from '../types';
|
||||
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) {
|
||||
const [typeConfigs] = useSystemSetting('celestial_type_configs', null);
|
||||
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)
|
||||
const defaultCameraPos = useMemo(() => {
|
||||
|
|
@ -85,31 +109,21 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
// Always show all probes (changed from previous behavior)
|
||||
const visibleProbes = probes;
|
||||
|
||||
// Calculate target position for OrbitControls
|
||||
const controlsTarget = useMemo(() => {
|
||||
if (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 currentSelectedBody = useMemo(
|
||||
() => selectedBody ? bodies.find((body) => body.id === selectedBody.id) ?? selectedBody : null,
|
||||
[bodies, selectedBody],
|
||||
);
|
||||
const focusInfoPosition = useMemo(() => {
|
||||
if (!selectedBody) return [0, 0, 0] as [number, number, number];
|
||||
|
||||
// We need to use the EXACT same logic as CelestialBody/Probe components
|
||||
// to ensure the label sticks to the object
|
||||
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]);
|
||||
if (!currentSelectedBody) return [0, 0, 0] as [number, number, number];
|
||||
const position = calculateRenderPosition(currentSelectedBody, bodies, typeConfigs);
|
||||
return [position.x, position.z, position.y] as [number, number, number];
|
||||
}, [bodies, currentSelectedBody, typeConfigs]);
|
||||
|
||||
return (
|
||||
<div id="cosmo-scene-container" className="w-full h-full bg-black">
|
||||
<Canvas
|
||||
key={rendererGeneration}
|
||||
style={{ background: '#000000' }}
|
||||
camera={{
|
||||
position: defaultCameraPos, // Dynamic default position
|
||||
fov: 60, // Slightly narrower FOV for less distortion
|
||||
|
|
@ -120,19 +134,8 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
antialias: true,
|
||||
preserveDrawingBuffer: true, // Required for screenshots
|
||||
}}
|
||||
onCreated={({ gl, camera }) => {
|
||||
gl.sortObjects = true; // Enable object sorting by renderOrder
|
||||
camera.lookAt(0, 0, 0); // Look at the Sun (center)
|
||||
}}
|
||||
onCreated={handleCanvasCreated}
|
||||
>
|
||||
{/* Camera controller for smooth transitions */}
|
||||
<CameraController
|
||||
focusTarget={selectedBody}
|
||||
allBodies={bodies}
|
||||
resetTrigger={resetTrigger}
|
||||
defaultPosition={defaultCameraPos}
|
||||
/>
|
||||
|
||||
{/* Increase ambient light to see textures better */}
|
||||
<ambientLight intensity={0.5} />
|
||||
|
||||
|
|
@ -171,6 +174,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
body={body}
|
||||
allBodies={bodies}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
showLabel={!selectedBody || selectedBody.id === body.id}
|
||||
onBodySelect={onBodySelect}
|
||||
typeConfigs={typeConfigs}
|
||||
/>
|
||||
|
|
@ -186,6 +190,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
body={body}
|
||||
allBodies={bodies}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
showLabel={!selectedBody || selectedBody.id === body.id}
|
||||
onBodySelect={onBodySelect}
|
||||
typeConfigs={typeConfigs}
|
||||
/>
|
||||
|
|
@ -202,23 +207,36 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
|
||||
{/* Camera controls */}
|
||||
<OrbitControls
|
||||
ref={orbitControlsRef}
|
||||
makeDefault
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
minDistance={2}
|
||||
enableDamping={true}
|
||||
dampingFactor={0.08}
|
||||
minDistance={0.5}
|
||||
maxDistance={500}
|
||||
target={controlsTarget}
|
||||
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 */}
|
||||
{selectedBody && showInfoPanel && (
|
||||
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
|
||||
<FocusInfo
|
||||
body={selectedBody}
|
||||
onClose={() => setShowInfoPanel(false)}
|
||||
toast={toast}
|
||||
onViewDetails={onViewDetails}
|
||||
{currentSelectedBody && showInfoPanel && (
|
||||
<Html position={focusInfoPosition} center zIndexRange={[30, 0]} style={{ pointerEvents: 'none' }}>
|
||||
<FocusInfo
|
||||
body={currentSelectedBody}
|
||||
onClose={() => setShowInfoPanel(false)}
|
||||
toast={toast}
|
||||
onViewDetails={onViewDetails}
|
||||
/>
|
||||
</Html>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
export interface TimelineState {
|
||||
|
|
@ -19,14 +19,19 @@ interface TimelineControllerProps {
|
|||
}
|
||||
|
||||
export function TimelineController({ onTimeChange, minDate, maxDate }: TimelineControllerProps) {
|
||||
// Swap: startDate is now (maxDate), endDate is past (minDate)
|
||||
const startDate = maxDate || new Date(); // Start from now
|
||||
const endDate = minDate || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // End at past
|
||||
// Swap: startDate is now (maxDate), endDate is past (minDate).
|
||||
// Reduce to primitive timestamps so identity is stable across parent re-renders
|
||||
// (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 [speed, setSpeed] = useState(1); // 1 day per second
|
||||
const animationFrameRef = useRef<number>();
|
||||
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||
const lastUpdateRef = useRef<number>(Date.now());
|
||||
|
||||
// Animation loop
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react';
|
||||
import { API_BASE_URL } from '../utils/request';
|
||||
import type { AuthUser } from '../utils/auth';
|
||||
|
||||
interface UserAuthProps {
|
||||
user: any;
|
||||
user: AuthUser | null | undefined;
|
||||
onOpenAuth: () => void;
|
||||
onLogout: () => void;
|
||||
onNavigateToAdmin: () => void;
|
||||
|
|
@ -26,7 +26,7 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
|
|||
|
||||
// Helper to get full avatar URL
|
||||
const getAvatarUrl = () => {
|
||||
if (!user?.avatar_url) return null;
|
||||
if (!user?.avatar_url) return undefined;
|
||||
return `/upload/${user.avatar_url}`;
|
||||
};
|
||||
|
||||
|
|
@ -77,8 +77,7 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
|
|||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Open admin in new tab
|
||||
window.open('/admin', '_blank');
|
||||
onNavigateToAdmin();
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { Table, Input, Button, Space, Popconfirm, Switch, Card, Tooltip } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface DataTableProps<T> {
|
||||
|
|
@ -14,14 +13,18 @@ interface DataTableProps<T> {
|
|||
pageSize?: number;
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
onSearch?: (keyword: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
onAdd?: () => void;
|
||||
showAdd?: boolean;
|
||||
onEdit?: (record: T) => void;
|
||||
showEdit?: boolean;
|
||||
onDelete?: (record: T) => void;
|
||||
onStatusChange?: (record: T, checked: boolean) => void;
|
||||
statusField?: keyof T; // Field name for the status switch (e.g., 'is_active')
|
||||
rowKey?: string;
|
||||
// Custom actions to be added before edit/delete buttons
|
||||
customActions?: (record: T) => ReactNode;
|
||||
scroll?: TableProps<T>['scroll'];
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
|
|
@ -34,16 +37,18 @@ export function DataTable<T extends object>({
|
|||
pageSize = 10,
|
||||
onPageChange,
|
||||
onSearch,
|
||||
searchPlaceholder = '搜索...',
|
||||
onAdd,
|
||||
showAdd = true,
|
||||
onEdit,
|
||||
showEdit = true,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
statusField = 'is_active' as keyof T,
|
||||
rowKey = 'id',
|
||||
customActions,
|
||||
scroll = { x: 'max-content' },
|
||||
}: DataTableProps<T>) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// Inject action columns if callbacks are provided
|
||||
const tableColumns: ColumnsType<T> = [
|
||||
...columns,
|
||||
|
|
@ -80,7 +85,7 @@ export function DataTable<T extends object>({
|
|||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
{customActions && customActions(record)}
|
||||
{onEdit && (
|
||||
{onEdit && showEdit && (
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
|
|
@ -122,17 +127,16 @@ export function DataTable<T extends object>({
|
|||
<Space>
|
||||
{onSearch && (
|
||||
<Input.Search
|
||||
placeholder="搜索..."
|
||||
placeholder={searchPlaceholder}
|
||||
allowClear
|
||||
onSearch={onSearch}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
if (!e.target.value) onSearch(''); // Auto search on clear
|
||||
if (!e.target.value) onSearch('');
|
||||
}}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
)}
|
||||
{onAdd && (
|
||||
{onAdd && showAdd && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
新增
|
||||
</Button>
|
||||
|
|
@ -162,7 +166,7 @@ export function DataTable<T extends object>({
|
|||
showTotal: (total) => `共 ${total} 条`,
|
||||
}
|
||||
}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={scroll}
|
||||
/>
|
||||
</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,
|
||||
LogoutOutlined,
|
||||
RocketOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ControlOutlined,
|
||||
|
|
@ -22,7 +21,7 @@ import {
|
|||
StarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { authAPI, API_BASE_URL } from '../../utils/request';
|
||||
import { authAPI } from '../../utils/request';
|
||||
import { auth } from '../../utils/auth';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
|
|
@ -40,12 +39,12 @@ const iconMap: Record<string, any> = {
|
|||
sliders: <ControlOutlined />,
|
||||
profile: <IdcardOutlined />,
|
||||
star: <StarOutlined />,
|
||||
rocket: <RocketOutlined />,
|
||||
};
|
||||
|
||||
export function AdminLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menus, setMenus] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<any>(auth.getUser());
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
|
@ -90,8 +89,6 @@ export function AdminLayout() {
|
|||
setMenus(data);
|
||||
} catch (error) {
|
||||
toast.error('加载菜单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,189 +1,104 @@
|
|||
/**
|
||||
* NASA Data Download Page
|
||||
* 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 { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge, Modal } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import { request } from '../../utils/request';
|
||||
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
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);
|
||||
|
||||
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() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bodies, setBodies] = useState<GroupedBodies>({});
|
||||
const [selectedBodies, setSelectedBodies] = useState<string[]>([]);
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().startOf('month'),
|
||||
dayjs().endOf('month')
|
||||
]);
|
||||
const [dateRange, setDateRange] = useState<DateRange>([dayjs().startOf('month'), dayjs().endOf('month')]);
|
||||
const [availableDates, setAvailableDates] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [loadingDates, setLoadingDates] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState({ current: 0, total: 0 });
|
||||
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 toast = useToast();
|
||||
|
||||
// Get data cutoff date
|
||||
const { cutoffDate } = useDataCutoffDate();
|
||||
|
||||
// Type name mapping
|
||||
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 () => {
|
||||
const loadBodies = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await request.get('/celestial/positions/download/bodies');
|
||||
|
||||
// 只显示激活的天体 (is_active = true)
|
||||
const activeBodies: GroupedBodies = {};
|
||||
Object.keys(data.bodies || {}).forEach((type) => {
|
||||
const bodiesOfType = data.bodies[type].filter((body: CelestialBody) => body.is_active);
|
||||
if (bodiesOfType.length > 0) {
|
||||
activeBodies[type] = bodiesOfType;
|
||||
}
|
||||
const active = data.bodies[type].filter((body: CelestialBody) => body.is_active);
|
||||
if (active.length > 0) activeBodies[type] = active;
|
||||
});
|
||||
|
||||
setBodies(activeBodies);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('加载天体列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
const loadAvailableDates = async () => {
|
||||
const loadAvailableDates = useCallback(async () => {
|
||||
if (selectedBodies.length === 0) return;
|
||||
|
||||
setLoadingDates(true);
|
||||
try {
|
||||
const newAvailableDates = new Map<string, Set<string>>();
|
||||
|
||||
// Load available dates for ALL selected bodies
|
||||
const datesByBody = new Map<string, Set<string>>();
|
||||
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', {
|
||||
params: {
|
||||
body_id: bodyId,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}
|
||||
params: { body_id: bodyId, start_date: dateRange[0].format('YYYY-MM-DD'), end_date: dateRange[1].format('YYYY-MM-DD') },
|
||||
});
|
||||
|
||||
const bodyDates = new Set<string>();
|
||||
data.available_dates.forEach((date: string) => {
|
||||
bodyDates.add(date);
|
||||
});
|
||||
newAvailableDates.set(bodyId, bodyDates);
|
||||
datesByBody.set(bodyId, new Set<string>(data.available_dates));
|
||||
}
|
||||
|
||||
setAvailableDates(newAvailableDates);
|
||||
} catch (error) {
|
||||
setAvailableDates(datesByBody);
|
||||
} catch {
|
||||
toast.error('加载数据状态失败');
|
||||
} finally {
|
||||
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) => {
|
||||
setSelectedBodies(prev =>
|
||||
checked ? [...prev, bodyId] : prev.filter(id => id !== bodyId)
|
||||
);
|
||||
setSelectedBodies((current) => checked ? [...current, bodyId] : current.filter((id) => id !== bodyId));
|
||||
};
|
||||
|
||||
const handleTypeSelectAll = (type: string, checked: boolean) => {
|
||||
const typeBodyIds = bodies[type].map(b => b.id);
|
||||
setSelectedBodies(prev => {
|
||||
if (checked) {
|
||||
return [...new Set([...prev, ...typeBodyIds])];
|
||||
} else {
|
||||
return prev.filter(id => !typeBodyIds.includes(id));
|
||||
}
|
||||
});
|
||||
const bodyIds = bodies[type].map((body) => body.id);
|
||||
setSelectedBodies((current) => checked ? [...new Set([...current, ...bodyIds])] : current.filter((id) => !bodyIds.includes(id)));
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (dates: any) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setDateRange([dates[0], dates[1]]);
|
||||
const handleDateRangeChange = (dates: [Dayjs | null, Dayjs | null] | null) => {
|
||||
if (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) => {
|
||||
|
|
@ -191,65 +106,34 @@ export function NASADownload() {
|
|||
toast.warning('请先选择至少一个天体');
|
||||
return;
|
||||
}
|
||||
if (selectedDate?.isAfter(dayjs(), 'day')) {
|
||||
toast.warning('不能下载未来日期的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
let datesToDownload: string[] = [];
|
||||
const today = dayjs();
|
||||
|
||||
if (selectedDate) {
|
||||
// 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;
|
||||
}
|
||||
const dates = selectedDate ? [selectedDate.format('YYYY-MM-DD')] : getDatesInRange(true);
|
||||
if (dates.length === 0) {
|
||||
toast.warning('所选日期范围内没有可下载的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
setDownloadProgress({ current: 0, total: datesToDownload.length });
|
||||
|
||||
setDownloadProgress({ current: 0, total: dates.length });
|
||||
try {
|
||||
if (selectedDate) {
|
||||
// Sync download for single date
|
||||
const { data } = await request.post('/celestial/positions/download', {
|
||||
body_ids: selectedBodies,
|
||||
dates: datesToDownload
|
||||
});
|
||||
|
||||
setDownloadProgress({ current: datesToDownload.length, total: datesToDownload.length });
|
||||
|
||||
const { data } = await request.post('/celestial/positions/download', { body_ids: selectedBodies, dates });
|
||||
setDownloadProgress({ current: dates.length, total: dates.length });
|
||||
if (data.total_success > 0) {
|
||||
toast.success(`成功下载 ${data.total_success} 条数据${data.total_failed > 0 ? `,${data.total_failed} 条失败` : ''}`);
|
||||
loadAvailableDates();
|
||||
await loadAvailableDates();
|
||||
} else {
|
||||
toast.error('下载失败');
|
||||
}
|
||||
} else {
|
||||
// Async download for range
|
||||
await request.post('/celestial/positions/download-async', {
|
||||
body_ids: selectedBodies,
|
||||
dates: datesToDownload
|
||||
});
|
||||
await request.post('/celestial/positions/download-async', { body_ids: selectedBodies, dates });
|
||||
toast.success('批量下载任务已启动,请前往“系统任务”查看进度');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('请求失败');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
|
|
@ -257,423 +141,103 @@ export function NASADownload() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = () => {
|
||||
if (selectedBodies.length === 0) {
|
||||
toast.warning('请先选择至少一个天体');
|
||||
return;
|
||||
}
|
||||
|
||||
const start = dateRange[0];
|
||||
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;
|
||||
}
|
||||
const dates = getDatesInRange(false);
|
||||
if (dates.length === 0) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除 ${selectedBodies.length} 个天体在 ${datesToDelete.length} 天内的所有位置数据吗?此操作不可恢复。`,
|
||||
content: `确定要删除 ${selectedBodies.length} 个天体在 ${dates.length} 天内的所有位置数据吗?此操作不可恢复。`,
|
||||
okText: '确认删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const { data } = await request.post('/celestial/positions/delete', {
|
||||
body_ids: selectedBodies,
|
||||
dates: datesToDelete
|
||||
});
|
||||
|
||||
const { data } = await request.post('/celestial/positions/delete', { body_ids: selectedBodies, dates });
|
||||
toast.success(data.message || '数据已删除');
|
||||
loadAvailableDates(); // Refresh status
|
||||
} catch (error) {
|
||||
await loadAvailableDates();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Custom calendar cell renderer
|
||||
const dateCellRender = (value: Dayjs) => {
|
||||
const dateStr = value.format('YYYY-MM-DD');
|
||||
const inRange = value.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
||||
|
||||
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>
|
||||
);
|
||||
if (!activeBodyForCalendar || !value.isBetween(dateRange[0], dateRange[1], 'day', '[]')) return null;
|
||||
return <div style={{ textAlign: 'center', padding: '4px 0' }}><Badge status={availableDates.get(activeBodyForCalendar)?.has(value.format('YYYY-MM-DD')) ? 'success' : 'default'} text="" /></div>;
|
||||
};
|
||||
|
||||
const disabledDate = (current: Dayjs) => {
|
||||
// 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) => {
|
||||
const loadDateData = async (date: string) => {
|
||||
setLoadingDateData(true);
|
||||
try {
|
||||
// Query all selected bodies at once (comma-separated)
|
||||
const bodyIdsToQuery = selectedBodies.filter(bodyId => {
|
||||
const bodyDates = availableDates.get(bodyId);
|
||||
return bodyDates?.has(dateStr);
|
||||
});
|
||||
|
||||
if (bodyIdsToQuery.length === 0) {
|
||||
setViewingDateData({ date: dateStr, bodies: [] });
|
||||
setLoadingDateData(false);
|
||||
const bodyIds = selectedBodies.filter((bodyId) => availableDates.get(bodyId)?.has(date));
|
||||
if (bodyIds.length === 0) {
|
||||
setViewingDateData({ date, bodies: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Query all bodies in one request
|
||||
const { data } = await request.get('/celestial/positions', {
|
||||
params: {
|
||||
body_ids: bodyIdsToQuery.join(','),
|
||||
start_time: `${dateStr}T00:00:00Z`,
|
||||
end_time: `${dateStr}T00:00:00Z`
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Response for bodies ${bodyIdsToQuery.join(',')}:`, data);
|
||||
|
||||
const bodiesData = [];
|
||||
const allBodies = Object.values(bodies).flat();
|
||||
|
||||
// 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('加载数据失败');
|
||||
}
|
||||
const { data } = await request.get('/celestial/positions', {
|
||||
params: { body_ids: bodyIds.join(','), start_time: `${date}T00:00:00Z`, end_time: `${date}T00:00:00Z` },
|
||||
});
|
||||
const allBodies = Object.values(bodies).flat();
|
||||
const rows: PositionRow[] = [];
|
||||
data.bodies?.forEach((bodyData: { id: string; name?: string; positions?: PositionRow[] }) => {
|
||||
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 });
|
||||
});
|
||||
setViewingDateData({ date, bodies: rows });
|
||||
} catch {
|
||||
toast.error('加载数据失败');
|
||||
} finally {
|
||||
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 (
|
||||
<div>
|
||||
{/* Data Cutoff Date Display */}
|
||||
{cutoffDate && (
|
||||
<Alert
|
||||
title={`数据截止日期: ${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`}
|
||||
description="选择左侧天体,右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据(00:00 UTC)。"
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
{/* Left: Body Selection */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title="选择天体"
|
||||
loading={loading}
|
||||
extra={
|
||||
<Text type="secondary">
|
||||
已选择: {selectedBodies.length}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<NasaDownloadView
|
||||
cutoffDate={cutoffDate}
|
||||
loading={loading}
|
||||
bodies={bodies}
|
||||
selectedBodies={selectedBodies}
|
||||
dateRange={dateRange}
|
||||
loadingDates={loadingDates}
|
||||
downloading={downloading}
|
||||
deleting={deleting}
|
||||
downloadProgress={downloadProgress}
|
||||
activeBodyForCalendar={activeBodyForCalendar}
|
||||
viewingDateData={viewingDateData}
|
||||
loadingDateData={loadingDateData}
|
||||
onBodySelect={handleBodySelect}
|
||||
onTypeSelectAll={handleTypeSelectAll}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
onDelete={handleDelete}
|
||||
onDownload={() => void handleDownload()}
|
||||
onActiveBodyChange={setActiveBodyForCalendar}
|
||||
onCalendarDateClick={(date) => void handleCalendarDateClick(date)}
|
||||
onDateCellRender={dateCellRender}
|
||||
onCloseDateData={() => setViewingDateData(null)}
|
||||
disabledDate={(date) => date.isAfter(dayjs(), 'day')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
/**
|
||||
* Scheduled Jobs Management Page
|
||||
*/
|
||||
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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Form, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
job_type: 'predefined' | 'custom_code';
|
||||
predefined_function?: string;
|
||||
function_params?: Record<string, any>;
|
||||
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;
|
||||
}>;
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (typeof error !== 'object' || error === null || !('response' in error)) return null;
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } }).response?.data?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (typeof detail === 'object' && detail !== null && 'message' in detail) return String(detail.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ScheduledJobs() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<ScheduledJob[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<ScheduledJob[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<ScheduledJob | null>(null);
|
||||
const [activeTabKey, setActiveTabKey] = useState('basic');
|
||||
|
|
@ -51,89 +29,68 @@ export function ScheduledJobs() {
|
|||
const [form] = Form.useForm();
|
||||
const toast = useToast();
|
||||
|
||||
const jobType = Form.useWatch('job_type', form);
|
||||
const predefinedFunction = Form.useWatch('predefined_function', form);
|
||||
const jobType = Form.useWatch<ScheduledJob['job_type']>('job_type', 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(() => {
|
||||
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 () => {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: result } = await request.get('/scheduled-jobs');
|
||||
setData(result || []);
|
||||
setFilteredData(result || []);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
const loadAvailableTasks = async () => {
|
||||
const loadAvailableTasks = useCallback(async () => {
|
||||
try {
|
||||
const { data: result } = await request.get('/scheduled-jobs/available-tasks');
|
||||
setAvailableTasks(result || []);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('加载可用任务列表失败');
|
||||
}
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
const filtered = data.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerKeyword) ||
|
||||
item.description?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
setFilteredData(filtered);
|
||||
};
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
void loadAvailableTasks();
|
||||
}, [loadAvailableTasks, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!predefinedFunction || availableTasks.length === 0) {
|
||||
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 = () => {
|
||||
setEditingRecord(null);
|
||||
setSelectedTask(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
job_type: 'predefined',
|
||||
is_active: true,
|
||||
function_params: {}
|
||||
});
|
||||
form.setFieldsValue({ job_type: 'predefined', is_active: true, function_params: {} });
|
||||
setActiveTabKey('basic');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: ScheduledJob) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
function_params: record.function_params || {}
|
||||
});
|
||||
form.setFieldsValue({ ...record, function_params: record.function_params || {} });
|
||||
setActiveTabKey('basic');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
|
@ -142,8 +99,8 @@ export function ScheduledJobs() {
|
|||
try {
|
||||
await request.delete(`/scheduled-jobs/${record.id}`);
|
||||
toast.success('删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
await loadData();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
|
@ -152,8 +109,8 @@ export function ScheduledJobs() {
|
|||
try {
|
||||
await request.post(`/scheduled-jobs/${record.id}/run`);
|
||||
toast.success('定时任务已触发,请前往"系统任务"中查看进度');
|
||||
setTimeout(loadData, 1000);
|
||||
} catch (error) {
|
||||
window.setTimeout(() => void loadData(), 1000);
|
||||
} catch {
|
||||
toast.error('触发失败');
|
||||
}
|
||||
};
|
||||
|
|
@ -161,8 +118,6 @@ export function ScheduledJobs() {
|
|||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Clean up data based on job_type
|
||||
if (values.job_type === 'predefined') {
|
||||
delete values.python_code;
|
||||
} else {
|
||||
|
|
@ -177,124 +132,44 @@ export function ScheduledJobs() {
|
|||
await request.post('/scheduled-jobs', values);
|
||||
toast.success('创建成功');
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.detail) {
|
||||
const detail = error.response.data.detail;
|
||||
if (typeof detail === 'object' && detail.message) {
|
||||
toast.error(detail.message);
|
||||
} else {
|
||||
toast.error(detail);
|
||||
}
|
||||
}
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (message) toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ScheduledJob> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
width: 60,
|
||||
title: '任务名称', dataIndex: 'name', 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: '任务名称',
|
||||
dataIndex: 'name',
|
||||
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: '类型', dataIndex: 'job_type', width: 120,
|
||||
render: (type) => <Tag color={type === 'predefined' ? 'blue' : 'purple'}>{type === 'predefined' ? '内置任务' : '自定义代码'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'job_type',
|
||||
width: 120,
|
||||
render: (type) => (
|
||||
<Tag color={type === 'predefined' ? 'blue' : 'purple'}>
|
||||
{type === 'predefined' ? '内置任务' : '自定义代码'}
|
||||
</Tag>
|
||||
),
|
||||
title: '任务函数', dataIndex: 'predefined_function', width: 200,
|
||||
render: (func, record) => record.job_type === 'predefined' ? <Tag color="cyan">{func}</Tag> : <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) => 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: '任务函数',
|
||||
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,
|
||||
title: '操作', key: 'action', width: 150,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="立即执行">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlayCircleOutlined />}
|
||||
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>
|
||||
<Tooltip title="立即执行"><Button type="text" icon={<PlayCircleOutlined />} 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>
|
||||
</Space>
|
||||
),
|
||||
|
|
@ -309,278 +184,24 @@ export function ScheduledJobs() {
|
|||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
total={filteredData.length}
|
||||
onSearch={handleSearch}
|
||||
onSearch={setKeyword}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
rowKey="id"
|
||||
pageSize={10}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑任务' : '新增任务'}
|
||||
<ScheduledJobModal
|
||||
form={form}
|
||||
record={editingRecord}
|
||||
open={isModalOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={900}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<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>
|
||||
activeTab={activeTabKey}
|
||||
onActiveTabChange={setActiveTabKey}
|
||||
jobType={jobType}
|
||||
availableTasks={availableTasks}
|
||||
selectedTask={selectedTask}
|
||||
onClearSelectedTask={() => setSelectedTask(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 { Modal, Form, Input, InputNumber, Select, Button, Space, Popconfirm, Tag, Descriptions, Tabs } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Form, Popconfirm, Space, Tag } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
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 { request } from '../../utils/request';
|
||||
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();
|
||||
|
||||
interface StarSystem {
|
||||
id: number;
|
||||
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;
|
||||
function getErrorDetail(error: unknown, fallback: string) {
|
||||
if (typeof error !== 'object' || error === null || !('response' in error)) return fallback;
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } }).response?.data?.detail;
|
||||
return typeof detail === 'string' ? detail : fallback;
|
||||
}
|
||||
|
||||
export function StarSystems() {
|
||||
|
|
@ -55,49 +27,38 @@ export function StarSystems() {
|
|||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<StarSystem | null>(null);
|
||||
const [detailData, setDetailData] = useState<StarSystemWithBodies | null>(null);
|
||||
const [activeTabKey, setActiveTabKey] = useState('basic'); // 添加tab状态
|
||||
const [form] = Form.useForm();
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [currentPage, pageSize, searchKeyword]);
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: result } = await request.get('/star-systems', {
|
||||
params: {
|
||||
skip: (currentPage - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
search: searchKeyword || undefined,
|
||||
exclude_solar: false,
|
||||
},
|
||||
params: { skip: (currentPage - 1) * pageSize, limit: pageSize, search: searchKeyword || undefined, exclude_solar: false },
|
||||
});
|
||||
setData(result.systems || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('加载恒星系统数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [currentPage, pageSize, searchKeyword, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
color: '#FFFFFF',
|
||||
planet_count: 0,
|
||||
});
|
||||
setActiveTabKey('basic'); // 重置到基础信息tab
|
||||
form.setFieldsValue({ color: '#FFFFFF', planet_count: 0 });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: StarSystem) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue(record);
|
||||
setActiveTabKey('basic'); // 重置到基础信息tab
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -106,7 +67,7 @@ export function StarSystems() {
|
|||
const { data: result } = await request.get(`/star-systems/${record.id}/bodies`);
|
||||
setDetailData(result);
|
||||
setIsDetailModalOpen(true);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('加载恒星系统详情失败');
|
||||
}
|
||||
};
|
||||
|
|
@ -116,161 +77,60 @@ export function StarSystems() {
|
|||
toast.error('不能删除太阳系');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await request.delete(`/star-systems/${id}`);
|
||||
toast.success('删除成功');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || '删除失败');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(getErrorDetail(error, '删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingRecord) {
|
||||
// Update
|
||||
await request.put(`/star-systems/${editingRecord.id}`, values);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
// Create
|
||||
await request.post('/star-systems', values);
|
||||
toast.success('创建成功');
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'errorFields' in error) {
|
||||
toast.error('请填写必填字段');
|
||||
} else {
|
||||
toast.error(error.response?.data?.detail || '操作失败');
|
||||
toast.error(getErrorDetail(error, '操作失败'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<StarSystem> = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
title: '系统名称', dataIndex: 'name', 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: 'host_star_name', key: 'host_star_name', width: 150 },
|
||||
{
|
||||
title: '系统名称',
|
||||
dataIndex: 'name',
|
||||
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: 'distance_pc', key: 'distance', width: 120,
|
||||
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> : '-',
|
||||
},
|
||||
{ title: '光谱类型', dataIndex: 'spectral_type', key: 'spectral_type', width: 100, render: (text) => text || '-' },
|
||||
{
|
||||
title: '主恒星',
|
||||
dataIndex: 'host_star_name',
|
||||
key: 'host_star_name',
|
||||
width: 150,
|
||||
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: '距离',
|
||||
dataIndex: 'distance_pc',
|
||||
key: 'distance',
|
||||
width: 120,
|
||||
render: (pc, record) => {
|
||||
if (!pc) return '-';
|
||||
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>
|
||||
),
|
||||
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}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={(page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
onPageChange={(page, size) => { setCurrentPage(page); setPageSize(size); }}
|
||||
onAdd={handleAdd}
|
||||
onSearch={handleSearch}
|
||||
onSearch={(value) => { setSearchKeyword(value); setCurrentPage(1); }}
|
||||
searchPlaceholder="搜索恒星系统名称..."
|
||||
/>
|
||||
|
||||
{/* 创建/编辑 Modal */}
|
||||
<Modal
|
||||
title={editingRecord ? '编辑恒星系统' : '创建恒星系统'}
|
||||
<StarSystemModal
|
||||
key={`${editingRecord?.id || 'new'}-${isModalOpen}`}
|
||||
form={form}
|
||||
record={editingRecord}
|
||||
open={isModalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={1200}
|
||||
okText="保存"
|
||||
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>
|
||||
/>
|
||||
<StarSystemDetailsModal record={detailData} open={isDetailModalOpen} onClose={() => setIsDetailModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Switch, Select, Button, Card, Descriptions, Badge, Space, Popconfirm, Alert, Divider } from 'antd';
|
||||
import { ReloadOutlined, ClearOutlined, WarningOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { Modal, Form, Input, InputNumber, Switch, Select, Button, Card, Badge, Space, Popconfirm, Alert, Divider } from 'antd';
|
||||
import { ClearOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Tag, Progress, Button, Modal, Descriptions, Badge, Typography, Space } from 'antd';
|
||||
import { ReloadOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { Tag, Progress, Button, Modal, Descriptions, Badge, Typography } from 'antd';
|
||||
import { EyeOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* 个人信息页面
|
||||
*/
|
||||
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 { request, API_BASE_URL } from '../../utils/request';
|
||||
import { request } from '../../utils/request';
|
||||
import { auth } from '../../utils/auth';
|
||||
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