Compare commits

...

2 Commits

Author SHA1 Message Date
mula.liu 5433892bf6 1.1.0 2026-07-21 20:10:32 +08:00
mula.liu d93b4f787a 1.1.0 2026-07-21 20:09:51 +08:00
114 changed files with 5574 additions and 3463 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "yarn",
"runtimeArgs": ["--cwd", "frontend", "dev"],
"port": 5173,
"autoPort": true
}
]
}

View File

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

1
AGENTS.md 100644
View File

@ -0,0 +1 @@
- tools

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
data/.DS_Store vendored

Binary file not shown.

BIN
data/backups/.DS_Store vendored

Binary file not shown.

15
docs/README.md 100644
View File

@ -0,0 +1,15 @@
# Cosmo 文档
项目入口文档保留在仓库根目录:
- [README](../README.md)
- [项目说明](../PROJECT.md)
- [快速开始](../QUICKSTART.md)
- [部署指南](../DEPLOYMENT.md)
其余文档按用途归档:
- `architecture/`:架构、数据策略、算法和数据库设计
- `guides/`:配置、运维和功能使用指南
- `plans/`:路线图及阶段计划
- `reports/`:阶段完成报告、问题分析和修复记录

View File

@ -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 小时 (可配) |

View File

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

View File

@ -87,4 +87,4 @@
3. **验证**:
* 测试关注/取关。
* 测试频道消息发送与接收(验证 Redis 存储)。
* 测试 Horizons 缓存生效。
* 测试 Horizons 缓存生效。

View File

@ -328,4 +328,4 @@ function getPlanetTexture(radius, temp) {
// 4. 宜居带/岩石行星 (默认)
return textureLoader.load(PATH + 'rocky_atmosphere.jpg');
}
```
```

View File

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

View File

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

View File

@ -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 */}

View File

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

View File

@ -15,13 +15,6 @@ interface BeltProps {
rotationSpeed: number;
}
interface BeltData {
category: string;
name: string;
name_zh: string;
data: BeltProps;
}
function Belt({
innerRadiusAU,
outerRadiusAU,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '任务完成',
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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