From 0d38e1da47e808a4946a14331a322731f4128532 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Sun, 20 Sep 2026 13:48:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .CLAUDE.md | 35 +- .DS_Store | Bin 6148 -> 8196 bytes CLAUDE.md | 1 - DEPLOYMENT.md | 39 +- PROJECT.md | 225 ---- QUICKSTART.md | 200 --- README.md | 371 ----- backend/scripts/add_rocket_simulator.sql | 94 -- backend/scripts/add_user_avatar_column.sql | 2 - backend/scripts/fetch_and_cache.py | 198 --- backend/scripts/insert_sirius_system.sql | 93 -- backend/scripts/list_celestial_bodies.py | 31 - backend/scripts/populate_resources.py | 143 -- backend/scripts/prefetch_historical_data.py | 224 ---- backend/scripts/reset_admin_password.py | 45 - backend/scripts/run_sql.py | 40 - backend/scripts/seed_asteroid_belts.py | 83 -- backend/scripts/seed_celestial_bodies.py | 194 --- backend/scripts/update_static_data.py | 623 --------- docker-compose.yml | 2 +- docs/README.md | 7 +- docs/guides/BACKEND_CONFIG.md | 18 +- docs/guides/PROXY_SETUP.md | 2 +- frontend/src/Router.tsx | 26 +- frontend/src/components/admin/AdminPage.tsx | 76 ++ frontend/src/components/admin/DataTable.tsx | 196 +-- .../rocket-simulator/RocketFlightScene.tsx | 179 ++- .../features/rocket-simulator/RocketModel.tsx | 815 ++++++++--- .../rocket-simulator/rocket-simulator.css | 1188 +++++++++++------ .../features/rocket-simulator/sceneMath.ts | 204 ++- .../rocket-simulator/useRocketSimulation.ts | 105 +- frontend/src/pages/RocketSimulator.tsx | 444 ++++-- frontend/src/pages/admin/AdminLayout.tsx | 438 ++++-- .../src/pages/admin/AdminPrefsContext.tsx | 71 + frontend/src/pages/admin/CelestialBodies.tsx | 80 +- frontend/src/pages/admin/CelestialEvents.tsx | 75 +- frontend/src/pages/admin/ChangePassword.tsx | 95 -- frontend/src/pages/admin/Dashboard.tsx | 403 +++++- .../src/pages/admin/MyCelestialBodies.tsx | 475 +++---- frontend/src/pages/admin/Rockets.tsx | 54 +- frontend/src/pages/admin/ScheduledJobs.tsx | 52 +- frontend/src/pages/admin/StarSystems.tsx | 58 +- frontend/src/pages/admin/StaticData.tsx | 63 +- frontend/src/pages/admin/SystemSettings.tsx | 151 +-- frontend/src/pages/admin/Tasks.tsx | 230 ++-- frontend/src/pages/admin/UserProfile.tsx | 323 +++-- frontend/src/pages/admin/Users.tsx | 162 +-- frontend/src/pages/admin/admin.css | 913 +++++++++++++ frontend/src/pages/admin/adminI18n.ts | 164 +++ frontend/src/pages/admin/adminTheme.ts | 171 +++ .../celestial-bodies/CelestialBodyModal.tsx | 19 +- .../celestial-bodies/ResourceManager.tsx | 20 +- .../admin/nasa-download/NasaDownloadView.tsx | 248 +++- .../scheduled-jobs/ScheduledJobModal.tsx | 27 +- .../star-systems/StarSystemDetailsModal.tsx | 125 +- .../admin/star-systems/StarSystemModal.tsx | 73 +- frontend/src/pages/admin/useListPageSize.ts | 17 + {backend/scripts => scripts}/create_db.py | 6 +- deploy.sh => scripts/deploy.sh | 18 +- {backend/scripts => scripts}/init_db.py | 7 +- {backend/scripts => scripts}/init_db.sql | 0 scripts/run.sh | 244 ++++ {backend/scripts => scripts}/seed_admin.py | 6 +- {backend/scripts => scripts}/setup.sh | 43 +- 64 files changed, 6073 insertions(+), 4661 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 PROJECT.md delete mode 100644 QUICKSTART.md delete mode 100644 README.md delete mode 100644 backend/scripts/add_rocket_simulator.sql delete mode 100644 backend/scripts/add_user_avatar_column.sql delete mode 100755 backend/scripts/fetch_and_cache.py delete mode 100644 backend/scripts/insert_sirius_system.sql delete mode 100644 backend/scripts/list_celestial_bodies.py delete mode 100644 backend/scripts/populate_resources.py delete mode 100644 backend/scripts/prefetch_historical_data.py delete mode 100644 backend/scripts/reset_admin_password.py delete mode 100644 backend/scripts/run_sql.py delete mode 100644 backend/scripts/seed_asteroid_belts.py delete mode 100755 backend/scripts/seed_celestial_bodies.py delete mode 100644 backend/scripts/update_static_data.py create mode 100644 frontend/src/components/admin/AdminPage.tsx create mode 100644 frontend/src/pages/admin/AdminPrefsContext.tsx delete mode 100644 frontend/src/pages/admin/ChangePassword.tsx create mode 100644 frontend/src/pages/admin/admin.css create mode 100644 frontend/src/pages/admin/adminI18n.ts create mode 100644 frontend/src/pages/admin/adminTheme.ts create mode 100644 frontend/src/pages/admin/useListPageSize.ts rename {backend/scripts => scripts}/create_db.py (88%) rename deploy.sh => scripts/deploy.sh (93%) rename {backend/scripts => scripts}/init_db.py (91%) rename {backend/scripts => scripts}/init_db.sql (100%) create mode 100755 scripts/run.sh rename {backend/scripts => scripts}/seed_admin.py (97%) rename {backend/scripts => scripts}/setup.sh (82%) diff --git a/.CLAUDE.md b/.CLAUDE.md index fafc1e6..f162fc9 100644 --- a/.CLAUDE.md +++ b/.CLAUDE.md @@ -109,23 +109,24 @@ Cosmo 是一个深空探索可视化平台,使用 Three.js 在浏览器中实 - `POST /api/probe-positions` - 优化缓存策略 - `GET /health` - 新增 Redis 和数据库健康检查 -#### 2.3 数据迁移脚本 -创建了完整的初始化和迁移脚本 (`backend/scripts/`): +#### 2.3 启动 / 初始化 / 部署脚本 +脚本统一放在项目根目录 `scripts/`,只保留启动、初始化、部署三类: -**初始化脚本**: +**启动**: +- `run.sh` - 一键检查环境并启动前后端开发环境 + +**初始化**: +- `setup.sh` - 一键初始化(检查环境 + 建库建表 + 初始化管理员) - `create_db.py` - 创建数据库 - `init_db.py` - 初始化表结构 -- `check_config.py` - 验证配置 +- `seed_admin.py` - 初始化默认管理员、角色与菜单 +- `init_db.sql` - 完整数据库结构与数据(Docker 首次启动自动导入) -**数据迁移**: -- `migrate_data.py` - 迁移天体基础数据 -- `update_static_data.py` - 迁移静态数据 (星座、星系) -- `populate_resources.py` - 迁移资源文件记录 +**部署**: +- `deploy.sh` - Docker 生产部署 -**辅助脚本**: -- `fetch_and_cache.py` - 预取并缓存 NASA 数据 -- `list_celestial_bodies.py` - 列出所有天体 -- `add_pluto.py` - 添加冥王星数据示例 +> 一次性数据填充/迁移脚本(如天体数据、静态数据、资源登记)已清理, +> 相关数据已包含在 init_db.sql 中,由应用内定时任务持续更新。 #### 2.4 依赖管理 更新了后端依赖 (`backend/requirements.txt`): @@ -311,13 +312,11 @@ export const getProbePositions = async (probeIds: string[]) **后端初始化**: ```bash -cd backend -pip install -r requirements.txt +# 一键初始化(检查环境 + 建库建表):./scripts/setup.sh +cd backend && pip install -r requirements.txt cp .env.example .env # 修改配置 -python scripts/create_db.py -python scripts/init_db.py -python scripts/migrate_data.py -python -m uvicorn app.main:app --reload +cd .. && python scripts/create_db.py && python scripts/init_db.py && python scripts/seed_admin.py +cd backend && python -m uvicorn app.main:app --reload ``` **前端初始化**: diff --git a/.DS_Store b/.DS_Store index 569b66850ac53666b0bf5c5a333257e27085f367..7e4ee8773cf9d7b4e8f4a49ca4185dffd5ea2532 100644 GIT binary patch literal 8196 zcmeI1&ubGw6vyABso78pK`PXPu;4YuY^!O(ON=QZXcbcwQK`Gxv@YH5mTYR2P{>6D ze}H$vlXtKBhv=Uoc+&5inTE}#^`KCt&cMu@-S^&P=QD3-_iczsGc5K@XY379rD~)TGJW= zhQNPGfcFO*m1SMap^|dzKqFHC$UIKVf0@hI*K(+&bj6q|dl1@H=oCXJcj#?4 zhpcNkR8qNI*qv!ywSx{{_f1Pp;m0-U=S=`vlV63b&*9rJN-Rz1h&L zIv%yJ(91B`@RPQOt7Mmq>Pf2`g>keA4Qun{1745Z47Tskr6G^(jtQ{Qd@?*{sfpmb zMc3&j-J=^i5cOxIbYSJaWC)!E$ykSBm(HOV$0_Idx$!O)%6wHk zMlx=PTY~8QL3V$8HJl4&+o>)8Y50!jommZwHmD0C5hC<}n_F%LX)e>S+@t{Q({X}w z8%SFguqthg9bkoeS>8FmJJQjgu})F{eB_(P>A}ERt}^x|iQRE{*I3H#8oVo@9J*_~ zhB7m)3HS`Xi?C&&ZZ>$ubBypI9h7gH(c8|8)on9@@1J~2#gb$Qit=L|F0bW z{eRra92o+Jz_B7A^37JWfhq03?q+Al?@Tu8BUCPIH&jx(pkWg}FVk_z(I1BB+gexG Za;PLUC`bMw;5U*6^Zb`fWtw9|;3pM#*^dAK delta 158 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{MGjUEV6q~50D9Q|y2aBaLxp z&L{(tVP{BUNMuN6$Ywy2&Cg++Y$*JCW64*R#q1m$f*^%JAixbITtP-}Ed0(qnP0{c VWH-og77)z@u^cS6Ii6<@GXS-K8xH^g diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a85f662..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -- tools \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 74ed61a..b57de1a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -103,14 +103,15 @@ npm install --registry=https://registry.npmmirror.com cosmo/ ├── docker-compose.yml # Docker Compose 配置 ├── .env.production # 生产环境变量(需配置) -├── deploy.sh # 一键部署脚本 +├── scripts/ +│ ├── deploy.sh # 一键部署脚本(Docker 生产部署) +│ ├── run.sh # 一键启动开发环境(前端 + 后端) +│ └── init_db.sql # 数据库初始化 SQL ├── nginx/ │ └── nginx.conf # Nginx 反向代理配置 ├── backend/ │ ├── Dockerfile # 后端镜像配置 -│ ├── .dockerignore -│ └── scripts/ -│ └── init_db.sql # 数据库初始化 SQL +│ └── .dockerignore └── frontend/ ├── Dockerfile # 前端镜像配置(多阶段构建) └── .dockerignore @@ -176,10 +177,10 @@ HTTPS_PROXY= ```bash # 赋予执行权限 -chmod +x deploy.sh +chmod +x scripts/deploy.sh # 初始化系统(首次部署) -./deploy.sh --init +./scripts/deploy.sh --init ``` 初始化脚本会自动: @@ -222,26 +223,26 @@ sudo chmod -R 755 /opt/cosmo/data ```bash # 启动服务 -./deploy.sh --start +./scripts/deploy.sh --start # 停止服务 -./deploy.sh --stop +./scripts/deploy.sh --stop # 重启服务 -./deploy.sh --restart +./scripts/deploy.sh --restart # 查看状态 -./deploy.sh --status +./scripts/deploy.sh --status # 查看日志 -./deploy.sh --logs +./scripts/deploy.sh --logs ``` ### 数据备份 ```bash # 创建备份(数据库 + 上传文件) -./deploy.sh --backup +./scripts/deploy.sh --backup # 备份文件位置 ls -lh /opt/cosmo/data/backups/ @@ -251,17 +252,17 @@ ls -lh /opt/cosmo/data/backups/ ```bash # 拉取最新代码并重启 -./deploy.sh --update +./scripts/deploy.sh --update ``` ### 清理操作 ```bash # 删除容器(保留数据) -./deploy.sh --clean +./scripts/deploy.sh --clean # 完全清除(删除容器和所有数据)⚠️ 危险操作 -./deploy.sh --full-clean +./scripts/deploy.sh --full-clean ``` ## 🔧 手动操作 @@ -450,7 +451,7 @@ CORS_ORIGINS=http://domain.com/,https://domain.com/ 修改 `.env.production` 后需要重启服务: ```bash -./deploy.sh --restart +./scripts/deploy.sh --restart ``` ### 服务启动失败 @@ -504,7 +505,7 @@ df -h /opt/cosmo/data 1. 备份数据: ```bash -./deploy.sh --backup +./scripts/deploy.sh --backup ``` 2. 拉取最新代码: @@ -547,9 +548,9 @@ tar -xzf upload_backup.tar.gz -C /opt/cosmo/data/ ## 📞 技术支持 -- 项目文档: [README.md](./README.md) +- 项目文档: [docs/README.md](./docs/README.md) - Issue 反馈: GitHub Issues -- 配置说明: [CONFIG.md](./backend/CONFIG.md) +- 配置说明: [BACKEND_CONFIG.md](./docs/guides/BACKEND_CONFIG.md) ## 🎯 性能优化建议 diff --git a/PROJECT.md b/PROJECT.md deleted file mode 100644 index 75fe8c0..0000000 --- a/PROJECT.md +++ /dev/null @@ -1,225 +0,0 @@ -# Cosmo - 深空探测器可视化系统 - -## 项目概述 - -基于 NASA JPL Horizons 数据的深空探测器 3D 可视化系统,展示旅行者号、火星探测器等深空探测器在太阳系中的实时位置和历史轨迹。 - -## 技术栈 - -### 后端 -- **框架**: Python 3.11+ with FastAPI -- **核心库**: - - `fastapi` - Web 框架 - - `astroquery` - NASA JPL Horizons 数据查询 - - `astropy` - 天文计算和时间处理 - - `uvicorn` - ASGI 服务器 - - `pydantic` - 数据验证 - - `python-dotenv` - 环境变量管理 - -### 前端 -- **框架**: React 18 with TypeScript -- **构建工具**: Vite -- **3D 渲染**: - - `three` - 核心 3D 引擎 - - `@react-three/fiber` - React Three.js 集成 - - `@react-three/drei` - Three.js 辅助组件 -- **UI 库**: - - `tailwindcss` - 样式框架 - - `lucide-react` - 图标库 -- **状态管理**: React Hooks (useState, useContext) -- **HTTP 客户端**: `axios` - -## 核心功能 - -### 1. 数据获取 -- 从 NASA JPL Horizons 获取探测器和行星的日心坐标 (x, y, z) -- 支持时间序列查询(用户指定起止时间) -- 数据缓存策略:每3天更新一次 -- 单位:AU (天文单位) - -### 2. 支持的天体 - -#### 探测器 -| 名称 | ID | 备注 | -|------|----|----| -| Voyager 1 | -31 | 最远的人造物体 | -| Voyager 2 | -32 | 访问过天王星海王星 | -| New Horizons | -98 | 冥王星探测器 | -| Parker Solar Probe | -96 | 最接近太阳 | -| Juno | -61 | 木星探测器 | -| Cassini | -82 | 土星探测器(历史数据) | -| Perseverance | -168 | 火星车 | - -#### 行星 -| 名称 | ID | -|------|----| -| Sun | 10 | -| Mercury | 199 | -| Venus | 299 | -| Earth | 399 | -| Mars | 499 | -| Jupiter | 599 | -| Saturn | 699 | -| Uranus | 799 | -| Neptune | 899 | - -### 3. 3D 可视化功能 - -#### 基础功能 -- 太阳系 3D 场景渲染(日心坐标系) -- 行星纹理贴图(diffuse, normal, specular maps) -- 探测器 3D 模型加载(GLB 格式) -- 轨道线绘制(时间序列连线) -- 星空背景(Skybox) - -#### 交互功能(进阶) -- **OrbitControls**: 旋转、缩放、平移视角 -- **点击聚焦**: 点击探测器/行星,相机平滑飞向目标 -- **信息面板**: 显示选中物体的详细信息 - - 名称、距离太阳距离、速度 - - 最近的行星及距离 -- **时间选择器**: 用户选择起止时间查看历史位置 -- **动态缩放**: 解决尺度问题(远看时放大物体) - -### 4. 尺度处理策略 - -**问题**: 太阳系空间巨大,真实比例下行星会小到看不见 - -**解决方案**: -- 坐标系统使用真实 AU 单位(计算准确) -- 渲染时应用动态缩放: - - 远景:行星和探测器放大 1000-10000 倍 - - 近景:逐渐恢复真实比例 - - 探测器在远景时显示为发光图标,近景时显示 3D 模型 - -## 外部资源需求 - -### 3D 模型(需下载) -- **来源**: https://nasa3d.arc.nasa.gov/models -- **格式**: GLB/GLTF -- **存放位置**: `frontend/public/models/` -- **需要的模型**: - - Voyager 1 & 2 - - New Horizons - - Parker Solar Probe - - Juno - - Cassini - - Perseverance - -### 行星纹理(需下载) -- **来源**: https://www.solarsystemscope.com/textures/ -- **格式**: JPG/PNG (2K 或 4K) -- **存放位置**: `frontend/public/textures/` -- **每个行星需要**: - - `{planet}_diffuse.jpg` - 颜色贴图 - - `{planet}_normal.jpg` - 法线贴图(可选) - - `earth_specular.jpg` - 地球高光贴图(仅地球) - -## 项目结构 - -``` -cosmo/ -├── backend/ -│ ├── app/ -│ │ ├── __init__.py -│ │ ├── main.py # FastAPI 入口 -│ │ ├── config.py # 配置 -│ │ ├── models/ -│ │ │ ├── __init__.py -│ │ │ └── celestial.py # 数据模型 -│ │ ├── services/ -│ │ │ ├── __init__.py -│ │ │ ├── horizons.py # JPL Horizons 查询 -│ │ │ └── cache.py # 数据缓存 -│ │ └── api/ -│ │ ├── __init__.py -│ │ └── routes.py # API 路由 -│ ├── requirements.txt -│ └── .env.example -├── frontend/ -│ ├── src/ -│ │ ├── App.tsx -│ │ ├── main.tsx -│ │ ├── components/ -│ │ │ ├── Scene.tsx # 主场景 -│ │ │ ├── CelestialBody.tsx -│ │ │ ├── Probe.tsx -│ │ │ ├── OrbitLine.tsx -│ │ │ ├── InfoPanel.tsx -│ │ │ └── TimeSelector.tsx -│ │ ├── hooks/ -│ │ │ └── useSpaceData.ts -│ │ ├── types/ -│ │ │ └── index.ts -│ │ └── utils/ -│ │ └── api.ts -│ ├── public/ -│ │ ├── models/ # 探测器 3D 模型 -│ │ └── textures/ # 行星纹理 -│ ├── package.json -│ ├── tsconfig.json -│ ├── vite.config.ts -│ └── tailwind.config.js -├── PROJECT.md # 本文件 -├── IMPLEMENTATION_PLAN.md # 实施计划 -└── README.md -``` - -## API 设计 - -### 端点 - -#### `GET /api/celestial/positions` -获取指定时间的天体位置 - -**Query Parameters**: -- `start_time`: ISO 8601 格式(可选,默认为当前时间) -- `end_time`: ISO 8601 格式(可选) -- `step`: 时间步长,如 "1d"(可选,默认 "1d") - -**Response**: -```json -{ - "timestamp": "2025-11-26T00:00:00Z", - "bodies": [ - { - "id": "-31", - "name": "Voyager 1", - "type": "probe", - "positions": [ - { - "time": "2025-11-26T00:00:00Z", - "x": 160.5, - "y": 20.3, - "z": -15.2 - } - ] - } - ] -} -``` - -#### `GET /api/celestial/info/{body_id}` -获取天体详细信息 - -**Response**: -```json -{ - "id": "-31", - "name": "Voyager 1", - "type": "probe", - "description": "离地球最远的人造物体", - "launch_date": "1977-09-05", - "status": "active" -} -``` - -## 开发阶段 - -详见 `IMPLEMENTATION_PLAN.md` - -## 数据更新策略 - -- 深空探测器移动缓慢,数据每3天更新一次 -- 后端实现缓存机制,避免频繁请求 NASA API -- 缓存存储在内存中(简单实现)或 Redis(生产环境) diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index f860f9a..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,200 +0,0 @@ -# Cosmo - 深空探测器可视化系统 🚀 - -基于 NASA JPL Horizons 数据的深空探测器 3D 可视化系统。 - -## 快速开始 - -### 前置要求 - -- Python 3.11+ -- Node.js 20+ -- Yarn - -### 1. 启动后端 API - -```bash -cd backend - -# 创建虚拟环境并安装依赖 -python -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate -pip install -r requirements.txt - -# 启动服务器 -python -m app.main -``` - -后端将运行在 http://localhost:8000 - -- API 文档: http://localhost:8000/docs -- 健康检查: http://localhost:8000/health - -### 2. 启动前端应用 - -```bash -cd frontend - -# 安装依赖 -yarn install --ignore-engines - -# 启动开发服务器 -yarn dev -``` - -前端将运行在 http://localhost:5173 - -## 项目结构 - -``` -cosmo/ -├── backend/ # Python FastAPI 后端 -│ ├── app/ -│ │ ├── main.py # FastAPI 入口 -│ │ ├── api/ # API 路由 -│ │ ├── models/ # 数据模型 -│ │ └── services/ # 业务逻辑 -│ └── requirements.txt -├── frontend/ # React + Three.js 前端 -│ ├── src/ -│ │ ├── components/ # React 组件 -│ │ ├── hooks/ # 自定义 hooks -│ │ ├── types/ # TypeScript 类型 -│ │ └── utils/ # 工具函数 -│ └── package.json -├── PROJECT.md # 详细技术方案 -├── IMPLEMENTATION_PLAN.md # 实施计划 -└── README.md # 本文件 -``` - -## 功能特性 - -### 已实现 ✅ - -- **后端 API** - - 从 NASA JPL Horizons 获取实时天体数据 - - 支持时间范围查询 - - 数据缓存机制(每3天更新) - - RESTful API 设计 - -- **前端 3D 可视化** - - React + Three.js 3D 场景 - - 实时显示太阳系天体位置 - - 交互式相机控制(旋转、平移、缩放) - - 星空背景 - - 响应式设计 - -- **支持的天体** - - 探测器: Voyager 1 & 2, New Horizons, Parker Solar Probe, Juno, Cassini, Perseverance - - 行星: 太阳系八大行星 - -### 规划中 🚧 - -- 轨道线绘制 -- 时间选择器 -- 点击聚焦功能 -- 信息面板 -- 真实纹理贴图 -- 3D 探测器模型 -- 动态缩放优化 - -## 技术栈 - -### 后端 -- FastAPI - 现代 Python Web 框架 -- astroquery - NASA JPL Horizons 数据查询 -- astropy - 天文计算 -- Pydantic - 数据验证 - -### 前端 -- React 18 + TypeScript -- Vite - 快速构建工具 -- Three.js - 3D 渲染 -- @react-three/fiber - React Three.js 集成 -- @react-three/drei - Three.js 辅助工具 -- Tailwind CSS - 样式框架 -- Axios - HTTP 客户端 - -## API 端点 - -### 获取天体位置 -``` -GET /api/celestial/positions -``` - -查询参数: -- `start_time`: 起始时间 (ISO 8601) -- `end_time`: 结束时间 (ISO 8601) -- `step`: 时间步长 (如 "1d", "12h") - -### 获取天体信息 -``` -GET /api/celestial/info/{body_id} -``` - -### 列出所有天体 -``` -GET /api/celestial/list -``` - -## 使用说明 - -### 控制方式 - -- **左键拖动**: 旋转视角 -- **右键拖动**: 平移视角 -- **滚轮**: 缩放 - -### 坐标系统 - -使用日心坐标系(Heliocentric),以太阳为原点,单位为 AU (天文单位)。 - -## 外部资源需求 - -### 3D 模型(未来) -- 来源: https://nasa3d.arc.nasa.gov/models -- 格式: GLB/GLTF -- 存放: `frontend/public/models/` - -### 行星纹理(未来) -- 来源: https://www.solarsystemscope.com/textures/ -- 格式: JPG/PNG -- 存放: `frontend/public/textures/` - -## 开发进度 - -详见 [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) - -- ✅ Stage 1: 后端基础框架和数据获取 -- ✅ Stage 2: 前端基础框架和简单 3D 场景 -- ✅ Stage 3: 集成真实数据(部分完成) -- 🚧 Stage 4: 进阶交互和信息面板 -- 🚧 Stage 5: 视觉优化和模型加载 - -## 故障排除 - -### 后端无法启动 -- 确保 Python 3.11+ 已安装 -- 检查虚拟环境是否激活 -- 尝试升级 pip: `pip install --upgrade pip` - -### 前端依赖安装失败 -- 使用 `yarn install --ignore-engines` -- 确保 Node.js 版本 >= 20 - -### 数据加载缓慢 -- NASA JPL Horizons API 首次查询较慢(10-30秒) -- 后续请求会使用缓存,速度更快 - -## 许可证 - -MIT - -## 致谢 - -- NASA JPL Horizons System -- React Three Fiber 社区 -- Astroquery 项目 - ---- - -更多技术细节请查看 [PROJECT.md](./PROJECT.md) diff --git a/README.md b/README.md deleted file mode 100644 index 11d6e69..0000000 --- a/README.md +++ /dev/null @@ -1,371 +0,0 @@ -## 项目概述 -专注于**深空探测器**(如旅行者号、火星探测器等),那么整个系统的实现逻辑会变得更加清晰和纯粹。你不再需要处理近地轨道的 TLE 数据,而是完全进入了**天体力学**的领域。 - -实现这个系统的核心只有一条路:**NASA JPL Horizons 系统**。 - -这是全人类最权威的太阳系天体位置数据库。以下是针对深空探测器系统的具体实现方案: - -### 一、 核心数据源:NASA JPL Horizons - -对于深空探测器,你不能用 GPS 坐标,甚至不能单纯用经纬度。你需要的是在**太阳系中的三维坐标**。 - - * **数据提供方:** NASA 喷气推进实验室 (JPL)。 - * **覆盖范围:** 所有的行星、卫星、以及几乎所有人类发射的深空探测器(Voyager, Juno, New Horizons 等)。 - * **唯一标识 (ID):** 每个探测器都有一个唯一的 ID。 - * 旅行者 1 号 (Voyager 1): `-31` - * 旅行者 2 号 (Voyager 2): `-32` - * 新视野号 (New Horizons): `-98` - * 帕克太阳探测器 (Parker Solar Probe): `-96` - * *注:人造探测器的 ID 通常是负数。* - ------ - -### 二、 获取数据的方式 (推荐技术方案) - -为了获取这些数据,你不需要去解析复杂的文本文件,最简单、最现代的方式是使用 **Python** 的 **`astroquery`** 库。它是一个专门用来查询天文数据库的工具,内置了对 JPL Horizons 的支持。 - -#### 1\. 安装工具 - -```bash -pip install astroquery -``` - -#### 2\. 代码实现逻辑 - -你需要向系统询问:“在**这个时间**,相对于**太阳**,**旅行者1号**在哪里?” - -以下是一个完整的 Python 脚本示例,它会获取旅行者 1 号和地球的坐标,以便你计算它们之间的距离或画图: - -```python -from astroquery.jplhorizons import Horizons -from astropy.time import Time - -# 1. 设定查询参数 -# id: 目标天体 ID (Voyager 1 = -31) -# location: 坐标原点 (@sun 表示以太阳为中心,@0 表示以太阳系质心为中心) -# epochs: 时间点 (当前时间) -obj = Horizons(id='-31', location='@sun', epochs=Time.now().jd) - -# 2. 获取向量数据 (Vectors) -# 这一步会向 NASA 服务器发送请求 -vectors = obj.vectors() - -# 3. 提取坐标 (x, y, z) -# 默认单位是 AU (天文单位,1 AU ≈ 1.5亿公里) -x = vectors['x'][0] -y = vectors['y'][0] -z = vectors['z'][0] - -print(f"旅行者1号 (Voyager 1) 相对于太阳的坐标 (AU):") -print(f"X: {x}\nY: {y}\nZ: {z}") - -# --- 同时获取地球的位置,用于画出相对位置 --- -earth = Horizons(id='399', location='@sun', epochs=Time.now().jd).vectors() -print(f"\n地球 (Earth) 坐标 (AU):") -print(f"X: {earth['x'][0]}, Y: {earth['y'][0]}, Z: {earth['z'][0]}") -``` - ------ - -### 三、 关键技术点解析 - -在开发这个系统时,有三个关键概念你必须处理好,才能正确显示“探测器在比着重的位置”以及“旁边的星球”。 - -#### 1\. 坐标系的选择:日心坐标 (Heliocentric) - - * **近地卫星**用的是“地心坐标”(以地球为原点)。 - * **深空探测器**必须用**日心坐标**(以太阳为原点)。 - * 在查询数据时,务必指定 `location='@sun'`。这样返回的 `(0,0,0)` 就是太阳,所有行星和探测器都围绕它分布。 - -#### 2\. 单位的量级:天文单位 (AU) - - * 深空的空间太大了。如果你用“米”或“公里”做单位,数字会大到让 JavaScript 崩溃或精度丢失。 - * **解决方案:** 使用 **AU (Astronomical Unit)**。 - * 地球到太阳的距离 ≈ 1.0 AU。 - * 旅行者 1 号目前距离太阳 ≈ 160+ AU。 - * 使用 AU 作为你 3D 场景的基础单位,显示时再换算成公里给用户看。 - -#### 3\. 如何确定“旁边的星球” - -因为所有坐标都是统一在“日心坐标系”下的,判断“旁边”非常简单:计算欧几里得距离。 -$$Distance = \sqrt{(x_1-x_2)^2 + (y_1-y_2)^2 + (z_1-z_2)^2}$$ - - * 比如,你要显示“朱诺号 (Juno)”旁边的星球。 - * 你获取 Juno 的坐标 $(x_j, y_j, z_j)$。 - * 你获取木星 (Jupiter) 的坐标 $(x_p, y_p, z_p)$。 - * 一算距离,你会发现它们非常近,而它离地球非常远。 - -### 四、 总结系统架构建议 - -如果你想做一个网页端展示系统: - -1. **后端 (Python API):** - - * 使用 `astroquery`。 - * 建立一个 ID 列表(包含八大行星 + 知名探测器)。 - * 每隔一段时间(比如每天或用户请求时)去 NASA JPL 拉取一次最新的坐标数据(因为深空探测器飞得很慢,不需要每秒更新)。 - * 将这些 `(x, y, z)` 坐标打包成 JSON 发给前端。 - -2. **前端 (Visualization):** - - * 建立一个 3D 场景,原点 `(0,0,0)` 放一个发光的球(太阳)。 - * 根据后端返回的 AU 坐标放置行星和探测器。 - * **关键功能:** 添加“轨道线”。为了让用户看懂探测器的轨迹,你不仅要获取“当前”位置,最好获取“过去一年”到“未来一年”的一系列点,连成线,这样用户就能直观地看到它是怎么飞掠木星然后飞向深空的。 - -这是一个非常棒的进阶问题!要在一个网页上“形象且准确”地展示深空探测器和行星,你不仅要解决**数据**问题,还要解决\*\*3D 图形学中的尺度(Scale)\*\*问题。因为宇宙太空中,“大”和“远”的跨度大到人类难以直观理解。 - -以下是实现这一目标的详细方案,包括数据细节、3D 模型资源和可视化技巧: - -### 一、 数据篇:关键探测器 ID 与 轨道线绘制 - -首先,你需要向 JPL Horizons 系统请求正确的目标 ID,并获取一段**时间序列**的数据来画出轨道线。 - -#### 1\. 常用深空探测器 ID 列表 (JPL Horizons) - -这些是人类历史上最重要的深空探测器,建议收入你的系统: - -| 探测器名称 | 英文名 | ID (JPL) | 备注 | -| :--- | :--- | :--- | :--- | -| **旅行者 1 号** | Voyager 1 | `-31` | 离地球最远的人造物体,已进入星际空间 | -| **旅行者 2 号** | Voyager 2 | `-32` | 唯一造访过天王星和海王星的探测器 | -| **新视野号** | New Horizons | `-98` | 飞掠冥王星,正处于柯伊伯带 | -| **帕克太阳探测器** | Parker Solar Probe | `-96` | 正在“触摸”太阳,速度最快 | -| **朱诺号** | Juno | `-61` | 正在木星轨道运行 | -| **卡西尼号** | Cassini | `-82` | 土星探测器(已撞击销毁,需查询历史时间) | -| **毅力号** | Perseverance | `-168` | 火星车(位置与火星几乎重叠,但在前往火星途中可查) | - -#### 2\. 如何绘制“轨道线” - -只显示一个点是不够的,你需要画出它“从哪里来,到哪里去”。 - - * **后端逻辑:** 当你查询 API 时,不要只查询 `Time.now()`。 - * **查询策略:** 查询一个时间段。例如,查询从 `2020-01-01` 到 `2025-01-01`,步长为 `1天`。 - * **数据结构:** 你会得到一个包含 1800 个 $(x, y, z)$ 坐标的数组。 - * **前端绘制:** 将这些点连接成一条平滑的线(在 Three.js 中使用 `LineLoop` 或 `CatmullRomCurve3`),用户就能看到探测器优美的弧形轨道。 - ------ - -### 二、 视觉篇:如何显示外形 (3D 模型与纹理) - -要在网页上显示逼真的外形,你需要使用 **WebGL** 技术。目前业界标准是 **Three.js**。 - -#### 1\. 获取高精度的探测器模型 (3D Models) - -你不需要自己建模!NASA 官方免费提供了极高质量的 3D 模型,格式通常是 `.glb` 或 `.gltf`(这是 3D 网页开发的 JPG,体积小、加载快)。 - - * **NASA 3D Resources:** 这是你的宝库。 - * *网址:* `https://nasa3d.arc.nasa.gov/models` - * 你可以下载到 Voyager, Cassini, Hubble 等所有知名探测器的官方模型。 - * **加载方法:** 使用 Three.js 的 `GLTFLoader`。 - ```javascript - import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; - - const loader = new GLTFLoader(); - loader.load( 'path/to/voyager.glb', function ( gltf ) { - const voyagerModel = gltf.scene; - scene.add( voyagerModel ); - }); - ``` - -#### 2\. 获取行星的逼真纹理 (Textures) - -行星是一个球体(SphereGeometry),你需要给它贴上高清的“皮肤”。 - - * **资源来源:** **Solar System Scope** 或 **NASA Scientific Visualization Studio**。 - * **你需要三种贴图来达到“准确且形象”:** - 1. **Diffuse Map (漫反射贴图):** 行星原本的颜色(如地球的蓝白、火星的红色)。 - 2. **Normal Map / Bump Map (法线/凹凸贴图):** 让山脉和陨石坑看起来有立体感,而不是光滑的皮球。 - 3. **Specular Map (高光贴图):** 只有海洋反光,陆地不反光(这对地球特别重要)。 - ------ - -### 三、 核心难点:大小与距离的冲突 (The Scale Problem) - -这是你在这个项目中最需要处理的**交互设计难点**。 - -**现实情况是:** 太阳系极其空旷。如果你按真实比例(1:1)显示: - - * 如果屏幕上可以看到地球和火星的距离,那么地球本身小到连一个像素都不到(看不见)。 - * 如果你把地球放大到能看见,那么火星在几公里以外的屏幕外。 - -**解决方案:动态尺度缩放 (Dynamic Scaling / Billboard Mode)** - -你不能始终使用真实大小,你需要欺骗眼睛: - -1. **真实模式 (Real Scale):** 用于计算物理位置和轨道。这是后台运行的数学逻辑。 -2. **展示模式 (Iconic Scale):** 用于渲染。 - * **远景视角时:** 将所有行星和探测器放大 **1000倍 到 10000倍**。这样用户在看整个太阳系时,能看到一个个清晰的小球或图标。 - * **近景视角时(当摄像机靠近物体):** 逐渐将放大倍数缩小回 **1倍**。 - * **具体实现:** 在每一帧渲染循环(Render Loop)中,根据摄像机到物体的距离 $D$,动态计算物体的缩放系数 $S$。 - $$S = \max(1, \frac{D}{k})$$ - *(其中 $k$ 是一个常数因子)* - -**关于探测器的特殊处理:** -探测器比行星更小(几米 vs 几千公里)。在宏观视角下,绝对不能按比例渲染探测器模型,否则永远看不见。 - - * **策略:** 在远景时,不要渲染 3D 模型,而是渲染一个**发光的图标(Sprite)或者文字标签**。 - * **交互:** 只有当用户点击“旅行者1号”标签,摄像机自动飞过去并拉近距离后,才淡出图标,加载并显示精细的 3D 模型。 - -### 四、 总结:推荐的开发路线 - -如果你现在开始动手,我建议按照这个层级构建: - -1. **Level 1 (原型):** - - * 使用 **Three.js**。 - * 中间放一个红球(太阳),周围放一个蓝球(地球)。 - * 使用静态数据(手动写死坐标)确位置。 - -2. **Level 2 (接入数据):** - - * 后端写好 Python 脚本,拉取 JPL 数据。 - * 前端根据数据更新球体的位置。 - -3. **Level 3 (视觉升级):** - - * 给球体贴上 NASA 的纹理。 - * 去 NASA 3D 网站下载 Voyager 的 `.glb` 模型,替换掉代表探测器的小方块。 - * 加上“星空背景盒子 (Skybox)”,让背景是真实的银河系星图,而不是全黑。 - -4. **Level 4 (交互完善):** - - * 实现**轨道控制器 (OrbitControls)**,允许用户旋转、缩放视角。 - * 实现**点击聚焦**:点击列表里的“火星”,视角平滑飞向火星。 - -太棒了!这两个功能是让你的太阳系可视化项目从“能用”走向“惊艳”的关键一步。 - -下面我将分别提供这两个核心功能的 Three.js 代码片段。你可以把它们集成到你的 Three.js 初始化和渲染循环中。 - ------ - -### 一、 Three.js 加载行星纹理 (让星球看起来真实) - -这段代码展示了如何创建一个带有漫反射贴图(颜色)、高光贴图(反光)和法线贴图(凹凸感)的逼真地球。 - -**前置要求:** 你需要准备好 `earth_diffuse.jpg`, `earth_specular.jpg`, `earth_normal.jpg` 这三张图片放在你的项目文件夹中。 - -```javascript -import * as THREE from 'three'; - -// 1. 初始化纹理加载器 -const textureLoader = new THREE.TextureLoader(); - -// 2. 定义创建行星的函数 -function createRealisticPlanet() { - // --- 几何体 (Geometry) --- - // 创建一个球体。参数:半径, 水平分段数, 垂直分段数 - // 分段数越高,球体越圆滑,但性能开销越大。64是比较好的平衡点。 - const geometry = new THREE.SphereGeometry(1, 64, 64); - - // --- 材质 (Material) --- - // 使用 MeshPhongMaterial,这是一种支持高光反射的材质,适合表现行星表面。 - const material = new THREE.MeshPhongMaterial({ - // a. 漫反射贴图 (Diffuse Map) - 决定星球表面的基本颜色和图案 - map: textureLoader.load('textures/earth_diffuse.jpg'), - - // b. 高光贴图 (Specular Map) - 决定哪些区域反光(海洋),哪些不反光(陆地) - // 通常是黑白图片,白色反光强,黑色不反光。 - specularMap: textureLoader.load('textures/earth_specular.jpg'), - specular: new THREE.Color('grey'), // 高光的颜色 - shininess: 10, // 高光的亮度指数 - - // c. 法线贴图 (Normal Map) - 模拟表面的凹凸细节(山脉、海沟),不改变实际几何体 - normalMap: textureLoader.load('textures/earth_normal.jpg'), - normalScale: new THREE.Vector2(1, 1) // 凹凸感的强度 - }); - - // --- 网格 (Mesh) --- - // 将几何体和材质组合成一个可渲染的对象 - const earthMesh = new THREE.Mesh(geometry, material); - - // 稍微倾斜一点,模拟地轴倾角 - earthMesh.rotation.z = THREE.MathUtils.degToRad(23.5); - - return earthMesh; -} - -// 3. 将地球加入场景 -const scene = new THREE.Scene(); -// ... 添加灯光 (必须有光才能看到 Phong 材质的效果) ... -const sunLight = new THREE.PointLight(0xffffff, 1.5); -scene.add(sunLight); - -const earth = createRealisticPlanet(); -scene.add(earth); - -// 在你的动画循环中让它自转 -function animate() { - requestAnimationFrame(animate); - earth.rotation.y += 0.001; // 每一帧旋转一点点 - // renderer.render(...) -} -animate(); -``` - ------ - -### 二、 处理动态缩放 (The Scale Problem) - -这段代码解决的是“距离太远看不见”的问题。它的核心思想是:**在每一帧渲染前,检查摄像机离物体有多远,然后调整物体的大小,确保它在屏幕上至少占据一定的大小。** - -你需要把这段逻辑放在你的 `animate()` 或 `render()` 循环中。 - -```javascript -import * as THREE from 'three'; - -// 假设你已经有了场景、摄像机和一些物体 -// scene, camera, renderer 已初始化 -// 假设你有一个数组存放所有的探测器对象 (Mesh 或 Sprite) -const probes = [voyager1Mesh, parkerSolarProbeSprite, ...]; - -// 定义一个基础缩放因子,决定物体在远看时保持多大 -// 这个值需要根据你的实际场景单位进行调整测试 -const MIN_VISIBLE_SCALE = 0.05; - -// --- 这个函数放在你的 animate() 循环中 --- -function updateObjectScales() { - probes.forEach(probe => { - // 1. 计算物体到摄像机的距离 - const distance = camera.position.distanceTo(probe.position); - - // 2. 计算目标缩放比例 - // 逻辑:距离越远,需要的缩放比例就越大。 - // 我们设置一个下限为 1 (保持原始大小),上限根据距离动态增加。 - // distance * MIN_VISIBLE_SCALE 是一个经验公式,你可以根据需要修改。 - let targetScale = Math.max(1, distance * MIN_VISIBLE_SCALE); - - // 【可选优化】:如果物体是 Sprite(图标),我们通常希望它大小固定,不随距离变化 - // 如果是 3D 模型,我们希望它远看大,近看恢复真实大小。 - if (probe.isSprite) { - // 对于图标,我们可以让它始终保持相对于屏幕的固定大小 - // 这种计算稍微复杂一点,需要考虑相机的视场角 (FOV) - const scaleFactor = distance / camera.fov; // 简化版计算 - probe.scale.set(scaleFactor, scaleFactor, scaleFactor); - } else { - // 对于 3D 模型,应用动态缩放 - probe.scale.set(targetScale, targetScale, targetScale); - } - }); -} - -// --- 你的主循环 --- -function animate() { - requestAnimationFrame(animate); - - // 1. 更新控制器 (如果用了 OrbitControls) - // controls.update(); - - // 2. 【核心】更新物体的动态缩放 - updateObjectScales(); - - // 3. 渲染场景 - renderer.render(scene, camera); -} -animate(); -``` - -### 建议 - -对于初学者,我强烈建议先从**纹理贴图**开始。把一个灰色的球体变成一个逼真的地球,会给你带来巨大的成就感。 - -动态缩放稍微复杂一些,涉及到对 3D 空间距离感的调试。你可以先把所有的物体都按 1000 倍的固定比例放大,等整个流程跑通了,再加入动态缩放的逻辑来提升体验。 \ No newline at end of file diff --git a/backend/scripts/add_rocket_simulator.sql b/backend/scripts/add_rocket_simulator.sql deleted file mode 100644 index ee570b4..0000000 --- a/backend/scripts/add_rocket_simulator.sql +++ /dev/null @@ -1,94 +0,0 @@ -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 - ); diff --git a/backend/scripts/add_user_avatar_column.sql b/backend/scripts/add_user_avatar_column.sql deleted file mode 100644 index b54a6ed..0000000 --- a/backend/scripts/add_user_avatar_column.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url varchar(255) DEFAULT NULL; -COMMENT ON COLUMN users.avatar_url IS 'User avatar file path'; \ No newline at end of file diff --git a/backend/scripts/fetch_and_cache.py b/backend/scripts/fetch_and_cache.py deleted file mode 100755 index 3bf6b13..0000000 --- a/backend/scripts/fetch_and_cache.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -""" -Fetch celestial body positions from NASA Horizons API and cache them - -This script: -1. Fetches position data for all celestial bodies -2. Caches data in Redis (L2 cache) -3. Saves data to PostgreSQL (L3 cache/persistent storage) - -Usage: - python scripts/fetch_and_cache.py [--days DAYS] - -Options: - --days DAYS Number of days to fetch (default: 7) -""" -import asyncio -import sys -from pathlib import Path -from datetime import datetime, timedelta -import argparse -import logging - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from app.services.horizons import horizons_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 - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - - -async def fetch_and_cache_body(body_id: str, body_name: str, days: int = 7): - """Fetch and cache position data for a single celestial body""" - logger.info(f"Fetching data for {body_name} ({body_id})...") - - try: - # Calculate time range - now = datetime.utcnow() - start_time = now - end_time = now + timedelta(days=days) - step = "1d" - - # Fetch positions from NASA API (synchronous call in async context) - loop = asyncio.get_event_loop() - positions = await loop.run_in_executor( - None, - horizons_service.get_body_positions, - body_id, - start_time, - end_time, - step - ) - - if not positions: - logger.warning(f"No positions returned for {body_name}") - return False - - logger.info(f"Fetched {len(positions)} positions for {body_name}") - - # Prepare data for caching - position_data = [ - { - "time": pos.time, - "x": pos.x, - "y": pos.y, - "z": pos.z, - } - for pos in positions - ] - - # Cache in Redis (L2) - redis_cached = await cache_nasa_response( - body_id=body_id, - start_time=start_time, - end_time=end_time, - step=step, - data=position_data - ) - - if redis_cached: - logger.info(f"✓ Cached {body_name} data in Redis") - else: - logger.warning(f"⚠ Failed to cache {body_name} data in Redis") - - # Save to PostgreSQL (L3 - persistent storage) - # Save raw NASA response for future cache hits - await nasa_cache_service.save_response( - body_id=body_id, - start_time=start_time, - end_time=end_time, - step=step, - response_data={"positions": position_data}, - ttl_days=settings.cache_ttl_days - ) - logger.info(f"✓ Cached {body_name} data in PostgreSQL (nasa_cache)") - - # Save positions to positions table for querying - saved_count = await position_service.save_positions( - body_id=body_id, - positions=position_data, - source="nasa_horizons" - ) - logger.info(f"✓ Saved {saved_count} positions for {body_name} in PostgreSQL") - - return True - - except Exception as e: - logger.error(f"✗ Failed to fetch/cache {body_name}: {e}") - import traceback - traceback.print_exc() - return False - - -async def main(): - """Fetch and cache data for all celestial bodies""" - parser = argparse.ArgumentParser(description='Fetch and cache celestial body positions') - parser.add_argument('--days', type=int, default=7, help='Number of days to fetch (default: 7)') - args = parser.parse_args() - - logger.info("=" * 60) - logger.info("Fetch and Cache NASA Horizons Data") - logger.info("=" * 60) - logger.info(f"Time range: {args.days} days from now") - logger.info("=" * 60) - - # Connect to Redis - await redis_cache.connect() - - try: - # Get all celestial bodies from database - bodies = await celestial_body_service.get_all_bodies() - logger.info(f"\nFound {len(bodies)} celestial bodies in database") - - # Filter for probes and planets (skip stars) - bodies_to_fetch = [ - body for body in bodies - if body.type in ['probe', 'planet'] - ] - logger.info(f"Will fetch data for {len(bodies_to_fetch)} bodies (probes + planets)") - - # Fetch and cache data for each body - success_count = 0 - fail_count = 0 - - for i, body in enumerate(bodies_to_fetch, 1): - logger.info(f"\n[{i}/{len(bodies_to_fetch)}] Processing {body.name}...") - success = await fetch_and_cache_body( - body_id=body.id, - body_name=body.name, - days=args.days - ) - - if success: - success_count += 1 - else: - fail_count += 1 - - # Small delay to avoid overwhelming NASA API - if i < len(bodies_to_fetch): - await asyncio.sleep(0.5) - - # Summary - logger.info("\n" + "=" * 60) - logger.info("Summary") - logger.info("=" * 60) - logger.info(f"✓ Successfully cached: {success_count} bodies") - if fail_count > 0: - logger.warning(f"✗ Failed: {fail_count} bodies") - logger.info("=" * 60) - - # Check cache status - redis_stats = await redis_cache.get_stats() - if redis_stats.get("connected"): - logger.info("\nRedis Cache Status:") - logger.info(f" Memory: {redis_stats.get('used_memory_human')}") - logger.info(f" Clients: {redis_stats.get('connected_clients')}") - logger.info(f" Hits: {redis_stats.get('keyspace_hits')}") - logger.info(f" Misses: {redis_stats.get('keyspace_misses')}") - - except Exception as e: - logger.error(f"\n✗ Failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - finally: - # Disconnect from Redis - await redis_cache.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/scripts/insert_sirius_system.sql b/backend/scripts/insert_sirius_system.sql deleted file mode 100644 index c3ac1ff..0000000 --- a/backend/scripts/insert_sirius_system.sql +++ /dev/null @@ -1,93 +0,0 @@ -BEGIN; - --- 1. Insert or Update Sirius System -WITH new_system AS ( - INSERT INTO "public"."star_systems" ( - "name", - "name_zh", - "host_star_name", - "distance_pc", - "distance_ly", - "ra", - "dec", - "position_x", - "position_y", - "position_z", - "spectral_type", - "magnitude", - "color", - "description", - "planet_count" - ) VALUES ( - 'Sirius', - '天狼星', - 'Sirius A', - 2.64, - 8.6, - 101.287, - -16.716, - -0.495, - 2.479, - -0.759, - 'A1V', - -1.46, - '#FFFFFF', - '天狼星(Sirius,α CMa)是夜空中最亮的恒星,距离太阳系约 8.6 光年。它是一个联星系统,包含一颗蓝矮星(天狼星 A)和一颗白矮星(天狼星 B)。', - 0 - ) - ON CONFLICT (name) DO UPDATE SET - distance_pc = EXCLUDED.distance_pc, - distance_ly = EXCLUDED.distance_ly, - ra = EXCLUDED.ra, - dec = EXCLUDED.dec, - position_x = EXCLUDED.position_x, - position_y = EXCLUDED.position_y, - position_z = EXCLUDED.position_z, - spectral_type = EXCLUDED.spectral_type, - magnitude = EXCLUDED.magnitude, - color = EXCLUDED.color, - description = EXCLUDED.description - RETURNING id -) --- 2. Insert Celestial Bodies (Sirius A and Sirius B) linked to the system -INSERT INTO "public"."celestial_bodies" ( - "id", - "name", - "name_zh", - "type", - "system_id", - "description", - "extra_data", - "is_active" -) -SELECT - 'sirius_a', - 'Sirius A', - '天狼星 A', - 'star', - id, - '天狼星 A 是天狼星系统的主星,是一颗光谱型 A1V 的蓝矮星,其质量约为太阳的 2 倍,光度约为太阳的 25 倍。', - '{"spectral_type": "A1V", "radius_solar": 1.71, "mass_solar": 2.06, "temperature_k": 9940}'::jsonb, - true -FROM new_system -UNION ALL -SELECT - 'sirius_b', - 'Sirius B', - '天狼星 B', - 'star', - id, - '天狼星 B 是天狼星 A 的伴星,是一颗微弱的白矮星。它是人类发现的第一颗白矮星,质量与太阳相当,但体积仅与地球相当。', - '{"spectral_type": "DA2", "radius_solar": 0.0084, "mass_solar": 1.02, "temperature_k": 25200}'::jsonb, - true -FROM new_system -ON CONFLICT (id) DO UPDATE SET - system_id = EXCLUDED.system_id, - name = EXCLUDED.name, - name_zh = EXCLUDED.name_zh, - type = EXCLUDED.type, - description = EXCLUDED.description, - extra_data = EXCLUDED.extra_data, - is_active = EXCLUDED.is_active; - -COMMIT; diff --git a/backend/scripts/list_celestial_bodies.py b/backend/scripts/list_celestial_bodies.py deleted file mode 100644 index 735ccc8..0000000 --- a/backend/scripts/list_celestial_bodies.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -List celestial bodies from database -""" -import asyncio -from app.database import get_db -from app.models.db.celestial_body import CelestialBody - - -async def list_celestial_bodies(): - """List all celestial bodies""" - async for session in get_db(): - try: - from sqlalchemy import select - - stmt = select(CelestialBody).order_by(CelestialBody.type, CelestialBody.id) - result = await session.execute(stmt) - bodies = result.scalars().all() - - print(f"\n📊 Found {len(bodies)} celestial bodies:\n") - print(f"{'ID':<20} {'Name':<25} {'Type':<10}") - print("=" * 60) - - for body in bodies: - print(f"{body.id:<20} {body.name:<25} {body.type:<10}") - - finally: - break - - -if __name__ == "__main__": - asyncio.run(list_celestial_bodies()) diff --git a/backend/scripts/populate_resources.py b/backend/scripts/populate_resources.py deleted file mode 100644 index f180f9e..0000000 --- a/backend/scripts/populate_resources.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Populate resources table with texture and model files -""" -import asyncio -import os -from pathlib import Path -from sqlalchemy.dialects.postgresql import insert as pg_insert -from app.database import get_db -from app.models.db.resource import Resource - - -# Mapping of texture files to celestial body IDs (use numeric Horizons IDs) -TEXTURE_MAPPING = { - "2k_sun.jpg": {"body_id": "10", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_mercury.jpg": {"body_id": "199", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_venus_surface.jpg": {"body_id": "299", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_venus_atmosphere.jpg": {"body_id": "299", "resource_type": "texture", "mime_type": "image/jpeg", "extra_data": {"layer": "atmosphere"}}, - "2k_earth_daymap.jpg": {"body_id": "399", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_earth_nightmap.jpg": {"body_id": "399", "resource_type": "texture", "mime_type": "image/jpeg", "extra_data": {"layer": "night"}}, - "2k_moon.jpg": {"body_id": "301", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_mars.jpg": {"body_id": "499", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_jupiter.jpg": {"body_id": "599", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_saturn.jpg": {"body_id": "699", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_saturn_ring_alpha.png": {"body_id": "699", "resource_type": "texture", "mime_type": "image/png", "extra_data": {"layer": "ring"}}, - "2k_uranus.jpg": {"body_id": "799", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_neptune.jpg": {"body_id": "899", "resource_type": "texture", "mime_type": "image/jpeg"}, - "2k_stars_milky_way.jpg": {"body_id": None, "resource_type": "texture", "mime_type": "image/jpeg", "extra_data": {"usage": "skybox"}}, -} - -# Mapping of model files to celestial body IDs (use numeric probe IDs) -MODEL_MAPPING = { - "voyager_1.glb": {"body_id": "-31", "resource_type": "model", "mime_type": "model/gltf-binary"}, - "voyager_2.glb": {"body_id": "-32", "resource_type": "model", "mime_type": "model/gltf-binary"}, - "juno.glb": {"body_id": "-61", "resource_type": "model", "mime_type": "model/gltf-binary"}, - "parker_solar_probe.glb": {"body_id": "-96", "resource_type": "model", "mime_type": "model/gltf-binary"}, - "cassini.glb": {"body_id": "-82", "resource_type": "model", "mime_type": "model/gltf-binary"}, -} - - -async def populate_resources(): - """Populate resources table with texture and model files""" - - # Get upload directory path - upload_dir = Path(__file__).parent.parent / "upload" - texture_dir = upload_dir / "texture" - model_dir = upload_dir / "model" - - print(f"📂 Scanning upload directory: {upload_dir}") - print(f"📂 Texture directory: {texture_dir}") - print(f"📂 Model directory: {model_dir}") - - async for session in get_db(): - try: - # Process textures - print("\n🖼️ Processing textures...") - texture_count = 0 - for filename, mapping in TEXTURE_MAPPING.items(): - file_path = texture_dir / filename - if not file_path.exists(): - print(f"⚠️ Warning: Texture file not found: {filename}") - continue - - file_size = file_path.stat().st_size - - # Prepare resource data - resource_data = { - "body_id": mapping["body_id"], - "resource_type": mapping["resource_type"], - "file_path": f"texture/{filename}", - "file_size": file_size, - "mime_type": mapping["mime_type"], - "extra_data": mapping.get("extra_data"), - } - - # Use upsert to avoid duplicates - stmt = pg_insert(Resource).values(**resource_data) - stmt = stmt.on_conflict_do_update( - index_elements=['body_id', 'resource_type', 'file_path'], - set_={ - 'file_size': file_size, - 'mime_type': mapping["mime_type"], - 'extra_data': mapping.get("extra_data"), - } - ) - - await session.execute(stmt) - texture_count += 1 - print(f" ✅ {filename} -> {mapping['body_id'] or 'global'} ({file_size} bytes)") - - # Process models - print("\n🚀 Processing models...") - model_count = 0 - for filename, mapping in MODEL_MAPPING.items(): - file_path = model_dir / filename - if not file_path.exists(): - print(f"⚠️ Warning: Model file not found: {filename}") - continue - - file_size = file_path.stat().st_size - - # Prepare resource data - resource_data = { - "body_id": mapping["body_id"], - "resource_type": mapping["resource_type"], - "file_path": f"model/{filename}", - "file_size": file_size, - "mime_type": mapping["mime_type"], - "extra_data": mapping.get("extra_data"), - } - - # Use upsert to avoid duplicates - stmt = pg_insert(Resource).values(**resource_data) - stmt = stmt.on_conflict_do_update( - index_elements=['body_id', 'resource_type', 'file_path'], - set_={ - 'file_size': file_size, - 'mime_type': mapping["mime_type"], - 'extra_data': mapping.get("extra_data"), - } - ) - - await session.execute(stmt) - model_count += 1 - print(f" ✅ {filename} -> {mapping['body_id']} ({file_size} bytes)") - - # Commit all changes - await session.commit() - - print(f"\n✨ Successfully populated resources table:") - print(f" 📊 Textures: {texture_count}") - print(f" 📊 Models: {model_count}") - print(f" 📊 Total: {texture_count + model_count}") - - except Exception as e: - print(f"❌ Error populating resources: {e}") - await session.rollback() - raise - finally: - break - - -if __name__ == "__main__": - asyncio.run(populate_resources()) diff --git a/backend/scripts/prefetch_historical_data.py b/backend/scripts/prefetch_historical_data.py deleted file mode 100644 index bffe15c..0000000 --- a/backend/scripts/prefetch_historical_data.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -""" -Historical Data Prefetch Script - -This script prefetches historical position data for all celestial bodies -and stores them in the database for fast retrieval. - -Usage: - # Prefetch last 12 months - python scripts/prefetch_historical_data.py --months 12 - - # Prefetch specific year-month - python scripts/prefetch_historical_data.py --year 2024 --month 1 - - # Prefetch a range - python scripts/prefetch_historical_data.py --start-year 2023 --start-month 1 --end-year 2023 --end-month 12 -""" - -import sys -import os -import asyncio -import argparse -from datetime import datetime, timedelta -from dateutil.relativedelta import relativedelta - -# Add backend to path -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.celestial_body_service import celestial_body_service -from app.services.position_service import position_service - - -async def prefetch_month(year: int, month: int, session): - """ - Prefetch data for a specific month - - Args: - year: Year (e.g., 2023) - month: Month (1-12) - session: Database session - """ - # Calculate start and end of month - start_date = datetime(year, month, 1, 0, 0, 0) - if month == 12: - end_date = datetime(year + 1, 1, 1, 0, 0, 0) - else: - end_date = datetime(year, month + 1, 1, 0, 0, 0) - - print(f"\n{'='*60}") - print(f"📅 Prefetching data for {year}-{month:02d}") - print(f" Period: {start_date.date()} to {end_date.date()}") - print(f"{'='*60}") - - # Get all celestial bodies from database - all_bodies = await celestial_body_service.get_all_bodies(session) - total_bodies = len(all_bodies) - success_count = 0 - skip_count = 0 - error_count = 0 - - for idx, body in enumerate(all_bodies, 1): - body_id = body.id - body_name = body.name - - try: - # Check if we already have data for this month - existing_positions = await position_service.get_positions_in_range( - body_id, start_date, end_date, session - ) - - if existing_positions and len(existing_positions) > 0: - print(f" [{idx}/{total_bodies}] ⏭️ {body_name:20s} - Already exists ({len(existing_positions)} positions)") - skip_count += 1 - continue - - print(f" [{idx}/{total_bodies}] 🔄 {body_name:20s} - Fetching...", end='', flush=True) - - # Query NASA Horizons API for this month - # Sample every 7 days to reduce data volume - step = "7d" - - if body_id == "10": - # Sun is always at origin - positions = [ - {"time": start_date, "x": 0.0, "y": 0.0, "z": 0.0}, - {"time": end_date, "x": 0.0, "y": 0.0, "z": 0.0}, - ] - elif body_id == "-82": - # Cassini mission ended 2017-09-15 - if year < 2017 or (year == 2017 and month <= 9): - cassini_date = datetime(2017, 9, 15, 11, 58, 0) - positions_data = horizons_service.get_body_positions( - body_id, cassini_date, cassini_date, step - ) - positions = [ - {"time": p.time, "x": p.x, "y": p.y, "z": p.z} - for p in positions_data - ] - else: - print(f" ⏭️ Mission ended", flush=True) - skip_count += 1 - continue - else: - # Query other bodies - positions_data = horizons_service.get_body_positions( - body_id, start_date, end_date, step - ) - positions = [ - {"time": p.time, "x": p.x, "y": p.y, "z": p.z} - for p in positions_data - ] - - # Store in database - for pos_data in positions: - await position_service.save_position( - body_id=body_id, - time=pos_data["time"], - x=pos_data["x"], - y=pos_data["y"], - z=pos_data["z"], - source="nasa_horizons", - session=session, - ) - - print(f" ✅ Saved {len(positions)} positions", flush=True) - success_count += 1 - - # Small delay to avoid overwhelming NASA API - await asyncio.sleep(0.5) - - except Exception as e: - print(f" ❌ Error: {str(e)}", flush=True) - error_count += 1 - continue - - print(f"\n{'='*60}") - print(f"📊 Summary for {year}-{month:02d}:") - print(f" ✅ Success: {success_count}") - print(f" ⏭️ Skipped: {skip_count}") - print(f" ❌ Errors: {error_count}") - print(f"{'='*60}\n") - - return success_count, skip_count, error_count - - -async def main(): - parser = argparse.ArgumentParser(description="Prefetch historical celestial data") - parser.add_argument("--months", type=int, help="Number of months to prefetch from now (default: 12)") - parser.add_argument("--year", type=int, help="Specific year to prefetch") - parser.add_argument("--month", type=int, help="Specific month to prefetch (1-12)") - parser.add_argument("--start-year", type=int, help="Start year for range") - parser.add_argument("--start-month", type=int, help="Start month for range (1-12)") - parser.add_argument("--end-year", type=int, help="End year for range") - parser.add_argument("--end-month", type=int, help="End month for range (1-12)") - - args = parser.parse_args() - - # Determine date range - months_to_fetch = [] - - if args.year and args.month: - # Single month - months_to_fetch.append((args.year, args.month)) - elif args.start_year and args.start_month and args.end_year and args.end_month: - # Date range - current = datetime(args.start_year, args.start_month, 1) - end = datetime(args.end_year, args.end_month, 1) - while current <= end: - months_to_fetch.append((current.year, current.month)) - current += relativedelta(months=1) - else: - # Default: last N months - months = args.months or 12 - current = datetime.now() - for i in range(months): - past_date = current - relativedelta(months=i) - months_to_fetch.append((past_date.year, past_date.month)) - months_to_fetch.reverse() # Start from oldest - - if not months_to_fetch: - print("❌ No months to fetch. Please specify a valid date range.") - return - - print(f"\n🚀 Historical Data Prefetch Script") - print(f"{'='*60}") - print(f"📅 Total months to fetch: {len(months_to_fetch)}") - print(f" From: {months_to_fetch[0][0]}-{months_to_fetch[0][1]:02d}") - print(f" To: {months_to_fetch[-1][0]}-{months_to_fetch[-1][1]:02d}") - print(f"{'='*60}\n") - - total_success = 0 - total_skip = 0 - total_error = 0 - - async for session in get_db(): - start_time = datetime.now() - - for year, month in months_to_fetch: - success, skip, error = await prefetch_month(year, month, session) - total_success += success - total_skip += skip - total_error += error - - end_time = datetime.now() - duration = end_time - start_time - - print(f"\n{'='*60}") - print(f"🎉 Prefetch Complete!") - print(f"{'='*60}") - print(f"📊 Overall Summary:") - print(f" Total months processed: {len(months_to_fetch)}") - print(f" ✅ Total success: {total_success}") - print(f" ⏭️ Total skipped: {total_skip}") - print(f" ❌ Total errors: {total_error}") - print(f" ⏱️ Duration: {duration}") - print(f"{'='*60}\n") - - break - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/scripts/reset_admin_password.py b/backend/scripts/reset_admin_password.py deleted file mode 100644 index 06a4ed7..0000000 --- a/backend/scripts/reset_admin_password.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Reset admin user password to 'cosmo' -""" -import asyncio -import sys -sys.path.insert(0, '/Users/jiliu/WorkSpace/cosmo/backend') - -from sqlalchemy import select, update -from app.database import AsyncSessionLocal -from app.models.db import User - - -async def reset_password(): - # Pre-generated bcrypt hash for 'cosmo' - new_hash = '$2b$12$42d8/NAaYJlK8w/1yBd5uegdHlDkpC9XFtXYu2sWq0EXj48KAMZ0i' - - async with AsyncSessionLocal() as session: - # Find admin user - result = await session.execute( - select(User).where(User.username == 'cosmo') - ) - user = result.scalar_one_or_none() - - if not user: - print("❌ Admin user 'cosmo' not found!") - return - - print(f"Found user: {user.username}") - print(f"New password hash: {new_hash[:50]}...") - - # Update password - await session.execute( - update(User) - .where(User.username == 'cosmo') - .values(password_hash=new_hash) - ) - await session.commit() - - print("✅ Admin password reset successfully!") - print("Username: cosmo") - print("Password: cosmo") - - -if __name__ == "__main__": - asyncio.run(reset_password()) diff --git a/backend/scripts/run_sql.py b/backend/scripts/run_sql.py deleted file mode 100644 index 98ee8d0..0000000 --- a/backend/scripts/run_sql.py +++ /dev/null @@ -1,40 +0,0 @@ -import asyncio -import sys -from sqlalchemy import text -from app.database import get_db, init_db -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -async def run_sql_file(sql_file_path): - await init_db() - - try: - with open(sql_file_path, 'r') as f: - sql_content = f.read() - - # Split by semicolon to handle multiple statements if needed - # But sqlalchemy text() might handle it. Let's try executing as one block if possible, - # or split manually if it's simple. - statements = [s.strip() for s in sql_content.split(';') if s.strip()] - - async for session in get_db(): - for stmt in statements: - logger.info(f"Executing: {stmt[:50]}...") - await session.execute(text(stmt)) - await session.commit() - logger.info("SQL execution completed successfully.") - - except FileNotFoundError: - logger.error(f"File not found: {sql_file_path}") - except Exception as e: - logger.error(f"Error executing SQL: {e}") - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python -m scripts.run_sql ") - sys.exit(1) - - sql_file = sys.argv[1] - asyncio.run(run_sql_file(sql_file)) diff --git a/backend/scripts/seed_asteroid_belts.py b/backend/scripts/seed_asteroid_belts.py deleted file mode 100644 index e94039b..0000000 --- a/backend/scripts/seed_asteroid_belts.py +++ /dev/null @@ -1,83 +0,0 @@ -import asyncio -from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db, init_db -from app.models.db import StaticData -from datetime import datetime -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -async def seed_asteroid_belts(): - await init_db() # Ensure database is initialized - - async for session in get_db(): # Use async for to get the session - logger.info("Seeding asteroid and Kuiper belt static data...") - - belts_data = [ - { - "category": "asteroid_belt", - "name": "Main Asteroid Belt", - "name_zh": "主小行星带", - "data": { - "innerRadiusAU": 2.2, - "outerRadiusAU": 3.2, - "count": 1500, - "color": "#665544", - "size": 0.1, - "opacity": 0.4, - "heightScale": 0.05, - "rotationSpeed": 0.02 - } - }, - { - "category": "kuiper_belt", - "name": "Kuiper Belt", - "name_zh": "柯伊伯带", - "data": { - "innerRadiusAU": 30, - "outerRadiusAU": 50, - "count": 2500, - "color": "#AABBDD", - "size": 0.2, - "opacity": 0.3, - "heightScale": 0.1, - "rotationSpeed": 0.005 - } - } - ] - - for belt_item in belts_data: - # Check if an item with the same category and name already exists - existing_item = await session.execute( - StaticData.__table__.select().where( - StaticData.category == belt_item["category"], - StaticData.name == belt_item["name"] - ) - ) - if existing_item.scalar_one_or_none(): - logger.info(f"Static data for {belt_item['name']} already exists. Updating...") - stmt = StaticData.__table__.update().where( - StaticData.category == belt_item["category"], - StaticData.name == belt_item["name"] - ).values( - name_zh=belt_item["name_zh"], - data=belt_item["data"], - updated_at=datetime.utcnow() - ) - await session.execute(stmt) - else: - logger.info(f"Adding static data for {belt_item['name']}...") - static_data_entry = StaticData( - category=belt_item["category"], - name=belt_item["name"], - name_zh=belt_item["name_zh"], - data=belt_item["data"] - ) - session.add(static_data_entry) - - await session.commit() - logger.info("Asteroid and Kuiper belt static data seeding complete.") - -if __name__ == "__main__": - asyncio.run(seed_asteroid_belts()) diff --git a/backend/scripts/seed_celestial_bodies.py b/backend/scripts/seed_celestial_bodies.py deleted file mode 100755 index 67ff213..0000000 --- a/backend/scripts/seed_celestial_bodies.py +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env python3 -""" -Seed celestial bodies script - -Adds all celestial bodies from CELESTIAL_BODIES to the database -and fetches their current positions from NASA Horizons. - -Usage: - python scripts/seed_celestial_bodies.py -""" - -import sys -import os -import asyncio -from datetime import datetime - -# Add backend to path -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.celestial_body_service import celestial_body_service -from app.services.position_service import position_service -from app.models.celestial import CELESTIAL_BODIES - - -async def seed_bodies(): - """Seed celestial bodies into database""" - print("\n" + "=" * 60) - print("🌌 Seeding Celestial Bodies") - print("=" * 60) - - async for session in get_db(): - success_count = 0 - skip_count = 0 - error_count = 0 - - total = len(CELESTIAL_BODIES) - - for idx, (body_id, info) in enumerate(CELESTIAL_BODIES.items(), 1): - body_name = info["name"] - - try: - # Check if body already exists - existing_body = await celestial_body_service.get_body_by_id(body_id, session) - - if existing_body: - print(f" [{idx}/{total}] ⏭️ {body_name:20s} - Already exists") - skip_count += 1 - continue - - print(f" [{idx}/{total}] 🔄 {body_name:20s} - Creating...", end='', flush=True) - - # Create body record - body_data = { - "id": body_id, - "name": info["name"], - "name_zh": info.get("name_zh"), - "type": info["type"], - "description": info.get("description"), - "extra_data": { - "launch_date": info.get("launch_date"), - "status": info.get("status"), - } - } - - await celestial_body_service.create_body(body_data, session) - print(f" ✅ Created", flush=True) - success_count += 1 - - except Exception as e: - print(f" ❌ Error: {str(e)}", flush=True) - error_count += 1 - continue - - print(f"\n{'='*60}") - print(f"📊 Summary:") - print(f" ✅ Created: {success_count}") - print(f" ⏭️ Skipped: {skip_count}") - print(f" ❌ Errors: {error_count}") - print(f"{'='*60}\n") - - break - - -async def sync_current_positions(): - """Fetch and store current positions for all bodies""" - print("\n" + "=" * 60) - print("📍 Syncing Current Positions") - print("=" * 60) - - async for session in get_db(): - now = datetime.utcnow() - success_count = 0 - skip_count = 0 - error_count = 0 - - all_bodies = await celestial_body_service.get_all_bodies(session) - total = len(all_bodies) - - for idx, body in enumerate(all_bodies, 1): - body_id = body.id - body_name = body.name - - try: - # Check if we have recent position (within last hour) - from datetime import timedelta - recent_time = now - timedelta(hours=1) - existing_positions = await position_service.get_positions( - body_id, recent_time, now, session - ) - - if existing_positions and len(existing_positions) > 0: - print(f" [{idx}/{total}] ⏭️ {body_name:20s} - Recent data exists") - skip_count += 1 - continue - - print(f" [{idx}/{total}] 🔄 {body_name:20s} - Fetching...", end='', flush=True) - - # Special handling for Sun - if body_id == "10": - positions_data = [{"time": now, "x": 0.0, "y": 0.0, "z": 0.0}] - # Special handling for Cassini - elif body_id == "-82": - cassini_date = datetime(2017, 9, 15, 11, 58, 0) - positions_data = horizons_service.get_body_positions( - body_id, cassini_date, cassini_date - ) - positions_data = [ - {"time": p.time, "x": p.x, "y": p.y, "z": p.z} - for p in positions_data - ] - else: - # Query current position - positions_data = horizons_service.get_body_positions( - body_id, now, now - ) - positions_data = [ - {"time": p.time, "x": p.x, "y": p.y, "z": p.z} - for p in positions_data - ] - - # Store positions - for pos_data in positions_data: - await position_service.save_position( - body_id=body_id, - time=pos_data["time"], - x=pos_data["x"], - y=pos_data["y"], - z=pos_data["z"], - source="nasa_horizons", - session=session, - ) - - print(f" ✅ Saved {len(positions_data)} position(s)", flush=True) - success_count += 1 - - # Small delay to avoid overwhelming NASA API - await asyncio.sleep(0.5) - - except Exception as e: - print(f" ❌ Error: {str(e)}", flush=True) - error_count += 1 - continue - - print(f"\n{'='*60}") - print(f"📊 Summary:") - print(f" ✅ Success: {success_count}") - print(f" ⏭️ Skipped: {skip_count}") - print(f" ❌ Errors: {error_count}") - print(f"{'='*60}\n") - - break - - -async def main(): - print("\n🚀 Celestial Bodies Database Seeding") - print("=" * 60) - print("This script will:") - print(" 1. Add all celestial bodies to the database") - print(" 2. Fetch and store their current positions") - print("=" * 60) - - # Seed celestial bodies - await seed_bodies() - - # Sync current positions - await sync_current_positions() - - print("\n🎉 Seeding complete!") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/scripts/update_static_data.py b/backend/scripts/update_static_data.py deleted file mode 100644 index ce74507..0000000 --- a/backend/scripts/update_static_data.py +++ /dev/null @@ -1,623 +0,0 @@ -#!/usr/bin/env python3 -""" -Update static_data table with expanded astronomical data -""" -import asyncio -import sys -import os - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app.database import get_db -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 - - -# Expanded constellation data (15 constellations) -CONSTELLATIONS = [ - { - "name": "Orion", - "name_zh": "猎户座", - "data": { - "stars": [ - {"name": "Betelgeuse", "ra": 88.79, "dec": 7.41}, - {"name": "Bellatrix", "ra": 81.28, "dec": 6.35}, - {"name": "Alnitak", "ra": 85.19, "dec": -1.94}, - {"name": "Alnilam", "ra": 84.05, "dec": -1.20}, - {"name": "Mintaka", "ra": 83.00, "dec": -0.30}, - {"name": "Saiph", "ra": 86.94, "dec": -9.67}, - {"name": "Rigel", "ra": 78.63, "dec": -8.20} - ], - "lines": [[0, 1], [1, 2], [2, 3], [3, 4], [2, 5], [5, 6]] - } - }, - { - "name": "Ursa Major", - "name_zh": "大熊座", - "data": { - "stars": [ - {"name": "Dubhe", "ra": 165.93, "dec": 61.75}, - {"name": "Merak", "ra": 165.46, "dec": 56.38}, - {"name": "Phecda", "ra": 178.46, "dec": 53.69}, - {"name": "Megrez", "ra": 183.86, "dec": 57.03}, - {"name": "Alioth", "ra": 193.51, "dec": 55.96}, - {"name": "Mizar", "ra": 200.98, "dec": 54.93}, - {"name": "Alkaid", "ra": 206.89, "dec": 49.31} - ], - "lines": [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6]] - } - }, - { - "name": "Cassiopeia", - "name_zh": "仙后座", - "data": { - "stars": [ - {"name": "Caph", "ra": 2.29, "dec": 59.15}, - {"name": "Schedar", "ra": 10.13, "dec": 56.54}, - {"name": "Navi", "ra": 14.18, "dec": 60.72}, - {"name": "Ruchbah", "ra": 21.45, "dec": 60.24}, - {"name": "Segin", "ra": 25.65, "dec": 63.67} - ], - "lines": [[0, 1], [1, 2], [2, 3], [3, 4]] - } - }, - { - "name": "Leo", - "name_zh": "狮子座", - "data": { - "stars": [ - {"name": "Regulus", "ra": 152.09, "dec": 11.97}, - {"name": "Denebola", "ra": 177.26, "dec": 14.57}, - {"name": "Algieba", "ra": 154.99, "dec": 19.84}, - {"name": "Zosma", "ra": 168.53, "dec": 20.52}, - {"name": "Chertan", "ra": 173.95, "dec": 15.43} - ], - "lines": [[0, 2], [2, 3], [3, 4], [4, 1], [1, 0]] - } - }, - { - "name": "Scorpius", - "name_zh": "天蝎座", - "data": { - "stars": [ - {"name": "Antares", "ra": 247.35, "dec": -26.43}, - {"name": "Shaula", "ra": 263.40, "dec": -37.10}, - {"name": "Sargas", "ra": 264.33, "dec": -43.00}, - {"name": "Dschubba", "ra": 240.08, "dec": -22.62}, - {"name": "Lesath", "ra": 262.69, "dec": -37.29} - ], - "lines": [[3, 0], [0, 1], [1, 4], [1, 2]] - } - }, - { - "name": "Cygnus", - "name_zh": "天鹅座", - "data": { - "stars": [ - {"name": "Deneb", "ra": 310.36, "dec": 45.28}, - {"name": "Sadr", "ra": 305.56, "dec": 40.26}, - {"name": "Albireo", "ra": 292.68, "dec": 27.96}, - {"name": "Delta Cygni", "ra": 296.24, "dec": 45.13}, - {"name": "Gienah", "ra": 314.29, "dec": 33.97} - ], - "lines": [[0, 1], [1, 2], [1, 3], [1, 4]] - } - }, - { - "name": "Aquila", - "name_zh": "天鹰座", - "data": { - "stars": [ - {"name": "Altair", "ra": 297.70, "dec": 8.87}, - {"name": "Tarazed", "ra": 296.56, "dec": 10.61}, - {"name": "Alshain", "ra": 298.83, "dec": 6.41}, - {"name": "Deneb el Okab", "ra": 304.48, "dec": 15.07} - ], - "lines": [[1, 0], [0, 2], [0, 3]] - } - }, - { - "name": "Lyra", - "name_zh": "天琴座", - "data": { - "stars": [ - {"name": "Vega", "ra": 279.23, "dec": 38.78}, - {"name": "Sheliak", "ra": 282.52, "dec": 33.36}, - {"name": "Sulafat", "ra": 284.74, "dec": 32.69}, - {"name": "Delta Lyrae", "ra": 283.82, "dec": 36.90} - ], - "lines": [[0, 3], [3, 1], [1, 2], [2, 0]] - } - }, - { - "name": "Pegasus", - "name_zh": "飞马座", - "data": { - "stars": [ - {"name": "Markab", "ra": 346.19, "dec": 15.21}, - {"name": "Scheat", "ra": 345.94, "dec": 28.08}, - {"name": "Algenib", "ra": 3.31, "dec": 15.18}, - {"name": "Enif", "ra": 326.05, "dec": 9.88} - ], - "lines": [[0, 1], [1, 2], [2, 0], [0, 3]] - } - }, - { - "name": "Andromeda", - "name_zh": "仙女座", - "data": { - "stars": [ - {"name": "Alpheratz", "ra": 2.10, "dec": 29.09}, - {"name": "Mirach", "ra": 17.43, "dec": 35.62}, - {"name": "Almach", "ra": 30.97, "dec": 42.33}, - {"name": "Delta Andromedae", "ra": 8.78, "dec": 30.86} - ], - "lines": [[0, 3], [3, 1], [1, 2]] - } - }, - { - "name": "Taurus", - "name_zh": "金牛座", - "data": { - "stars": [ - {"name": "Aldebaran", "ra": 68.98, "dec": 16.51}, - {"name": "Elnath", "ra": 81.57, "dec": 28.61}, - {"name": "Alcyone", "ra": 56.87, "dec": 24.11}, - {"name": "Zeta Tauri", "ra": 84.41, "dec": 21.14} - ], - "lines": [[0, 1], [0, 2], [1, 3]] - } - }, - { - "name": "Gemini", - "name_zh": "双子座", - "data": { - "stars": [ - {"name": "Pollux", "ra": 116.33, "dec": 28.03}, - {"name": "Castor", "ra": 113.65, "dec": 31.89}, - {"name": "Alhena", "ra": 99.43, "dec": 16.40}, - {"name": "Mebsuta", "ra": 100.98, "dec": 25.13} - ], - "lines": [[0, 1], [0, 2], [1, 3], [3, 2]] - } - }, - { - "name": "Virgo", - "name_zh": "室女座", - "data": { - "stars": [ - {"name": "Spica", "ra": 201.30, "dec": -11.16}, - {"name": "Porrima", "ra": 190.42, "dec": 1.76}, - {"name": "Vindemiatrix", "ra": 195.54, "dec": 10.96}, - {"name": "Heze", "ra": 211.67, "dec": -0.67} - ], - "lines": [[2, 1], [1, 0], [0, 3]] - } - }, - { - "name": "Sagittarius", - "name_zh": "人马座", - "data": { - "stars": [ - {"name": "Kaus Australis", "ra": 276.04, "dec": -34.38}, - {"name": "Nunki", "ra": 283.82, "dec": -26.30}, - {"name": "Ascella", "ra": 290.97, "dec": -29.88}, - {"name": "Kaus Media", "ra": 276.99, "dec": -29.83}, - {"name": "Kaus Borealis", "ra": 279.23, "dec": -25.42} - ], - "lines": [[0, 3], [3, 4], [4, 1], [1, 2]] - } - }, - { - "name": "Capricornus", - "name_zh": "摩羯座", - "data": { - "stars": [ - {"name": "Deneb Algedi", "ra": 326.76, "dec": -16.13}, - {"name": "Dabih", "ra": 305.25, "dec": -14.78}, - {"name": "Nashira", "ra": 325.02, "dec": -16.66}, - {"name": "Algedi", "ra": 304.51, "dec": -12.51} - ], - "lines": [[3, 1], [1, 2], [2, 0]] - } - } -] - -# Expanded galaxy data (12 galaxies) -GALAXIES = [ - { - "name": "Andromeda Galaxy", - "name_zh": "仙女座星系", - "data": { - "type": "spiral", - "distance_mly": 2.537, - "ra": 10.68, - "dec": 41.27, - "magnitude": 3.44, - "diameter_kly": 220, - "color": "#CCDDFF" - } - }, - { - "name": "Triangulum Galaxy", - "name_zh": "三角座星系", - "data": { - "type": "spiral", - "distance_mly": 2.73, - "ra": 23.46, - "dec": 30.66, - "magnitude": 5.72, - "diameter_kly": 60, - "color": "#AACCEE" - } - }, - { - "name": "Large Magellanic Cloud", - "name_zh": "大麦哲伦云", - "data": { - "type": "irregular", - "distance_mly": 0.163, - "ra": 80.89, - "dec": -69.76, - "magnitude": 0.9, - "diameter_kly": 14, - "color": "#DDCCFF" - } - }, - { - "name": "Small Magellanic Cloud", - "name_zh": "小麦哲伦云", - "data": { - "type": "irregular", - "distance_mly": 0.197, - "ra": 12.80, - "dec": -73.15, - "magnitude": 2.7, - "diameter_kly": 7, - "color": "#CCBBEE" - } - }, - { - "name": "Milky Way Center", - "name_zh": "银河系中心", - "data": { - "type": "galactic_center", - "distance_mly": 0.026, - "ra": 266.42, - "dec": -29.01, - "magnitude": -1, - "diameter_kly": 100, - "color": "#FFFFAA" - } - }, - { - "name": "Whirlpool Galaxy", - "name_zh": "漩涡星系", - "data": { - "type": "spiral", - "distance_mly": 23, - "ra": 202.47, - "dec": 47.20, - "magnitude": 8.4, - "diameter_kly": 76, - "color": "#AADDFF" - } - }, - { - "name": "Sombrero Galaxy", - "name_zh": "草帽星系", - "data": { - "type": "spiral", - "distance_mly": 29.3, - "ra": 189.99, - "dec": -11.62, - "magnitude": 8.0, - "diameter_kly": 50, - "color": "#FFDDAA" - } - }, - { - "name": "Pinwheel Galaxy", - "name_zh": "风车星系", - "data": { - "type": "spiral", - "distance_mly": 21, - "ra": 210.80, - "dec": 54.35, - "magnitude": 7.9, - "diameter_kly": 170, - "color": "#BBDDFF" - } - }, - { - "name": "Bode's Galaxy", - "name_zh": "波德星系", - "data": { - "type": "spiral", - "distance_mly": 11.8, - "ra": 148.97, - "dec": 69.07, - "magnitude": 6.9, - "diameter_kly": 90, - "color": "#CCDDFF" - } - }, - { - "name": "Cigar Galaxy", - "name_zh": "雪茄星系", - "data": { - "type": "starburst", - "distance_mly": 11.5, - "ra": 148.97, - "dec": 69.68, - "magnitude": 8.4, - "diameter_kly": 37, - "color": "#FFCCAA" - } - }, - { - "name": "Centaurus A", - "name_zh": "半人马座A", - "data": { - "type": "elliptical", - "distance_mly": 13.7, - "ra": 201.37, - "dec": -43.02, - "magnitude": 6.8, - "diameter_kly": 60, - "color": "#FFDDCC" - } - }, - { - "name": "Sculptor Galaxy", - "name_zh": "玉夫座星系", - "data": { - "type": "spiral", - "distance_mly": 11.4, - "ra": 15.15, - "dec": -25.29, - "magnitude": 7.2, - "diameter_kly": 90, - "color": "#CCDDEE" - } - } -] - -# Nebula data (12 nebulae) -NEBULAE = [ - { - "name": "Orion Nebula", - "name_zh": "猎户座大星云", - "data": { - "type": "emission", - "distance_ly": 1344, - "ra": 83.82, - "dec": -5.39, - "magnitude": 4.0, - "diameter_ly": 24, - "color": "#FF6B9D" - } - }, - { - "name": "Eagle Nebula", - "name_zh": "鹰状星云", - "data": { - "type": "emission", - "distance_ly": 7000, - "ra": 274.70, - "dec": -13.80, - "magnitude": 6.0, - "diameter_ly": 70, - "color": "#FF8B7D" - } - }, - { - "name": "Crab Nebula", - "name_zh": "蟹状星云", - "data": { - "type": "supernova_remnant", - "distance_ly": 6500, - "ra": 83.63, - "dec": 22.01, - "magnitude": 8.4, - "diameter_ly": 11, - "color": "#FFAA66" - } - }, - { - "name": "Ring Nebula", - "name_zh": "环状星云", - "data": { - "type": "planetary", - "distance_ly": 2300, - "ra": 283.40, - "dec": 33.03, - "magnitude": 8.8, - "diameter_ly": 1, - "color": "#66DDFF" - } - }, - { - "name": "Helix Nebula", - "name_zh": "螺旋星云", - "data": { - "type": "planetary", - "distance_ly": 700, - "ra": 337.41, - "dec": -20.84, - "magnitude": 7.6, - "diameter_ly": 2.5, - "color": "#88CCFF" - } - }, - { - "name": "Lagoon Nebula", - "name_zh": "礁湖星云", - "data": { - "type": "emission", - "distance_ly": 4100, - "ra": 270.93, - "dec": -24.38, - "magnitude": 6.0, - "diameter_ly": 55, - "color": "#FF99AA" - } - }, - { - "name": "Horsehead Nebula", - "name_zh": "马头星云", - "data": { - "type": "dark", - "distance_ly": 1500, - "ra": 85.30, - "dec": -2.46, - "magnitude": 10.0, - "diameter_ly": 3.5, - "color": "#886655" - } - }, - { - "name": "Eta Carinae Nebula", - "name_zh": "船底座η星云", - "data": { - "type": "emission", - "distance_ly": 7500, - "ra": 161.26, - "dec": -59.87, - "magnitude": 3.0, - "diameter_ly": 300, - "color": "#FFAACC" - } - }, - { - "name": "North America Nebula", - "name_zh": "北美洲星云", - "data": { - "type": "emission", - "distance_ly": 1600, - "ra": 312.95, - "dec": 44.32, - "magnitude": 4.0, - "diameter_ly": 50, - "color": "#FF7788" - } - }, - { - "name": "Trifid Nebula", - "name_zh": "三叶星云", - "data": { - "type": "emission", - "distance_ly": 5200, - "ra": 270.36, - "dec": -23.03, - "magnitude": 6.3, - "diameter_ly": 25, - "color": "#FF99DD" - } - }, - { - "name": "Dumbbell Nebula", - "name_zh": "哑铃星云", - "data": { - "type": "planetary", - "distance_ly": 1360, - "ra": 299.90, - "dec": 22.72, - "magnitude": 7.5, - "diameter_ly": 1.44, - "color": "#77DDFF" - } - }, - { - "name": "Veil Nebula", - "name_zh": "面纱星云", - "data": { - "type": "supernova_remnant", - "distance_ly": 2400, - "ra": 312.92, - "dec": 30.72, - "magnitude": 7.0, - "diameter_ly": 110, - "color": "#AADDFF" - } - } -] - - -async def update_static_data(): - """Update static_data table with expanded astronomical data""" - print("=" * 60) - print("Updating static_data table") - print("=" * 60) - - async for session in get_db(): - # Update constellations - print(f"\nUpdating {len(CONSTELLATIONS)} constellations...") - for const in CONSTELLATIONS: - stmt = pg_insert(StaticData).values( - category="constellation", - name=const["name"], - name_zh=const["name_zh"], - data=const["data"] - ) - stmt = stmt.on_conflict_do_update( - index_elements=['category', 'name'], - set_={ - 'name_zh': const["name_zh"], - 'data': const["data"] - } - ) - await session.execute(stmt) - print(f" ✓ {const['name']} ({const['name_zh']})") - - # Update galaxies - print(f"\nUpdating {len(GALAXIES)} galaxies...") - for galaxy in GALAXIES: - stmt = pg_insert(StaticData).values( - category="galaxy", - name=galaxy["name"], - name_zh=galaxy["name_zh"], - data=galaxy["data"] - ) - stmt = stmt.on_conflict_do_update( - index_elements=['category', 'name'], - set_={ - 'name_zh': galaxy["name_zh"], - 'data': galaxy["data"] - } - ) - await session.execute(stmt) - print(f" ✓ {galaxy['name']} ({galaxy['name_zh']})") - - # Insert nebulae - print(f"\nInserting {len(NEBULAE)} nebulae...") - for nebula in NEBULAE: - stmt = pg_insert(StaticData).values( - category="nebula", - name=nebula["name"], - name_zh=nebula["name_zh"], - data=nebula["data"] - ) - stmt = stmt.on_conflict_do_update( - index_elements=['category', 'name'], - set_={ - 'name_zh': nebula["name_zh"], - 'data': nebula["data"] - } - ) - await session.execute(stmt) - print(f" ✓ {nebula['name']} ({nebula['name_zh']})") - - await session.commit() - break # Only use first session - - print("\n" + "=" * 60) - print("✓ Static data update complete!") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(update_static_data()) diff --git a/docker-compose.yml b/docker-compose.yml index ac3f276..d10aaf4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: PGDATA: /var/lib/postgresql/data/pgdata volumes: - /opt/cosmo/data/postgres:/var/lib/postgresql/data - - ./backend/scripts/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql:ro + - ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql:ro ports: - "5432:5432" networks: diff --git a/docs/README.md b/docs/README.md index 6d55305..5c05554 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,10 @@ # Cosmo 文档 -项目入口文档保留在仓库根目录: +项目入口文档: -- [README](../README.md) -- [项目说明](../PROJECT.md) -- [快速开始](../QUICKSTART.md) - [部署指南](../DEPLOYMENT.md) +- 开发环境一键启动:[`scripts/run.sh`](../scripts/run.sh) +- 后端环境初始化:[`scripts/setup.sh`](../scripts/setup.sh) 其余文档按用途归档: diff --git a/docs/guides/BACKEND_CONFIG.md b/docs/guides/BACKEND_CONFIG.md index 9294191..5678fa8 100644 --- a/docs/guides/BACKEND_CONFIG.md +++ b/docs/guides/BACKEND_CONFIG.md @@ -8,10 +8,16 @@ backend/ ├── .env.example # 配置模板(提交到 Git) ├── app/ │ └── config.py # 配置管理(Pydantic Settings) -└── scripts/ - ├── create_db.py # 创建数据库 - ├── init_db.py # 初始化表结构 - └── setup.sh # 一键初始化脚本 +└── ... + +scripts/ # 启动 / 初始化 / 部署脚本 +├── run.sh # 一键启动前后端开发环境 +├── setup.sh # 一键初始化(建库建表 + 默认管理员) +├── create_db.py # 创建数据库 +├── init_db.py # 初始化表结构 +├── seed_admin.py # 初始化默认管理员、角色与菜单 +├── init_db.sql # 完整数据库结构与数据(Docker 初始化用) +└── deploy.sh # Docker 生产部署 ``` ## 配置项说明 @@ -101,8 +107,9 @@ chmod +x scripts/setup.sh ./scripts/setup.sh # 方式二:手动执行 -python scripts/create_db.py # 创建数据库 +python scripts/create_db.py # 创建数据库(在项目根目录执行) python scripts/init_db.py # 初始化表结构 +python scripts/seed_admin.py # 初始化默认管理员 cosmo / cosmo ``` ### 5. 启动服务 @@ -180,6 +187,7 @@ curl http://localhost:8000/health - 如果需要重置数据库,先删除再创建: ```bash psql -U postgres -c "DROP DATABASE cosmo_db;" + # 以下命令在项目根目录执行 python scripts/create_db.py python scripts/init_db.py ``` diff --git a/docs/guides/PROXY_SETUP.md b/docs/guides/PROXY_SETUP.md index 7877bda..0a45ee9 100644 --- a/docs/guides/PROXY_SETUP.md +++ b/docs/guides/PROXY_SETUP.md @@ -33,7 +33,7 @@ HTTPS_PROXY=http://192.168.124.203:20171 ```bash # 在部署服务器上执行 cd /path/to/cosmo -./deploy.sh --restart +./scripts/deploy.sh --restart ``` 或者使用 Docker Compose: diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 3243ab5..6ccbcba 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -21,7 +21,6 @@ const SystemSettings = lazy(() => import('./pages/admin/SystemSettings').then((m 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 }))); @@ -33,11 +32,22 @@ function RouteFallback() { ); } -// Protected Route wrapper -function ProtectedRoute({ children }: { children: React.ReactNode }) { +/** + * 受保护路由:未登录跳转登录页;adminOnly 时校验管理员角色, + * 普通用户直接访问管理页面会被引导回用户中心。 + */ +function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; adminOnly?: boolean }) { if (!auth.isLoggedIn()) { return ; } + + if (adminOnly) { + const roles = (auth.getUser()?.roles as string[] | undefined) ?? []; + if (!roles.includes('admin')) { + return ; + } + } + return <>{children}; } @@ -65,19 +75,21 @@ export function Router() { > } /> } /> + {/* 修改密码已合并到个人资料页,旧链接重定向过去 */} + } /> {/* Admin routes (protected) */} - - + + + } > } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/admin/AdminPage.tsx b/frontend/src/components/admin/AdminPage.tsx new file mode 100644 index 0000000..f119fc6 --- /dev/null +++ b/frontend/src/components/admin/AdminPage.tsx @@ -0,0 +1,76 @@ +/** + * 管理后台页面骨架 + * + * 每个页面只有一个 Header 卡片:图标 + 标题 + 说明(+ 可选附加信息)+ 右侧操作。 + * 页面内不再出现第二块说明卡片,避免与 Header 重复。 + */ +import type { ReactNode } from 'react'; + +import { useAdminPrefs } from '../../pages/admin/AdminPrefsContext'; + +interface AdminPageProps { + title: string; + description?: string; + /** 标题左侧的图标,通常与该页在菜单中的图标一致 */ + icon?: ReactNode; + /** 说明下方的附加信息(截止日期、统计等) */ + meta?: ReactNode; + actions?: ReactNode; + children: ReactNode; +} + +export function AdminPage({ title, description, icon, meta, actions, children }: AdminPageProps) { + const { t } = useAdminPrefs(); + + return ( +
+
+
+ {icon ? {icon} : null} +
+

{t(title)}

+ {description ?

{t(description)}

: null} + {meta ?
{meta}
: null} +
+
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +} + +/** + * 统计卡片:控制台等页面统一使用。 + */ +export function StatCard({ + icon, + label, + value, + unit, + footnote, + loading, +}: { + icon: ReactNode; + label: string; + value: ReactNode; + unit?: string; + footnote?: string; + loading?: boolean; +}) { + const { t } = useAdminPrefs(); + + return ( +
+
+ {icon} + {t(label)} +
+
+ {loading ? '—' : value} + {unit && !loading ? {t(unit)} : null} +
+ {footnote ?
{t(footnote)}
: null} +
+ ); +} diff --git a/frontend/src/components/admin/DataTable.tsx b/frontend/src/components/admin/DataTable.tsx index 24cb670..28bbd3a 100644 --- a/frontend/src/components/admin/DataTable.tsx +++ b/frontend/src/components/admin/DataTable.tsx @@ -1,10 +1,20 @@ -import { Table, Input, Button, Space, Popconfirm, Switch, Card, Tooltip } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +/** + * 管理后台统一数据表格 + * + * 所有列表页共用:面板标题 + 说明 + 工具栏(搜索 / 批量操作 / 新增)+ + * 表格 + 分页,以及一致的行操作(自定义操作 / 编辑 / 删除)。 + */ +import { useEffect, useState } from 'react'; +import { Button, Card, Empty, Input, Popconfirm, Space, Switch, Table, Tooltip } from 'antd'; +import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import type { ColumnsType, TableProps } from 'antd/es/table'; import type { ReactNode } from 'react'; +import { useAdminPrefs } from '../../pages/admin/AdminPrefsContext'; + interface DataTableProps { - title?: string; + title?: ReactNode; + description?: ReactNode; columns: ColumnsType; dataSource: T[]; loading?: boolean; @@ -15,20 +25,28 @@ interface DataTableProps { onSearch?: (keyword: string) => void; searchPlaceholder?: string; onAdd?: () => void; + addText?: string; showAdd?: boolean; + /** 工具栏中的刷新按钮(各列表页统一提供) */ + onRefresh?: () => void; onEdit?: (record: T) => void; showEdit?: boolean; onDelete?: (record: T) => void; + deleteConfirmTitle?: string; + deleteConfirmDescription?: string; 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; + /** 工具栏中、搜索框右侧的额外控件 */ + toolbar?: ReactNode; scroll?: TableProps['scroll']; } export function DataTable({ title, + description, columns, dataSource, loading, @@ -39,79 +57,79 @@ export function DataTable({ onSearch, searchPlaceholder = '搜索...', onAdd, + addText = '新增', showAdd = true, + onRefresh, onEdit, showEdit = true, onDelete, + deleteConfirmTitle = '确认删除?', + deleteConfirmDescription = '此操作无法撤销', onStatusChange, statusField = 'is_active' as keyof T, rowKey = 'id', customActions, + toolbar, scroll = { x: 'max-content' }, }: DataTableProps) { - // Inject action columns if callbacks are provided - const tableColumns: ColumnsType = [ - ...columns, - ]; + const { t } = useAdminPrefs(); + // 分页大小由外部(系统参数 page_size)驱动,同时允许用户在分页器里临时调整 + const [innerPage, setInnerPage] = useState(currentPage); + const [innerPageSize, setInnerPageSize] = useState(pageSize); - // Check if an action column already exists in the provided columns - const hasExistingActionColumn = columns.some(col => col.key === 'action'); + useEffect(() => { + setInnerPage(currentPage); + }, [currentPage]); + + useEffect(() => { + setInnerPageSize(pageSize); + }, [pageSize]); + + // 列标题统一走翻译表,页面无需逐列处理 + const tableColumns: ColumnsType = columns.map((column) => ( + typeof column.title === 'string' ? { ...column, title: t(column.title) } : column + )); + const hasExistingActionColumn = columns.some((column) => column.key === 'action'); - // Add status column if onStatusChange is provided if (onStatusChange) { tableColumns.push({ - title: '状态', + title: t('状态'), dataIndex: statusField as string, key: 'status', width: 100, render: (value: boolean, record: T) => ( - onStatusChange(record, checked)} - size="small" - /> + + onStatusChange(record, checked)} size="small" /> + ), }); } - // Add operations column if onEdit or onDelete or customActions is provided - // and if there isn't already an 'action' column explicitly defined by the parent if (!hasExistingActionColumn && (onEdit || onDelete || customActions)) { tableColumns.push({ - title: '操作', + title: t('操作'), key: 'action', - width: 150, + width: 160, fixed: 'right', render: (_, record) => ( - - {customActions && customActions(record)} + + {customActions?.(record)} {onEdit && showEdit && ( - + )} + + + ); + return ( - {onSearch && ( - { - if (!e.target.value) onSearch(''); - }} - style={{ width: 250 }} - /> - )} - {onAdd && showAdd && ( - - )} - + className="adm-panel" + title={ + title ? ( +
+
{title}
+ {description ?
{description}
: null} +
+ ) : undefined } + extra={onSearch || onAdd || toolbar ? toolbarNode : undefined} styles={{ body: { padding: 0 } }} > `共 ${total} 条`, - } - : { - defaultPageSize: pageSize, - showSizeChanger: true, - showTotal: (total) => `共 ${total} 条`, - } - } + locale={{ + emptyText: ( + + ), + }} + pagination={{ + current: innerPage, + pageSize: innerPageSize, + total: onPageChange ? total : undefined, + onChange: (nextPage, nextPageSize) => { + setInnerPage(nextPage); + setInnerPageSize(nextPageSize); + onPageChange?.(nextPage, nextPageSize); + }, + showSizeChanger: true, + showTotal: (count) => `共 ${count} 条`, + }} scroll={scroll} /> diff --git a/frontend/src/features/rocket-simulator/RocketFlightScene.tsx b/frontend/src/features/rocket-simulator/RocketFlightScene.tsx index a99ec40..e19f8bc 100644 --- a/frontend/src/features/rocket-simulator/RocketFlightScene.tsx +++ b/frontend/src/features/rocket-simulator/RocketFlightScene.tsx @@ -74,7 +74,158 @@ function globalFlightFrame(altitude: number, downrange: number, targetAltitude: }; } -/** Stable single-plane ground, simple launch platform and service rack. */ +/** 发射场:混凝土地坪 + 导流槽 + 发射台 + 服务塔 + 避雷塔。 */ +function LaunchPadStructure() { + const towerHeight = 13; + return ( + + {/* 混凝土地坪 */} + + + + + + + + + {/* 尾焰熏黑区域 */} + + + + + + {/* 发射台 + 导流槽 */} + + + + + + + + + {/* 导流锥 */} + + + + + {/* 压紧机构 */} + {[[-2.6, -2.6], [2.6, -2.6], [-2.6, 2.6], [2.6, 2.6]].map(([x, z]) => ( + + + + + ))} + + {/* 服务塔 */} + + {[-1.2, 1.2].map((offset) => ( + + + + + ))} + {[3.2, 6.4, 9.6, 12.2].map((y) => ( + + + + + ))} + {/* 摆杆 / 加注臂 */} + {[5.4, 9.4].map((y, index) => ( + + + + + ))} + {/* 塔顶工作平台(避雷针只装在四周的避雷塔上) */} + + + + + {[-1.55, 1.55].map((offset) => ( + + + + + ))} + + + + + + + {/* 避雷塔(四根柱子顶部的避雷针) */} + {[[-14, -9], [14, -9], [-14, 9], [14, 9]].map(([x, z]) => ( + + + + + + + + + + + ))} + + {/* 场坪编号,让地坪有尺度参照 */} + + + + + + ); +} + +/** + * 发射瞬间的蒸汽/烟雾:点火后从发射台底部翻涌扩散, + * 随飞行高度升高逐渐淡出(离地后不再有地面烟雾)。 + */ +function PadExhaust({ state }: { state: SimulationState }) { + const groupRef = useRef(null); + const puffs = useMemo( + () => Array.from({ length: 14 }, (_, index) => ({ + angle: index * 2.399, + speed: 0.5 + (index % 5) * 0.09, + delay: (index % 7) / 7, + })), + [], + ); + + useFrame(({ clock }) => { + if (!groupRef.current) return; + const burning = state.isRunning && state.throttle > 0.05 && state.altitude < 4_000; + groupRef.current.visible = burning; + if (!burning) return; + const time = clock.elapsedTime * 0.5; + groupRef.current.children.forEach((child, index) => { + const puff = puffs[index]; + const progress = (time * puff.speed + puff.delay) % 1; + const spread = 3 + progress * 26; + child.position.set( + Math.cos(puff.angle) * spread, + progress * 9, + Math.sin(puff.angle) * spread, + ); + child.scale.setScalar(1.4 + progress * 4.2); + const material = (child as THREE.Mesh).material as THREE.MeshBasicMaterial; + material.opacity = (1 - progress) * 0.24; + }); + }); + + return ( + + {puffs.map((_, index) => ( + + + + + ))} + + ); +} + +/** 地面场景容器:随飞行高度下移、随射程后移。 */ function LaunchRack({ state }: { state: SimulationState }) { const groupRef = useRef(null); @@ -88,26 +239,8 @@ function LaunchRack({ state }: { state: SimulationState }) { return ( - - - - - - - - - - - - - - {[5.4, 9.2].map((y) => ( - - - - - ))} - + + ); } @@ -333,7 +466,9 @@ function SceneContents({ rocket, state, cameraMode, viewScale, viewScaleResetTri const orbitControlsRef = useRef(null); const manualDistanceRef = useRef(null); const viewScaleRef = useRef(viewScale); - viewScaleRef.current = viewScale; + useEffect(() => { + viewScaleRef.current = viewScale; + }, [viewScale]); const controlTarget = useMemo<[number, number, number]>( () => cameraMode === 'follow' ? [0, 5.2, 0] : [EARTH_CENTER.x, EARTH_CENTER.y, EARTH_CENTER.z], [cameraMode], diff --git a/frontend/src/features/rocket-simulator/RocketModel.tsx b/frontend/src/features/rocket-simulator/RocketModel.tsx index b9a47b2..a22e043 100644 --- a/frontend/src/features/rocket-simulator/RocketModel.tsx +++ b/frontend/src/features/rocket-simulator/RocketModel.tsx @@ -2,16 +2,180 @@ 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'; +import { engineLayout, engineState, separationAge, separationTransform, stageGeometry } from './sceneMath'; -const DARK = '#20262d'; -const METAL = '#8b96a1'; +/* + * 运载火箭 3D 模型 + * + * 几何比例、发动机数量与排布、箭体涂装与文字标识全部来自火箭配置 + * (height_m / diameter_m / length_ratio / engine_count / color / name), + * 因此猎鹰 9(9 + 1 台发动机、细长箭体、大直径整流罩)与长征五号 + * (10 + 2 台发动机、5m 箭体、近乎等径整流罩)在场景里一眼可辨。 + */ + +const SOOT = '#14171a'; +const DARK = '#191d22'; +const METAL = '#9aa4ad'; +const COMPOSITE = '#e3e7ea'; + +/** 由箭体颜色推导深/浅两种涂装色,避免所有火箭长得一样。 */ +function shade(hex: string, amount: number): string { + const color = new THREE.Color(hex); + const target = amount >= 0 ? new THREE.Color('#ffffff') : new THREE.Color('#0b0d10'); + return color.lerp(target, Math.abs(amount)).getStyle(); +} /** - * 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. + * 用 canvas 生成箭体文字标识(型号名 / 级次),让模型与火箭资料对应。 + */ +function useDecalTexture(text: string, color: string, background: string) { + return useMemo(() => { + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.fillStyle = background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = color; + ctx.font = 'bold 64px "Helvetica Neue", Arial, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text.slice(0, 16), canvas.width / 2, canvas.height / 2 + 4); + const texture = new THREE.CanvasTexture(canvas); + texture.anisotropy = 4; + texture.colorSpace = THREE.SRGBColorSpace; + return texture; + }, [background, color, text]); +} + +/** 一圈弧面贴片:用于把文字标识包在箭体表面。 */ +function BodyDecal({ + radius, + height, + y, + text, + textColor, + background, + facing = 0, +}: { + radius: number; + height: number; + y: number; + text: string; + textColor: string; + background: string; + facing?: number; +}) { + const texture = useDecalTexture(text, textColor, background); + if (!texture) return null; + return ( + + + + + ); +} + +/** 箭体加强环:打断大面积圆柱面,让细长箭体有机械层次。 */ +function StringerRings({ radius, height, baseY = 0, count = 3 }: { radius: number; height: number; baseY?: number; count?: number }) { + const ys = useMemo( + () => Array.from({ length: count }, (_, index) => baseY + height * ((index + 1) / (count + 1))), + [baseY, count, height], + ); + return ( + + {ys.map((y) => ( + + + + + ))} + + ); +} + +/** 电缆罩(贯穿箭体的走线通道),真实运载火箭的显著特征之一。 */ +function Raceway({ radius, height, baseY = 0 }: { radius: number; height: number; baseY?: number }) { + return ( + + + + + ); +} + +/** 单个喷管:收敛段 + 扩张段 + 喷管出口。 */ +function Nozzle({ radius, length, color = METAL }: { radius: number; length: number; color?: string }) { + return ( + + + + + + + + + + + ); +} + +/** + * 发动机舱:隔热盘 + 按配置排布的喷管 + 伺服作动器。 + * 喷管数量与位置由火箭配置的 engine_count 决定。 + */ +function EngineCluster({ + radius, + count, + bellColor = METAL, + showActuators = true, +}: { + radius: number; + count: number; + bellColor?: string; + showActuators?: boolean; +}) { + const placements = useMemo(() => engineLayout(count, radius), [count, radius]); + + return ( + + {/* 尾段 / 隔热盘 */} + + + + + + + + + + {placements.map((placement, index) => ( + { + // 外圈发动机沿径向向外偏摆(真实矢量布局)。 + const angle = Math.atan2(placement.z, placement.x); + return [-Math.sin(angle) * placement.cant, 0, Math.cos(angle) * placement.cant]; + })()} + > + + {showActuators && placement.bellRadius > radius * 0.2 && ( + + + + + )} + + ))} + + ); +} + +/** + * 发动机尾焰:外层橙焰 + 内层白芯 + 马赫环。 + * 整个尾焰锚定在喷管出口(局部 y = 0)向下生长,缩放时焰根始终贴合喷管。 */ function EnginePlume({ radius, @@ -20,6 +184,7 @@ function EnginePlume({ active, running, nozzleY = 0, + spread = 1, }: { radius: number; length: number; @@ -27,78 +192,62 @@ function EnginePlume({ active: boolean; running: boolean; nozzleY?: number; + spread?: number; }) { const anchorRef = useRef(null); const lightRef = useRef(null); + const diamondRef = useRef(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; + const flicker = 0.88 + Math.sin(clock.elapsedTime * 42) * 0.08 + Math.random() * 0.05; + const stretch = flicker * (0.45 + throttle * 0.75); + anchorRef.current.scale.set(0.8 + throttle * 0.3, stretch, 0.8 + throttle * 0.3); + if (lightRef.current) lightRef.current.intensity = 6 + flicker * 4 * throttle; + if (diamondRef.current) { + diamondRef.current.children.forEach((child, index) => { + const pulse = 0.65 + Math.sin(clock.elapsedTime * 30 + index * 1.3) * 0.35; + child.scale.setScalar(0.7 + pulse * 0.5 * throttle); + }); + } }); 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 ( - - - + {/* 外层燃气 */} + + + - - - + {/* 内层亮芯 */} + + + - - - ); -} - -/** 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 ( - - - - + {/* 喷管根部的蓝色激波 */} + + + - {ring.map(([x, z], i) => ( - - - - - ))} - - ); -} - -/** Grid-fin / stabiliser fins around the base of the first stage. */ -function Fins({ radius, height }: { radius: number; height: number }) { - return ( - - {[0, 1, 2, 3].map((i) => ( - - - - - ))} + {/* 马赫环 */} + + {[0.34, 0.56, 0.76].map((offset) => ( + + + + + ))} + + ); } @@ -109,105 +258,339 @@ interface StageProps { 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 }) { +/** 一级芯级:箭体 + 涂装 + 走线罩 + 加强环 + 尾段发动机。基座位于局部 y = 0。 */ +function FirstStageBody({ + radius, + height, + color, + engineCount, + label, + gridFins = false, +}: StageProps & { engineCount: number; label: string; gridFins?: boolean }) { + const accent = shade(color, -0.55); + return ( + + + + + + {/* 底部防热 / 烟熏段 */} + + + + + {/* 顶部深色带 */} + + + + + + + + {gridFins && } + + + + + + + ); +} + +/** 栅格翼:细长单芯级回收构型(猎鹰 9)靠近一级顶部的四片格栅。 */ +function GridFins({ radius, y, color }: { radius: number; y: number; color: string }) { + return ( + + {[0, 1, 2, 3].map((index) => { + const angle = (index / 4) * Math.PI * 2 + Math.PI / 4; + return ( + + {/* 翼盒 */} + + + + + {/* 格栅叶片 */} + {[-0.24, 0, 0.24].map((offset) => ( + + + + + ))} + + ); + })} + + ); +} + +/** + * 助推器:捆绑在芯级四周的两级半火箭助推器(长征五号 4×2 台发动机)。 + * 每个助推器自带头锥、涂装、发动机与尾段。 + */ +function StrapOnBooster({ + radius, + height, + color, + engines, + angle, +}: { + radius: number; + height: number; + color: string; + engines: number; + angle: number; +}) { + const offset = radius * 1.28; + const noseHeight = height * 0.22; + return ( + + {/* 助推器箭体 */} + + + + + {/* 头锥 */} + + + + + {/* 涂装环 */} + + + + + {/* 与芯级的连接件 */} + + + + + {/* 助推器发动机 */} + + + + + ); +} + +/** 级间段:深色结构段 + 分离火箭。 */ +function Interstage({ radius, height }: { radius: number; height: number }) { + const motors = useMemo(() => [0, 1, 2, 3].map((index) => (index / 4) * Math.PI * 2 + Math.PI / 4), []); return ( - - + + - {/* Livery band near the top of the stage */} - - - + + + - - - - + {motors.map((angle) => ( + + + + + ))} ); } -/** 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; +/** 二级姿态控制推力器组(RCS)。 */ +function RcsCluster({ radius, y, angle }: { radius: number; y: number; angle: number }) { return ( - - {/* Interstage (dark) */} - - + + {[-0.35, 0, 0.35].map((offset) => ( + + + + + ))} + + - {/* Second-stage engine bell tucked under the interstage */} - - - - {/* Second-stage body */} - - - + + ); +} + +/** 二级:箭体 + 涂装 + RCS + 发动机。基座位于局部 y = 0。 */ +function SecondStageBody({ + radius, + height, + color, + engineCount, + label, +}: StageProps & { engineCount: number; label: string }) { + const bodyRadius = radius * 0.97; + const accent = shade(color, -0.5); + return ( + + + + - {/* Payload fairing / nose cone */} - - - + + + + + + + {[0, 1, 2, 3].map((index) => ( + + ))} + + + + + ); +} + +/** 整流罩头锥的卵形母线,比圆锥更接近真实整流罩。 */ +function ogivePoints(radius: number, height: number, segments = 14): THREE.Vector2[] { + return Array.from({ length: segments + 1 }, (_, index) => { + const t = index / segments; + const y = height * t; + const r = radius * Math.pow(1 - t, 0.62); + return new THREE.Vector2(Math.max(r, 0.0005), y); + }); +} + +/** + * 半瓣载荷整流罩:圆柱段 + 卵形头锥。 + * 两瓣用各自的起始角直接生成几何(不做整体旋转), + * 因此外层动画无论如何变换都不会再出现「只剩半片」的情况。 + */ +function FairingHalf({ + radius, + barrelHeight, + coneHeight, + color, + startAngle, +}: { + radius: number; + barrelHeight: number; + coneHeight: number; + color: string; + startAngle: number; +}) { + const profile = useMemo(() => ogivePoints(radius, coneHeight), [coneHeight, radius]); + + return ( + + + + + + + + + + {/* 对接框 */} + + + ); } +/** 载荷适配器:二级顶部与卫星之间的锥形对接结构。 */ +function PayloadAdapter({ radius, height }: { radius: number; height: number }) { + return ( + + + + + ); +} + +/** 载荷卫星:星体 + 通信天线 + 太阳翼(部署时展开)。 */ function PayloadSatellite({ radius, panelProgress }: { radius: number; panelProgress: number }) { const satelliteRef = useRef(null); - const easedProgress = 1 - Math.pow(1 - panelProgress, 3); + const eased = 1 - Math.pow(1 - panelProgress, 3); + const panelLength = radius * 3.4; + const cells = useMemo(() => Array.from({ length: 6 }, (_, index) => index), []); useFrame((_, delta) => { - if (satelliteRef.current) satelliteRef.current.rotation.y += delta * 0.18; + // 缓慢自转,并保持一个固定倾角,让太阳翼朝向镜头时更好辨认 + if (satelliteRef.current) satelliteRef.current.rotation.y += delta * 0.08; }); return ( - - - - + + {/* 星体(金色多层隔热材料) */} + + + - - - + + + - - - + {/* 抛物面天线 */} + + + + + + + + {/* 天线阵 */} {[-1, 1].map((direction) => ( - - - - - - - + + + + + ))} + {/* 太阳翼:每侧两块板,展开时沿转轴伸出 */} + {[-1, 1].map((direction) => ( + + + + {[0, 1].map((panelIndex) => ( + + + + + + {cells.map((cell) => ( + + + + + ))} + + ))} ))} @@ -220,8 +603,8 @@ interface RocketModelProps { } /** - * 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]); @@ -230,21 +613,33 @@ export function RocketModel({ rocket, state }: RocketModelProps) { 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 deployProgress = deployAge === null ? 0 : Math.min(1, deployAge / 2.6); + const fairingAge = deployAge; const firstStageRef = useRef(null); + const boosterRef = useRef(null); const upperStackRef = useRef(null); + const fairingRef = useRef(null); + const boosterEvent = state.events.find((event) => event.id === 'booster-sep'); + const boosterAge = boosterEvent ? Math.max(0, state.time - boosterEvent.time) : 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; + const stage2BaseY = geo.interstageHeight; + const fairingBaseY = geo.interstageHeight + geo.stage2Height; + const payloadY = fairingBaseY + geo.fairingBarrelHeight * 0.45; + const firstStageLabel = rocket.name_zh || rocket.name; + const secondStageLabel = rocket.stage_2.name; + // 整流罩是复合材料壳体:以白色为底,掺入少量箭体涂装色以便区分型号。 + const fairingColor = useMemo(() => { + const base = new THREE.Color(COMPOSITE); + return base.lerp(new THREE.Color(rocket.color), 0.16).getStyle(); + }, [rocket.color]); 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.rotation.set(sep.tumble * 0.55, sep.tumble * 0.28, sep.tumble); firstStageRef.current.visible = sep.opacity > 0.02; } else { firstStageRef.current.position.set(0, 0, 0); @@ -253,66 +648,164 @@ export function RocketModel({ rocket, state }: RocketModelProps) { } } + // 助推器:分离后向四周散开、翻滚,飞出画面后隐藏 + if (boosterRef.current) { + if (boosterAge === null) { + boosterRef.current.visible = true; + boosterRef.current.children.forEach((child) => { + child.position.set(0, 0, 0); + child.rotation.set(0, 0, 0); + child.visible = true; + }); + } else { + const spread = geo.radius * (boosterAge * 2.2); + const visible = boosterAge < 3.2; + boosterRef.current.visible = visible; + boosterRef.current.children.forEach((child, index) => { + const angle = (index / Math.max(1, boosterRef.current!.children.length)) * Math.PI * 2 + Math.PI / 4; + child.position.set( + Math.cos(angle) * spread, + -0.6 * boosterAge - 0.35 * boosterAge * boosterAge, + Math.sin(angle) * spread, + ); + child.rotation.set(boosterAge * 0.6, boosterAge * 0.25, boosterAge * (index % 2 === 0 ? 0.8 : -0.8)); + child.visible = visible; + }); + } + } + 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; + upperStackRef.current.position.set(-deployAge * 0.7, upperBaseY - deployAge * 0.8, deployAge * 0.16); + upperStackRef.current.rotation.set(deployAge * 0.14, deployAge * 0.07, -deployAge * 0.28); + upperStackRef.current.visible = deployAge < 5.4; } } + + // 整流罩两瓣:向外平移 + 翻转分离后淡出。 + if (fairingRef.current) { + const open = fairingAge === null ? 0 : Math.min(1, fairingAge / 2.2); + const opacity = Math.max(0, 1 - open * 1.05); + fairingRef.current.children.forEach((child, index) => { + const direction = index === 0 ? 1 : -1; + child.position.set(direction * open * geo.fairingRadius * 3.2, open * geo.fairingRadius * 0.55, 0); + child.rotation.set(0, 0, direction * open * 1.1); + child.visible = opacity > 0.02; + child.traverse((node) => { + const material = (node as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined; + if (material && 'opacity' in material) material.opacity = opacity; + }); + }); + fairingRef.current.visible = fairingAge === null || fairingAge < 2.6; + } }); return ( - {/* Upper stack: active through insertion, then discarded after deployment. */} + {/* 上面级:级间段 + 二级 + 整流罩 + 载荷 */} - - {/* Stage-2 plume: fires from the second-stage bell (above interstage) */} - + + + + + {/* 二级尾焰:自二级喷管出口向下喷出 */} + + {/* 载荷适配器 + 卫星(整流罩抛离后可见) */} + + + + + + {/* 每瓣外面再包一层,动画只作用于外层,避免覆盖半瓣自身的朝向 */} + + + + + + + + + {/* 载荷:部署后脱离上面级独立存在,上面级淡出后载荷仍然保留在场景中 */} {deployAge !== null && ( - + )} - {/* First stage: attached below the stack, or detached and falling */} + {/* 一级:未分离时位于上面级下方,分离后翻滚坠落 */} - {/* Stage-1 plume: fires from the engine cluster at the base (y≈0) */} + {/* 捆绑助推器:先于芯级分离,分离后向四周散开 */} + {geo.layout === 'boosters' && ( + + {Array.from({ length: geo.boosterCount }, (_, index) => ( + + + + ))} + + )} diff --git a/frontend/src/features/rocket-simulator/rocket-simulator.css b/frontend/src/features/rocket-simulator/rocket-simulator.css index d554531..09e52f0 100644 --- a/frontend/src/features/rocket-simulator/rocket-simulator.css +++ b/frontend/src/features/rocket-simulator/rocket-simulator.css @@ -1,21 +1,29 @@ +/* ================================================================== */ +/* COSMO LAUNCH — SpaceX webcast style mission dashboard */ +/* ================================================================== */ + .rocket-simulator-page { - --panel: rgba(10, 12, 13, 0.88); - --panel-solid: #0b0d0e; - --line: rgba(255, 255, 255, 0.16); + --panel: rgba(7, 9, 10, 0.82); + --panel-strong: rgba(5, 7, 8, 0.94); + --line: rgba(255, 255, 255, 0.13); --line-strong: rgba(255, 255, 255, 0.3); - --text: #f4f6f5; - --muted: #929998; - --green: #2ea66f; - --green-bright: #54d497; - --amber: #f2b84b; + --text: #f2f5f4; + --muted: #7c8583; + --dim: #565e5c; + --green: #35d07f; + --green-bright: #5eea9c; + --green-dim: #1e7d4f; + --amber: #f5c451; + --mono: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Roboto Mono', 'Menlo', Consolas, 'Courier New', monospace; 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; + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; letter-spacing: 0; + -webkit-font-smoothing: antialiased; } .rocket-simulator-page button, @@ -39,6 +47,68 @@ display: block; } +/* ---------- shared glass panel ---------- */ + +.sx-left, +.sx-right { + position: absolute; + z-index: 5; + top: 78px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.sx-left { left: 16px; width: 300px; } +.sx-right { right: 16px; width: 290px; } + +.sx-left, +.sx-right { + max-height: calc(100vh - 250px); + overflow-y: auto; + scrollbar-width: none; +} + +.sx-left::-webkit-scrollbar, +.sx-right::-webkit-scrollbar { + display: none; +} + +.sx-profile, +.sx-vehicle, +.sx-data { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 4px; + backdrop-filter: blur(10px); + overflow: hidden; +} + +.sx-panel-title { + height: 40px; + padding: 0 13px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.025); +} + +.sx-panel-title span { + color: #7f8886; + font-size: 9px; + font-weight: 750; + letter-spacing: 0.12em; +} + +.sx-panel-title b { + color: #cfd6d3; + font-size: 11px; + font-weight: 600; +} + +/* ---------- top bar ---------- */ + .sx-icon-btn { width: 34px; height: 34px; @@ -68,12 +138,12 @@ left: 0; right: 0; top: 0; - height: 64px; + height: 62px; display: flex; align-items: center; gap: 14px; - padding: 0 20px; - background: rgba(5, 7, 8, 0.92); + padding: 0 18px; + background: linear-gradient(180deg, rgba(3, 4, 5, 0.96), rgba(3, 4, 5, 0.82)); border-bottom: 1px solid var(--line); backdrop-filter: blur(12px); } @@ -82,11 +152,11 @@ display: flex; align-items: center; gap: 10px; - min-width: 210px; + min-width: 218px; } .sx-brand > svg { - color: var(--text); + color: var(--green-bright); } .sx-brand div { @@ -97,7 +167,8 @@ .sx-brand strong { font-size: 13px; - font-weight: 750; + font-weight: 800; + letter-spacing: 0.06em; line-height: 1.2; } @@ -113,12 +184,12 @@ .sx-mission-status { height: 38px; - min-width: 150px; + min-width: 152px; display: flex; align-items: center; gap: 9px; padding: 0 12px; - border-left: 2px solid #606766; + border-left: 2px solid #5b6361; background: rgba(255, 255, 255, 0.04); } @@ -132,7 +203,7 @@ .sx-mission-status > .sx-live { background: var(--green-bright); - box-shadow: 0 0 0 4px rgba(84, 212, 151, 0.12); + box-shadow: 0 0 0 4px rgba(94, 234, 156, 0.12); animation: sxPulse 1.4s ease-in-out infinite; } @@ -142,9 +213,10 @@ } .sx-mission-status small { - color: #777f7d; + color: #737c7a; font-size: 8px; font-weight: 700; + letter-spacing: 0.1em; } .sx-mission-status strong { @@ -174,9 +246,10 @@ .sx-select > span { flex: 0 0 auto; - color: #777f7d; + color: #737c7a; font-size: 8px; font-weight: 700; + letter-spacing: 0.08em; } .sx-select > div { @@ -226,6 +299,52 @@ overflow: hidden; } +/* 三段式倍速开关:与「运载器」同样的标签 + 等宽分段控件 */ +.sx-select.sx-select--speed { + width: auto; +} + +.sx-speed-switch { + display: grid !important; + grid-template-columns: repeat(3, minmax(38px, 1fr)); + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; + background: #111415; +} + +.sx-speed-switch button { + height: 30px; + min-width: 38px; + padding: 0 8px; + border: 0; + border-right: 1px solid var(--line); + color: #a9afad; + background: transparent; + cursor: pointer; + font-size: 11px; + font-weight: 700; + font-variant-numeric: tabular-nums; + transition: background 150ms, color 150ms; +} + +.sx-speed-switch button:last-child { + border-right: 0; +} + +.sx-speed-switch button:hover, +.sx-speed-switch button:focus-visible { + color: #fff; + background: #222627; + outline: none; +} + +.sx-speed-switch button.active { + color: #fff; + background: #1d5c3c; +} + .sx-seg button { height: 34px; min-width: 68px; @@ -244,9 +363,7 @@ transition: background 150ms, color 150ms; } -.sx-seg button:last-child { - border-right: 0; -} +.sx-seg button:last-child { border-right: 0; } .sx-seg button:hover, .sx-seg button:focus-visible { @@ -257,7 +374,7 @@ .sx-seg button.active { color: #fff; - background: #2a6048; + background: #1d5c3c; } .sx-camera-slider { @@ -288,127 +405,434 @@ text-align: center; } -.sx-environment { - position: absolute; - z-index: 5; - left: 20px; - top: 84px; - width: 190px; - padding: 11px 13px; +/* ---------- mission profile timeline ---------- */ + +.sx-profile-list { + padding: 7px 13px 9px; +} + +.sx-profile-row { + position: relative; + height: 23px; + display: grid; + grid-template-columns: 13px 62px minmax(0, 1fr) 62px; + align-items: center; + gap: 6px; +} + +.sx-profile-row i { + width: 7px; + height: 7px; + border: 1.5px solid #4a5250; + border-radius: 50%; + background: #141718; +} + +.sx-profile-row b { + color: #6d7573; + font-size: 9px; + font-weight: 750; + letter-spacing: 0.06em; + font-variant-numeric: tabular-nums; +} + +.sx-profile-row span { + overflow: hidden; + color: #565e5c; + font-size: 9.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sx-profile-row time { + color: #4c5452; + font-size: 9.5px; + font-variant-numeric: tabular-nums; + text-align: right; + font-family: var(--mono); +} + +.sx-profile-row.reached i { + border-color: #9be8c0; + background: var(--green); + box-shadow: 0 0 6px rgba(53, 208, 127, 0.55); +} + +.sx-profile-row.reached b { color: #c9f2dc; } +.sx-profile-row.reached span { color: #8fb6a3; } +.sx-profile-row.reached time { color: var(--green-bright); } + +.sx-profile-row.next i { + border-color: #ffe2a0; + background: var(--amber); + box-shadow: 0 0 6px rgba(245, 196, 81, 0.6); + animation: sxPulse 1s ease-in-out infinite; +} + +.sx-profile-row.next b { color: #f4d391; } +.sx-profile-row.next span { color: #e9d7ae; } +.sx-profile-row.next time { color: #d9c99a; } + +/* ---------- vehicle stack diagram ---------- */ + +.sx-vehicle-body { + display: flex; + align-items: stretch; + gap: 10px; + padding: 10px 13px 12px; +} + +.sx-stack { + flex: 0 0 120px; + width: 120px; + height: 240px; + overflow: visible; +} + +.sx-stack-axis { + stroke: rgba(255, 255, 255, 0.14); + stroke-width: 1; + stroke-dasharray: 2 4; +} + +.sx-stack-grid { + stroke: rgba(255, 255, 255, 0.22); + stroke-width: 1; +} + +.sx-stack-dim { + fill: #6d7573; + font-size: 7.5px; + font-family: var(--mono); + letter-spacing: 0.05em; +} + +.sx-stack-seam { + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.sx-stack-body { + stroke: rgba(255, 255, 255, 0.35); + stroke-width: 0.6; +} + +.sx-stack-s1, +.sx-stack-s2, +.sx-stack-fairing { + transform-box: fill-box; + transition: transform 0.85s cubic-bezier(0.45, 0, 0.2, 1), opacity 0.85s ease; +} + +.sx-stack--stage2 .sx-stack-s1, +.sx-stack--payload .sx-stack-s1 { + transform: translateY(58px) rotate(9deg); + opacity: 0; +} + +.sx-stack--payload .sx-stack-s2 { + transform: translateY(34px); + opacity: 0; +} + +.sx-fairing { + transform-box: fill-box; + transition: transform 0.8s cubic-bezier(0.45, 0, 0.2, 1), opacity 0.8s ease; +} + +.sx-fairing-left { transform-origin: bottom left; } +.sx-fairing-right { transform-origin: bottom right; } + +.sx-stack--payload .sx-fairing-left { + transform: rotate(-36deg) translateY(6px); + opacity: 0; +} + +.sx-stack--payload .sx-fairing-right { + transform: rotate(36deg) translateY(6px); + opacity: 0; +} + +.sx-stack-payload { + opacity: 0; + transform: translateY(7px); + transition: opacity 0.5s ease 0.3s, transform 0.7s ease 0.3s; +} + +.sx-stack--payload .sx-stack-payload { + opacity: 1; + transform: translateY(0); +} + +.sx-stack-panel { + transform-box: fill-box; + transform: scaleX(0); + transition: transform 0.95s cubic-bezier(0.2, 0.8, 0.2, 1) 0.55s; +} + +.sx-stack-panel.l { transform-origin: right center; } +.sx-stack-panel.r { transform-origin: left center; } + +.sx-stack--payload .sx-stack-panel { + transform: scaleX(1); +} + +.sx-vehicle-side { + min-width: 0; + flex: 1 1 auto; 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-stack-caption { + padding: 1px 0 8px; + border-bottom: 1px solid var(--line); + display: flex; + flex-direction: column; } -.sx-environment strong { - margin-top: 2px; - font-size: 13px; - font-weight: 650; -} - -.sx-environment small { - margin-top: 3px; - color: var(--muted); +.sx-stack-caption span { + color: var(--green-bright); font-size: 9px; + font-weight: 800; + letter-spacing: 0.14em; + font-family: var(--mono); } -.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-stack-caption strong { + margin-top: 2px; + font-size: 12px; + font-weight: 700; } -.sx-panel-title { - height: 44px; - margin: 0 -15px 8px; - padding: 0 15px; +.sx-stack-caption small { + margin-top: 2px; + color: var(--muted); + font-size: 8.5px; +} + +.sx-spec { + padding-top: 7px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.sx-spec-row { + min-height: 15px; + display: grid; + grid-template-columns: 46px minmax(0, 1fr) auto; + align-items: center; + gap: 4px; +} + +.sx-spec-row > span { + color: #6d7573; + font-size: 8px; + letter-spacing: 0.05em; +} + +.sx-spec-row > b { + overflow: hidden; + color: #d7dcd9; + font-size: 9px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sx-spec-row > .sx-spec-name { + color: #aeb6b3; + font-weight: 550; +} + +.sx-spec-row > em { + color: var(--green-bright); + font-size: 8.5px; + font-style: normal; + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +.sx-spec-divider { + height: 1px; + margin: 3px 0; + background: var(--line); +} + +/* ---------- flight data ---------- */ + +.sx-data { + padding: 0 13px 12px; +} + +.sx-data-grid { + padding: 6px 0 2px; +} + +.sx-data-row { + min-height: 30px; display: flex; align-items: center; justify-content: space-between; - border-bottom: 1px solid var(--line); + border-bottom: 1px dashed rgba(255, 255, 255, 0.07); } -.sx-panel-title span { - color: #7f8785; - font-size: 9px; - font-weight: 750; +.sx-data-row > span { + color: #6d7573; + font-size: 8.5px; + font-weight: 700; + letter-spacing: 0.09em; } -.sx-panel-title b { - font-size: 11px; - font-weight: 650; +.sx-data-row > div { + display: flex; + align-items: baseline; + gap: 4px; } -.sx-side-row { - min-height: 29px; +.sx-data-row strong { + color: var(--green-bright); + font-size: 16px; + font-weight: 500; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + line-height: 1; + text-shadow: 0 0 14px rgba(94, 234, 156, 0.35); +} + +.sx-data-row em { + color: #7d8583; + font-size: 8px; + font-style: normal; + font-weight: 600; + letter-spacing: 0.05em; +} + +.sx-data-row.hero { + min-height: 42px; +} + +.sx-data-row.hero strong { + font-size: 27px; + font-weight: 500; +} + +.sx-data-row.hero em { + font-size: 10px; +} + +/* ---------- telemetry chart ---------- */ + +.sx-chart-wrap { + margin-top: 8px; + padding: 8px 9px 9px; + border: 1px solid var(--line); + border-radius: 3px; + background: rgba(255, 255, 255, 0.02); +} + +.sx-chart-title { + margin-bottom: 6px; display: flex; align-items: baseline; justify-content: space-between; } -.sx-side-row span { - color: #939a98; - font-size: 10px; +.sx-chart-title span { + color: #6d7573; + font-size: 8px; + font-weight: 750; + letter-spacing: 0.1em; } -.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; +.sx-chart-title b { + color: #9aa3a0; font-size: 9px; - font-style: normal; - font-weight: 500; + font-weight: 600; } -.sx-side-divider { - height: 1px; - margin: 9px 0 12px; - background: var(--line); +.telemetry-chart { + position: relative; + width: 100%; + height: 56px; +} + +.telemetry-chart svg { + width: 100%; + height: 56px; + display: block; +} + +.telemetry-chart-empty { + height: 56px; + display: flex; + align-items: center; + justify-content: center; + color: #565e5c; + font-size: 9px; +} + +.chart-legend { + position: absolute; + z-index: 2; + top: 1px; + right: 1px; + display: flex; + align-items: center; + gap: 9px; + color: #7d8583; + font-size: 8px; +} + +.chart-legend span::before { + content: ''; + display: inline-block; + width: 8px; + height: 2px; + margin-right: 4px; + vertical-align: middle; +} + +.chart-legend .altitude-key::before { background: #5eead4; } +.chart-legend .velocity-key::before { background: #fbbf24; } + +/* ---------- fuel ---------- */ + +.sx-fuel { + margin-top: 10px; + padding-top: 9px; + border-top: 1px solid var(--line); } .sx-fuel-head { - margin-bottom: 7px; + margin-bottom: 6px; display: flex; align-items: baseline; justify-content: space-between; } .sx-fuel-head span { - color: #939a98; - font-size: 10px; + color: #6d7573; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; } -.sx-fuel-head strong { +.sx-fuel-head b { + color: var(--green-bright); font-size: 13px; - font-weight: 700; + font-family: var(--mono); + font-variant-numeric: tabular-nums; } .sx-fuel-track { height: 4px; overflow: hidden; border-radius: 2px; - background: #292d2e; + background: #232829; } .sx-fuel-track i { @@ -422,20 +846,23 @@ .sx-fuel-row { display: grid; - grid-template-columns: 25px minmax(0, 1fr) 30px; + grid-template-columns: 24px minmax(0, 1fr) 32px; align-items: center; gap: 6px; - min-height: 20px; + min-height: 19px; } .sx-fuel-row > span { - color: #8d9593; - font-size: 9px; + color: #7d8583; + font-size: 8.5px; + font-family: var(--mono); + font-weight: 700; } .sx-fuel-row > b { - color: #dde2df; + color: #d8ddda; font-size: 9px; + font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; } @@ -446,197 +873,241 @@ display: flex; justify-content: space-between; border-top: 1px solid var(--line); - color: #8d9593; + color: #6d7573; font-size: 9px; } .sx-fuel-total-row b { color: #dfe4e1; + font-family: var(--mono); font-variant-numeric: tabular-nums; } +/* ---------- bottom mission bar ---------- */ + .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; + padding: 10px 18px 13px; display: flex; - justify-content: center; + align-items: center; + gap: 22px; + background: linear-gradient(0deg, rgba(3, 4, 5, 0.97) 55%, rgba(3, 4, 5, 0.72) 82%, transparent); } -.sx-node { - position: relative; - min-width: 88px; - padding-top: 14px; +.sx-bottom-left { + flex: 0 0 300px; display: flex; flex-direction: column; - align-items: center; + gap: 8px; } -.sx-node::before { - content: ""; - position: absolute; - top: 5px; - left: 50%; - width: 100%; - height: 1px; - background: rgba(255, 255, 255, 0.16); +.sx-env { + padding: 8px 11px; + display: flex; + flex-direction: column; + border-left: 2px solid var(--amber); + background: rgba(255, 255, 255, 0.03); } -.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-env-code { + color: var(--amber); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.14em; + font-family: var(--mono); } -.sx-node b { - color: #737a78; - font-size: 10px; - font-weight: 750; -} - -.sx-node time { +.sx-env strong { 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-size: 12.5px; font-weight: 650; } +.sx-env small { + margin-top: 2px; + color: var(--muted); + font-size: 9px; +} + +.sx-next-event { + padding: 8px 11px; + display: flex; + align-items: baseline; + gap: 9px; + border-left: 2px solid var(--green); + background: rgba(255, 255, 255, 0.03); +} + +.sx-next-event > span { + color: #6d7573; + font-size: 8px; + font-weight: 750; + letter-spacing: 0.1em; +} + +.sx-next-event > b { + color: var(--green-bright); + font-size: 13px; + font-family: var(--mono); + letter-spacing: 0.06em; +} + +.sx-next-event > small { + overflow: hidden; + color: #9aa3a0; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sx-bottom-center { + flex: 1 1 auto; + min-width: 240px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.sx-clock { + font-size: 44px; + font-weight: 500; + line-height: 1; + color: var(--green-bright); + font-family: var(--mono); + font-variant-numeric: tabular-nums; + text-shadow: 0 0 24px rgba(94, 234, 156, 0.4); + letter-spacing: 0.02em; +} + +.sx-realtime { + color: #8e9593; + display: flex; + align-items: center; + gap: 5px; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.14em; +} + +.sx-progress { + margin-top: 5px; + width: 100%; + max-width: 420px; + display: flex; + align-items: center; + gap: 9px; +} + +.sx-progress-track { + flex: 1 1 auto; + height: 3px; + overflow: hidden; + border-radius: 2px; + background: #232829; +} + +.sx-progress-track i { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--green-dim), var(--green-bright)); + box-shadow: 0 0 8px rgba(53, 208, 127, 0.7); + transition: width 300ms linear; +} + +.sx-progress > span { + flex: 0 0 34px; + color: var(--green-bright); + font-size: 10px; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.sx-bottom-right { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 18px; +} + +.sx-controls { + display: flex; + align-items: center; + gap: 7px; +} + +.sx-launch { + width: 124px; + height: 36px; + padding: 0 14px; + border: 1px solid #3fae79; + border-radius: 4px; + color: #fff; + background: #1e7d4f; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + cursor: pointer; + font-size: 11px; + font-weight: 750; + letter-spacing: 0.04em; + transition: border-color 150ms, background 150ms; +} + +.sx-launch:hover, +.sx-launch:focus-visible { + border-color: #67d59f; + background: #279461; + outline: none; +} + +/* ---------- engine gauge ---------- */ + .sx-engine-gauge { position: relative; - width: 124px; - height: 92px; - flex: 0 0 124px; + width: 108px; + height: 84px; + flex: 0 0 108px; text-align: center; } .sx-engine-ring { - --engine-radius: 23px; + --engine-radius: 20px; position: relative; - width: 70px; - height: 70px; + width: 62px; + height: 62px; margin: 0 auto; - border: 3px solid rgba(235, 240, 238, 0.8); + border: 2.5px solid rgba(235, 240, 238, 0.78); border-radius: 50%; - box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.06); } .sx-engine-ring i { position: absolute; top: 50%; left: 50%; - width: 9px; - height: 9px; - border: 1px solid #747c79; + width: 8px; + height: 8px; + border: 1px solid #6a726f; border-radius: 50%; - background: #262b29; + background: #232827; transition: background 180ms, border-color 180ms, box-shadow 180ms; } -.sx-engine-center { - transform: translate(-50%, -50%); +/* 发动机点位:按 3D 模型同一套布局给出的归一化坐标定位 */ +.sx-engine-dot { + transform: translate(-50%, -50%) + translate(calc(var(--dx) * var(--engine-radius)), calc(var(--dy) * var(--engine-radius))); } +.sx-engine-center { transform: translate(-50%, -50%); } + .sx-engine-outer { transform: translate(-50%, -50%) rotate(var(--engine-angle)) translateY(calc(var(--engine-radius) * -1)); } @@ -644,29 +1115,32 @@ .sx-engine-ring i.active { border-color: #ffe2a4; background: var(--amber); - box-shadow: 0 0 6px rgba(242, 184, 75, 0.7); + box-shadow: 0 0 6px rgba(245, 196, 81, 0.75); } .sx-engine-gauge > span { display: block; - margin-top: 2px; + margin-top: 3px; color: #858d8a; font-size: 7px; font-weight: 750; + letter-spacing: 0.08em; } +/* ---------- attitude gauge ---------- */ + .sx-attitude-gauge { position: relative; - width: 124px; - height: 92px; - flex: 0 0 124px; + width: 108px; + height: 84px; + flex: 0 0 108px; text-align: center; } .sx-attitude-dial { position: relative; - width: 70px; - height: 70px; + width: 62px; + height: 62px; margin: 0 auto; overflow: hidden; border: 2px solid rgba(235, 240, 238, 0.72); @@ -683,14 +1157,14 @@ .sx-attitude-axis.horizontal { top: 50%; - left: 7px; - right: 7px; + left: 6px; + right: 6px; height: 1px; } .sx-attitude-axis.vertical { - top: 7px; - bottom: 7px; + top: 6px; + bottom: 6px; left: 50%; width: 1px; } @@ -700,88 +1174,37 @@ z-index: 2; top: 50%; left: 50%; - width: 26px; - height: 26px; + width: 24px; + height: 24px; color: #f4f7f5; - filter: drop-shadow(0 0 5px rgba(242, 184, 75, 0.55)); + filter: drop-shadow(0 0 5px rgba(245, 196, 81, 0.55)); transform-origin: center; transition: transform 120ms linear; } +.sx-attitude-vehicle svg { width: 24px; height: 24px; } + .sx-attitude-gauge > span { display: block; - margin-top: 2px; + margin-top: 3px; color: #858d8a; font-size: 7px; font-weight: 750; + letter-spacing: 0.08em; } .sx-attitude-gauge > strong { position: absolute; - right: 13px; - bottom: 14px; + right: 10px; + bottom: 13px; color: #dfe4e1; font-size: 9px; font-weight: 650; + font-family: var(--mono); 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; -} +/* ---------- loading / error ---------- */ .rocket-page-message { width: 100vw; @@ -796,7 +1219,7 @@ font: 14px Inter, system-ui, sans-serif; } -.rocket-page-message svg { color: var(--amber, #f2b84b); } +.rocket-page-message svg { color: var(--amber, #f5c451); } .rocket-page-message a { color: #62ce98; } .loading-rocket { animation: loadingRocket 1.2s ease-in-out infinite alternate; } @@ -804,35 +1227,45 @@ to { transform: translateY(-12px); } } +/* ---------- responsive ---------- */ + +@media (max-width: 1200px) { + .sx-left { width: 264px; } + .sx-right { width: 258px; } + .sx-data-row.hero strong { font-size: 23px; } + .sx-bottom { gap: 14px; } + .sx-bottom-left { flex-basis: 260px; } + .sx-clock { font-size: 38px; } +} + @media (max-width: 1050px) { - .sx-brand { min-width: 160px; } - .sx-brand span { max-width: 145px; } + .sx-brand { min-width: 170px; } + .sx-brand span { max-width: 150px; } .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; } + .sx-select:not(.sx-select--speed) { width: 240px; } + .sx-left { display: none; } + .sx-bottom-right .sx-engine-gauge { display: none; } } @media (max-width: 760px) { .sx-topbar { - height: 56px; + height: 54px; gap: 9px; padding: 0 12px; } - .sx-brand { - min-width: 24px; - } - + .sx-brand { min-width: 24px; } .sx-brand div, - .sx-select, + .sx-select:not(.sx-select--speed), .sx-camera-slider, - .sx-side { + .sx-left, + .sx-right { display: none; } + /* 窄屏仍保留倍速开关,仅隐藏它的文字标签 */ + .sx-select--speed > span { display: none; } + .sx-mission-status { min-width: 116px; height: 34px; @@ -853,73 +1286,52 @@ .sx-seg button svg { width: 15px; height: 15px; } - .sx-environment { - top: 70px; - left: 12px; - width: 154px; - padding: 9px 11px; + .sx-bottom { + padding: 8px 12px 10px; + gap: 10px; } - .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-bottom-left { + flex: 0 0 150px; } - .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-env { padding: 6px 9px; } + .sx-env strong { font-size: 10.5px; } + .sx-env small, + .sx-next-event > span, + .sx-next-event > small { + display: none; } - .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-next-event { padding: 6px 9px; } - .sx-clock { width: 126px; gap: 4px; } - .sx-clock-time { font-size: 21px; } - .sx-realtime { font-size: 7px; } - .sx-launch { width: 98px; padding: 0 9px; } + .sx-bottom-center { min-width: 0; } + + .sx-clock { font-size: 26px; } + .sx-realtime { font-size: 7px; letter-spacing: 0.08em; } + .sx-progress { max-width: 220px; } + + .sx-bottom-right { gap: 9px; } + .sx-attitude-gauge { display: none; } + .sx-launch { width: 96px; height: 32px; padding: 0 8px; font-size: 10px; } } @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; } + .sx-bottom-left { flex-basis: 118px; } + .sx-clock { font-size: 21px; } + .sx-launch { width: 84px; 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; } +@media (max-height: 720px) { + .sx-left, + .sx-right { top: 68px; } + .sx-profile-list { padding-top: 4px; padding-bottom: 6px; } + .sx-profile-row { height: 20px; } + .sx-vehicle-body { padding-top: 7px; padding-bottom: 8px; } + .sx-data { padding-bottom: 9px; } + .sx-data-row { min-height: 26px; } + .sx-data-row.hero { min-height: 34px; } + .sx-chart-wrap { margin-top: 5px; } + .sx-fuel { margin-top: 7px; } + .sx-bottom { padding-top: 7px; padding-bottom: 9px; } } diff --git a/frontend/src/features/rocket-simulator/sceneMath.ts b/frontend/src/features/rocket-simulator/sceneMath.ts index 7562573..80416b2 100644 --- a/frontend/src/features/rocket-simulator/sceneMath.ts +++ b/frontend/src/features/rocket-simulator/sceneMath.ts @@ -7,44 +7,212 @@ import type { RocketConfig, SimulationState } from './types'; */ export const VEHICLE_HEIGHT = 12; +/** 火箭布局:单芯级(猎鹰 9 一类)或带 4 个助推器(长征五号一类)。 */ +export type VehicleLayout = 'single-core' | 'boosters'; + export interface StageGeometry { totalHeight: number; radius: number; + fairingRadius: number; stage1Height: number; stage2Height: number; interstageHeight: number; - noseHeight: number; + /** 整流罩总高度(圆柱段 + 头锥段) */ + fairingHeight: number; + fairingBarrelHeight: number; + fairingConeHeight: number; + stage1Engines: number; + stage2Engines: number; + layout: VehicleLayout; + /** 助推器数量(单芯级为 0) */ + boosterCount: number; + /** 每个助推器的发动机数量 */ + enginesPerBooster: number; + /** 芯级发动机数量 */ + coreEngines: number; + /** 是否为细长多机回收构型(用于决定栅格翼等特征件) */ + slenderCore: boolean; } /** - * 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. + * 由一级发动机数量推断布局:真实的两级半火箭把「芯级 + 4 个助推器」的 + * 发动机数量合并登记在一级上(长征五号 10 = 芯级 2 + 4×2 助推器), + * 单芯级火箭则为「芯级中心机 + 外圈机」(猎鹰 9 = 1 + 8)。 */ -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; +export function resolveVehicleLayout(stage1Engines: number, slenderness: number): { + layout: VehicleLayout; + boosterCount: number; + enginesPerBooster: number; + coreEngines: number; + slenderCore: boolean; +} { + const boosterCount = 4; + const remainder = stage1Engines - 2; + const supportsBoosters = stage1Engines > 9 && remainder % boosterCount === 0; - 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, - ); + if (supportsBoosters) { + return { + layout: 'boosters', + boosterCount, + enginesPerBooster: remainder / boosterCount, + coreEngines: 2, + slenderCore: false, + }; + } return { - totalHeight: VEHICLE_HEIGHT, + layout: 'single-core', + boosterCount: 0, + enginesPerBooster: 0, + coreEngines: stage1Engines, + // 细长且多发动机的单芯级(猎鹰 9)才会带栅格翼这类回收构型特征件 + slenderCore: stage1Engines >= 8 && slenderness >= 15, + }; +} + +/** 便捷入口:直接由火箭配置判断布局(供物理模拟与仪表盘复用)。 */ +export function layoutForRocket(rocket: RocketConfig) { + const heightM = Math.max(1, rocket.height_m); + const diameterM = Math.max(0.5, rocket.diameter_m); + return resolveVehicleLayout( + Math.max(1, Math.round(rocket.stage_1.engine_count)), + clamp(heightM / diameterM, 4, 26), + ); +} + +/** + * 由火箭配置推导可绘制的比例。 + * + * 全部尺寸都来自数据库里的真实参数,保证 3D 模型与火箭资料一致: + * - 长细比(height_m / diameter_m)决定箭体半径 + * - 整流罩直径按真实运载火箭经验值(约 1.4 倍芯级直径,上限 5.4m)推导, + * 因此细长的猎鹰 9 会得到明显的「大整流罩」,而 5m 级的长征五号近乎等径 + * - 整流罩长度约为其直径的 2.5 倍,与真实整流罩(约 12~13m)吻合 + * - length_ratio 决定一级 / 二级在箭体中的占比 + */ +export function stageGeometry(rocket: RocketConfig): StageGeometry { + const heightM = Math.max(1, rocket.height_m); + const diameterM = Math.max(0.5, rocket.diameter_m); + + const slenderness = clamp(heightM / diameterM, 4, 26); + // 场景里的绝对尺寸也跟随真实高度(70m 的猎鹰 9 会比 57m 的长征五号更高), + // 因此两台火箭在同一镜头下的高矮与粗细差别和真实资料一致。 + const totalHeight = clamp(VEHICLE_HEIGHT * (heightM / 62), 10, 15); + const radius = totalHeight / slenderness / 2; + + // 整流罩:约 1.4 倍芯级直径,最大 5.4m(与 5.2m 级真实整流罩一致) + const fairingDiameterM = clamp(diameterM * 1.4, 2.6, 5.4); + const fairingRadius = radius * (fairingDiameterM / diameterM); + const fairingHeight = clamp( + totalHeight * (fairingDiameterM * 2.5) / heightM, + totalHeight * 0.13, + totalHeight * 0.27, + ); + const fairingConeHeight = fairingHeight * 0.62; + const fairingBarrelHeight = fairingHeight - fairingConeHeight; + + // 级间段:真实长度约 3~5m + const interstageHeight = clamp( + totalHeight * (4.6 / heightM), + totalHeight * 0.035, + totalHeight * 0.085, + ); + + const stackHeight = totalHeight - fairingHeight - interstageHeight; + const ratio1 = clamp(rocket.stage_1.length_ratio, 0.2, 0.9); + const ratio2 = clamp(rocket.stage_2.length_ratio, 0.1, 0.8); + const ratioSum = ratio1 + ratio2; + const stage1Height = stackHeight * (ratio1 / ratioSum); + const stage2Height = stackHeight * (ratio2 / ratioSum); + + const stage1Engines = Math.max(1, Math.round(rocket.stage_1.engine_count)); + + return { + totalHeight, radius, + fairingRadius, stage1Height, stage2Height, interstageHeight, - noseHeight, + fairingHeight, + fairingBarrelHeight, + fairingConeHeight, + stage1Engines, + stage2Engines: Math.max(1, Math.round(rocket.stage_2.engine_count)), + ...resolveVehicleLayout(stage1Engines, slenderness), }; } +export interface EnginePlacement { + /** 相对发动机舱中心的横向偏移(世界单位) */ + x: number; + z: number; + /** 喷管出口半径 */ + bellRadius: number; + /** 喷管长度 */ + bellLength: number; + /** 外圈发动机略向外倾斜,符合真实矢量布局 */ + cant: number; +} + +/** + * 按真实发动机数量排布喷管: + * 1 台居中;2~4 台对称;5~9 台为「1 台中心 + 外圈」; + * 10 台及以上为「2 台中心 + 外圈」(长征五号 10 台 = 2 台芯级 + 8 台助推)。 + */ +export function engineLayout(count: number, radius: number): EnginePlacement[] { + const total = Math.max(1, Math.round(count)); + const ringRadius = radius * 0.7; + const make = (x: number, z: number, bellRadius: number, cant = 0): EnginePlacement => ({ + x, + z, + bellRadius, + bellLength: bellRadius * 2.1, + cant, + }); + + if (total === 1) return [make(0, 0, radius * 0.52)]; + + if (total === 2) { + return [ + make(-radius * 0.42, 0, radius * 0.42), + make(radius * 0.42, 0, radius * 0.42), + ]; + } + + if (total <= 4) { + return Array.from({ length: total }, (_, index) => { + const angle = (index / total) * Math.PI * 2 - Math.PI / 2; + return make(Math.cos(angle) * ringRadius * 0.78, Math.sin(angle) * ringRadius * 0.78, radius * 0.3, 0.08); + }); + } + + const coreCount = total > 9 ? 2 : 1; + const outerCount = total - coreCount; + const outerBell = Math.min(radius * 0.3, (2 * Math.PI * ringRadius) / (outerCount * 2.45)); + const coreBell = Math.min(radius * 0.34, outerBell * 1.12); + + const placements: EnginePlacement[] = []; + if (coreCount === 1) { + placements.push(make(0, 0, coreBell)); + } else { + placements.push(make(-radius * 0.26, 0, coreBell)); + placements.push(make(radius * 0.26, 0, coreBell)); + } + + for (let index = 0; index < outerCount; index += 1) { + const angle = (index / outerCount) * Math.PI * 2 + Math.PI / outerCount; + placements.push(make( + Math.cos(angle) * ringRadius, + Math.sin(angle) * ringRadius, + outerBell, + 0.09, + )); + } + + return placements; +} + /** Clamp helper. */ export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); diff --git a/frontend/src/features/rocket-simulator/useRocketSimulation.ts b/frontend/src/features/rocket-simulator/useRocketSimulation.ts index a47128b..cba847d 100644 --- a/frontend/src/features/rocket-simulator/useRocketSimulation.ts +++ b/frontend/src/features/rocket-simulator/useRocketSimulation.ts @@ -1,10 +1,65 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { FlightEvent, FlightPhase, RocketConfig, SimulationState } from './types'; +import { clamp, layoutForRocket } from './sceneMath'; const G0 = 9.80665; const EARTH_RADIUS = 6_371_000; const SEA_LEVEL_DENSITY = 1.225; const SCALE_HEIGHT = 8_500; +/** 名义一级 / 二级工作时间(秒),用于把俯仰程序按级次进度归一化。 */ +const NOMINAL_STAGE1_BURN_S = 150; +const NOMINAL_STAGE2_BURN_S = 380; +/** 捆绑助推器在一级推进剂消耗到这个比例时分离。 */ +const BOOSTER_SEPARATION_FUEL_RATIO = 0.45; + +/** + * 上升制导(简易显式制导)。 + * + * 低空垂直起飞,之后按级次进度把俯仰角从 90° 逐渐压到接近水平; + * 同时跟踪一条随高度衰减的目标垂直速度曲线,垂速偏低就抬头、偏高就压平, + * 并在入轨前不允许掉高度。这样火箭能真正达到配置里的目标轨道高度与目标速度。 + */ +function guidancePitch({ + altitude, + verticalVelocity, + secondsInStage, + stage, + pitch, + dt, + targetAltitude, +}: { + altitude: number; + verticalVelocity: number; + secondsInStage: number; + stage: 1 | 2; + pitch: number; + dt: number; + targetAltitude: number; +}): number { + const progress = Math.min(1, Math.max(0, secondsInStage / (stage === 1 ? NOMINAL_STAGE1_BURN_S : NOMINAL_STAGE2_BURN_S))); + + let target: number; + if (stage === 1) { + target = altitude < 900 ? 90 : 90 - 58 * Math.pow(progress, 0.8); + } else { + target = Math.max(4, 30 - 26 * Math.pow(progress, 0.9)); + } + + // 目标垂直速度随高度衰减到 0;跟不上就抬头,超了就压平。 + const targetVertical = Math.max(0, 780 * Math.pow(Math.max(0, 1 - altitude / (targetAltitude * 1.2)), 1.5)); + const error = targetVertical - verticalVelocity; + if (error > 30) target = Math.min(90, target + Math.min(24, (error - 30) * 0.02)); + else if (error < -80) target = Math.max(0, target - Math.min(12, (-error - 80) * 0.015)); + + // 入轨前保持正垂速,避免在中途掉高度。 + if (stage === 2 && altitude < targetAltitude * 0.985 && verticalVelocity < 30) { + target = Math.max(target, 34); + } + + // 限制俯仰角变化速率,姿态过渡更平滑。 + const maxRate = 1.2 * dt; + return clamp(pitch + clamp(target - pitch, -maxRate, maxRate), 0, 90); +} function initialState(rocket: RocketConfig): SimulationState { return { @@ -35,11 +90,17 @@ function addEvent(events: FlightEvent[], id: string, label: string, time: number export function useRocketSimulation(rocket: RocketConfig) { const [state, setState] = useState(() => initialState(rocket)); + const [speed, setSpeed] = useState(1); const stateRef = useRef(state); const rocketRef = useRef(rocket); const frameRef = useRef(0); const lastFrameRef = useRef(0); const maxQRef = useRef(0); + const speedRef = useRef(speed); + + useEffect(() => { + speedRef.current = speed; + }, [speed]); const commit = useCallback((next: SimulationState) => { stateRef.current = next; @@ -91,12 +152,14 @@ export function useRocketSimulation(rocket: RocketConfig) { return; } - const elapsed = Math.min((timestamp - lastFrameRef.current) / 1000, 0.25); + // 倍速播放:先限制单帧真实步长,再乘以倍速,避免切页回来时一次跳太多。 + const elapsed = Math.min((timestamp - lastFrameRef.current) / 1000, 0.25) * speedRef.current; 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; + const hasBoosters = layoutForRocket(config).layout === 'boosters'; for (let index = 0; index < steps; index += 1) { const phaseElapsed = next.time - next.phaseStartedAt; @@ -157,11 +220,26 @@ export function useRocketSimulation(rocket: RocketConfig) { thrust = 0; } + // 捆绑助推器(长征五号一类)在一级飞行中先于芯级分离 + if ( + hasBoosters && + next.phase === 'stage1_burn' && + next.stage1Fuel <= config.stage_1.fuel_mass_kg * BOOSTER_SEPARATION_FUEL_RATIO + ) { + next.events = addEvent(next.events, 'booster-sep', '助推器分离', next.time); + } + 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 stageElapsed = next.time - next.phaseStartedAt; + next.pitch = guidancePitch({ + altitude: next.altitude, + verticalVelocity: next.verticalVelocity, + secondsInStage: stageElapsed, + stage: stage1Attached ? 1 : 2, + pitch: next.pitch, + dt, + targetAltitude, + }); const velocity = Math.hypot(next.horizontalVelocity, next.verticalVelocity); const density = next.altitude > 120_000 @@ -187,8 +265,11 @@ export function useRocketSimulation(rocket: RocketConfig) { 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 centrifugal = next.horizontalVelocity * next.horizontalVelocity / (EARTH_RADIUS + next.altitude); const ax = (thrust * Math.cos(pitchRadians) - dragX) / totalMass; - const ay = (thrust * Math.sin(pitchRadians) - dragY) / totalMass - gravity; + const ay = (thrust * Math.sin(pitchRadians) - dragY) / totalMass - gravity + centrifugal; next.horizontalVelocity = Math.max(0, next.horizontalVelocity + ax * dt); next.verticalVelocity += ay * dt; @@ -198,9 +279,15 @@ export function useRocketSimulation(rocket: RocketConfig) { next.acceleration = Math.hypot(ax, ay); next.time += dt; + // 只在二级动力飞行阶段判定入轨:否则入轨后条件持续成立, + // 会不断重置阶段计时,导致任务永远停在「轨道注入」。 + const poweredSecondStage = next.phase === 'stage2_burn' || next.phase === 'stage2_ignition'; if ( - (next.phase === 'stage2_burn' && next.stage2Fuel <= 0) || - (next.altitude >= targetAltitude && next.horizontalVelocity >= config.target_velocity_mps) + poweredSecondStage && + ( + next.stage2Fuel <= 0 || + (next.altitude >= targetAltitude && next.horizontalVelocity >= config.target_velocity_mps) + ) ) { next.phase = 'orbit'; next.phaseStartedAt = next.time; @@ -226,5 +313,5 @@ export function useRocketSimulation(rocket: RocketConfig) { return () => cancelAnimationFrame(frameRef.current); }, [commit]); - return { state, toggle, reset, setThrottle }; + return { state, toggle, reset, setThrottle, speed, setSpeed }; } diff --git a/frontend/src/pages/RocketSimulator.tsx b/frontend/src/pages/RocketSimulator.tsx index 9e8efb6..0145c51 100644 --- a/frontend/src/pages/RocketSimulator.tsx +++ b/frontend/src/pages/RocketSimulator.tsx @@ -1,21 +1,24 @@ -import { useEffect, useState, type CSSProperties } from 'react'; +import { useEffect, useMemo, 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 { PHASE_LABELS, type FlightEvent, 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 { TelemetryChart } from '../features/rocket-simulator/TelemetryChart'; +import { engineLayout, layoutForRocket, stageGeometry } from '../features/rocket-simulator/sceneMath'; 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 + * mission profile timeline 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: 'booster-sep', code: 'BS', label: '助推器分离' }, { id: 'meco', code: 'MECO', label: '一级关机' }, { id: 'separation', code: 'SEP', label: '级间分离' }, { id: 'ses1', code: 'SES-1', label: '二级点火' }, @@ -24,6 +27,12 @@ const MILESTONES: Array<{ id: string; code: string; label: string }> = [ { id: 'deploy', code: 'PAYLOAD', label: '载荷部署' }, ]; +/** 只有捆绑助推器的火箭(长征五号一类)才显示助推器分离节点。 */ +function milestonesFor(rocket: RocketConfig) { + const hasBoosters = layoutForRocket(rocket).layout === 'boosters'; + return hasBoosters ? MILESTONES : MILESTONES.filter((item) => item.id !== 'booster-sep'); +} + function formatTime(seconds: number) { const sign = seconds < 0 ? '-' : '+'; const abs = Math.abs(Math.floor(seconds)); @@ -43,35 +52,306 @@ function environmentAt(altitude: number, targetOrbitKm: number) { 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; +/* ------------------------------------------------------------------ */ +/* Vehicle stack: the dashboard shows exactly what the 3D scene shows — */ +/* full rocket → second stage after separation → payload at deploy. */ +/* ------------------------------------------------------------------ */ + +type StackMode = 'full' | 'stage2' | 'payload'; + +function stackModeFor(phase: FlightPhase): StackMode { + if (phase === 'ready' || phase === 'stage1_burn' || phase === 'stage1_cutoff') return 'full'; + if (phase === 'payload_deploy' || phase === 'mission_complete') return 'payload'; + return 'stage2'; +} + +const STACK_MODE_LABEL: Record = { + full: { code: 'FULL STACK', zh: '全箭组合体', detail: 'S1 + S2 + PAYLOAD · 待命' }, + stage2: { code: 'STAGE 2', zh: '二级飞行', detail: 'S2 + PAYLOAD · 一级已分离' }, + payload: { code: 'PAYLOAD', zh: '载荷部署', detail: '载荷分离 · 太阳翼展开' }, +}; + +/** + * Vertical schematic of the launch vehicle. Height proportions come from the + * real config (stageGeometry) — 箭体粗细、整流罩形状与发动机数量都与 3D 场景 + * 使用同一套数据推导,因此示意图与真实火箭信息、三维模型三者始终一致。 + * 图形状态由模式类(full / stage2 / payload)驱动,CSS 负责分离动画。 + */ +function StackDiagram({ rocket }: { rocket: RocketConfig }) { + const geo = useMemo(() => stageGeometry(rocket), [rocket]); + const TOP = 16; + const BOTTOM = 212; + const CX = 58; + const scale = (BOTTOM - TOP) / geo.totalHeight; + + const r1 = Math.max(8, geo.radius * scale); + const r2 = r1 * 0.97; + const rf = Math.max(r1 + 1.5, geo.fairingRadius * scale); + const fairingH = geo.fairingHeight * scale; + const coneH = geo.fairingConeHeight * scale; + const s2H = geo.stage2Height * scale; + const interH = Math.max(geo.interstageHeight * scale, 4); + const s1H = geo.stage1Height * scale; + + const fairingBottom = TOP + fairingH; + const coneTop = TOP; + const barrelTop = TOP + coneH; + const s2Top = fairingBottom; + const interTop = s2Top + s2H; + const s1Top = interTop + interH; + const engineY = s1Top + s1H; + const padY = engineY + 14; + const metal = '#aeb7bd'; + + /** 侧视图中的发动机:把发动机舱布局投影到水平方向,按位置去重后绘制。 */ + const engineBells = (count: number, radius: number, baseY: number) => { + const drawn: Array<{ x: number; width: number }> = []; + engineLayout(count, radius).forEach((placement) => { + const x = CX + placement.x * scale; + if (drawn.some((item) => Math.abs(item.x - x) < placement.bellRadius * scale * 0.9)) return; + drawn.push({ x, width: Math.max(2.6, placement.bellRadius * 2 * scale) }); + }); + return drawn.map((bell, index) => ( + + )); + }; + return ( -
- - {label} - {value} - {unit} + + {/* measurement rail */} + + + {rocket.height_m.toFixed(1)} M + Ø {rocket.diameter_m.toFixed(1)} M + + + + + {/* 助推器(长征五号一类):画在芯级两侧,先于芯级绘制 */} + {geo.layout === 'boosters' && + [-1, 1].map((direction) => { + const boosterH = s1H * 0.86; + const boosterTop = s1Top + s1H * 0.1; + const bx = CX + direction * r1 * 1.85; + const br = r1 * 0.52; + return ( + + + + + {engineBells(geo.enginesPerBooster, geo.radius * 0.56, boosterTop + boosterH)} + + ); + })} + + {/* Stage 1 — drops away after separation */} + + + + + + + {/* 栅格翼(细长单芯级回收构型) */} + {geo.slenderCore && [-1, 1].map((direction) => ( + + ))} + {engineBells(geo.layout === 'boosters' ? geo.coreEngines : geo.stage1Engines, geo.radius, engineY + 5)} + + + {/* Upper stack (interstage + S2) */} + + + + + + {engineBells(geo.stage2Engines, geo.radius * 0.97, s2Top + s2H + 3.4)} + + + {/* Payload fairing — 两瓣卵形整流罩,部署载荷时分离 */} + + + + + + + + {/* Payload satellite — revealed at deploy */} + + + + + + + + + + + + ); +} + +function VehiclePanel({ rocket, mode, stage1FuelPct, stage2FuelPct }: { rocket: RocketConfig; mode: StackMode; stage1FuelPct: number; stage2FuelPct: number }) { + const meta = STACK_MODE_LABEL[mode]; + return ( +
+
VEHICLE运载器状态
+
+ +
+
+ {meta.code} + {meta.zh} + {meta.detail} +
+
+
一级{rocket.stage_1.name}{stage1FuelPct.toFixed(0)}%
+
二级{rocket.stage_2.name}{stage2FuelPct.toFixed(0)}%
+
+
箭体尺寸{rocket.height_m.toFixed(1)} × Ø{rocket.diameter_m.toFixed(1)} m
+
发动机{rocket.stage_1.engine_count} + {rocket.stage_2.engine_count} 台
+
一级推力{(rocket.stage_1.max_thrust_n / 1e6).toFixed(2)} MN
+
二级推力{(rocket.stage_2.max_thrust_n / 1e6).toFixed(2)} MN
+
载荷{(rocket.payload_mass_kg / 1000).toFixed(1)} t
+
目标轨道{rocket.target_orbit_km} km
+ {rocket.manufacturer ? ( +
制造方{rocket.manufacturer}
+ ) : null} +
+
+
); } -function EngineGauge({ stage, total, active }: { stage: 1 | 2; total: number; active: number }) { - const outerCount = Math.max(0, total - 1); +/* ------------------------------------------------------------------ */ +/* Mission profile timeline (SpaceX webcast style) */ +/* ------------------------------------------------------------------ */ + +function MissionProfile({ + events, + next, + running, + milestones, +}: { + events: FlightEvent[]; + next?: { id: string; code: string; label: string }; + running: boolean; + milestones: Array<{ id: string; code: string; label: string }>; +}) { + const eventTime = (id: string) => events.find((event) => event.id === id)?.time ?? null; return ( -
+
+
MISSION PROFILE任务剖面
+
+ {milestones.map((m) => { + const time = eventTime(m.id); + const reached = time !== null; + const isNext = next?.id === m.id && running; + return ( +
+ + {m.code} + {m.label} + +
+ ); + })} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Flight data panel (big green monospace numbers) */ +/* ------------------------------------------------------------------ */ + +function FlightData({ state, history, totalFuelPct, stage1FuelPct, stage2FuelPct }: { + state: ReturnType['state']; + history: ReturnType['state']['history']; + totalFuelPct: number; + stage1FuelPct: number; + stage2FuelPct: number; +}) { + return ( +
+
FLIGHT DATA飞行遥测
+
+
+ ALTITUDE +
{(state.altitude / 1000).toFixed(1)}KM
+
+
+ VELOCITY +
{(state.velocity * 3.6).toFixed(0)}KM/H
+
+
+ ACCELERATION +
{(state.acceleration / 9.80665).toFixed(2)}G
+
+
+ DYNAMIC PRESS +
{(state.dynamicPressure / 1000).toFixed(1)}KPA
+
+
+ DOWNRANGE +
{(state.downrange / 1000).toFixed(1)}KM
+
+
+ PITCH +
{state.pitch.toFixed(0)}°
+
+
+ +
+
ALT / VEL TRACE高度 · 速度
+ +
+ +
+
推进剂余量 / PROPELLANT{totalFuelPct.toFixed(0)}%
+
S1
{stage1FuelPct.toFixed(0)}%
+
S2
{stage2FuelPct.toFixed(0)}%
+
总推进剂{totalFuelPct.toFixed(0)}%
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Small mission-control widgets */ +/* ------------------------------------------------------------------ */ + +function EngineGauge({ stage, total, active }: { stage: 1 | 2; total: number; active: number }) { + // 与 3D 模型共用同一套发动机布局:2 台并排、3 台三角形、4 台方形, + // 5 台以上才是「中心 + 外圈」,避免 2 台被画成中心一个旁边一个。 + const placements = useMemo(() => engineLayout(total, 1), [total]); + return ( +
- 0 ? 'active' : ''}`} /> - {Array.from({ length: outerCount }, (_, index) => ( + {placements.map((placement, index) => ( ))}
@@ -97,6 +377,10 @@ function AttitudeGauge({ pitch }: { pitch: number }) { ); } +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + function RocketMission({ rockets }: { rockets: RocketConfig[] }) { const navigate = useNavigate(); const [selectedCode, setSelectedCode] = useState(rockets[0].code); @@ -104,7 +388,7 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) { 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 { state, toggle, reset, speed, setSpeed } = 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; @@ -112,8 +396,14 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) { 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 milestones = useMemo(() => milestonesFor(rocket), [rocket]); + const reachedCount = milestones.filter((m) => eventTime(m.id) !== null).length; + const progress = reachedCount / milestones.length * 100; + const nextMilestone = milestones.find((m) => eventTime(m.id) === null); + const stackMode = stackModeFor(state.phase); + 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 @@ -128,6 +418,7 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) { : state.phase === 'ready' ? '执行点火' : '继续'; + const clockText = state.phase === 'ready' && !state.isRunning ? 'T-00:00:00' : formatTime(state.time); return (
@@ -140,7 +431,7 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) { onViewScaleChange={setViewScale} /> - {/* Top bar: brand + mission clock + rocket selector + view controls */} + {/* Top bar: brand + mission status + vehicle selector + view controls */}
COSMO LAUNCH{rocket.name_zh || rocket.name} · {rocket.name}
@@ -157,6 +448,24 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) { {rockets.map((item) => )}
+ {/* 完整任务约 9 分钟,提供三段倍速开关便于观察分离、入轨与载荷部署 */} +
@@ -170,62 +479,47 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) {
-
- {environment.code} - {environment.label} - {rocket.launch_site_name} · {environment.detail} -
- - {/* Minimal telemetry side panel */} -
- - } + title="控制台" + description="平台数据总览" + meta={ + cutoffDate ? ( + {t('数据截止日期')}:{cutoffDate} + ) : undefined + } + actions={ + + } + > + + + } + label="天体总数" + value={stats?.total_bodies ?? 0} + unit="个" + footnote="包含行星、卫星、探测器等全部登记天体" + loading={loading} + /> + + + } + label="探测器" + value={stats?.total_probes ?? 0} + unit="个" + footnote="NASA Horizons 实时位置追踪" + loading={loading} + /> + + + } + label="恒星系统" + value={systemStats?.total_systems ?? 0} + unit="个" + footnote="含太阳系与系外星系" + loading={loading} + /> + + + } + label="注册用户" + value={stats?.total_users ?? 0} + unit="人" + footnote="包含管理员与普通用户" + loading={loading} + /> + + + + + + {t('近期任务')}} + extra={ + + } + styles={{ body: { padding: 0 } }} + > +
} + pagination={false} + locale={{ + emptyText: , + }} /> - - - } - /> - - - - - } - /> + + + {t('系统状态')}} loading={loading}> +
+
+ {t('Redis 缓存')} + + + {redis?.connected ? t('已连接') : t('未连接')} + +
+
+ {t('缓存内存占用')} + {redis?.used_memory_human ?? '—'} +
+
+ {t('缓存命中率')} + {hitRate === '—' ? '—' : `${hitRate}%`} +
+
+ {t('累计命令数')} + {redis?.total_commands_processed?.toLocaleString() ?? '—'} +
+
+
+ {t('数据截止日期')} + {cutoffDate ?? '—'} +
+
+ {t('太阳系行星')} + {systemStats?.solar_system_planets ?? '—'} {t('颗')} +
+
+ {t('系外行星')} + {systemStats?.exo_planets ?? '—'} {t('颗')} +
+
+ {t('星系总数')} + {systemStats?.total_planets ?? '—'} {t('颗')} +
+
-
+ + {t('即将发生的天象')}} + extra={ + + + + + } + styles={{ body: { padding: 0 } }} + > +
, + }} + /> + + ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/admin/MyCelestialBodies.tsx b/frontend/src/pages/admin/MyCelestialBodies.tsx index d1e3ae3..06b6157 100644 --- a/frontend/src/pages/admin/MyCelestialBodies.tsx +++ b/frontend/src/pages/admin/MyCelestialBodies.tsx @@ -1,174 +1,151 @@ /** - * My Celestial Bodies Page (User Follow) - * 我的天体页面 - 左侧是关注的天体列表,右侧是天体详情和事件 + * 我的天体(普通用户) + * + * 左侧是已关注天体列表,右侧是天体资料与相关天象事件。 */ -import { useState, useEffect } from 'react'; -import { Row, Col, Card, List, Tag, Button, Empty, Descriptions, Table, Space } from 'antd'; -import { StarFilled, RocketOutlined } from '@ant-design/icons'; +import { useCallback, useEffect, useState } from 'react'; +import { Button, Card, Col, Descriptions, Empty, Row, Table, Tag } from 'antd'; +import { ReloadOutlined, RocketOutlined, StarFilled, StarOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; + import { request } from '../../utils/request'; import { useToast } from '../../contexts/ToastContext'; +import { AdminPage } from '../../components/admin/AdminPage'; -interface CelestialBody { +interface FollowedBody { id: string; name: string; - name_zh: string; + name_zh?: string | null; type: string; is_active: boolean; followed_at?: string; } -interface CelestialEvent { +interface BodyEvent { id: number; title: string; event_type: string; event_time: string; description: string; - details: any; - source: string; + details?: Record; } +const BODY_TYPE_LABELS: Record = { + star: '恒星', + planet: '行星', + dwarf_planet: '矮行星', + satellite: '卫星', + comet: '彗星', + asteroid: '小行星', + probe: '探测器', +}; + +const BODY_TYPE_COLORS: Record = { + star: 'gold', + planet: 'blue', + dwarf_planet: 'cyan', + satellite: 'geekblue', + comet: 'purple', + asteroid: 'volcano', + probe: 'magenta', +}; + +const EVENT_TYPE_LABELS: Record = { + approach: '接近', + close_approach: '近距离接近', + eclipse: '食', + conjunction: '合', + opposition: '冲', + transit: '凌', +}; + +const EVENT_TYPE_COLORS: Record = { + approach: 'blue', + close_approach: 'magenta', + eclipse: 'purple', + conjunction: 'cyan', + opposition: 'orange', + transit: 'green', +}; + export function MyCelestialBodies() { const [loading, setLoading] = useState(false); - const [followedBodies, setFollowedBodies] = useState([]); - const [selectedBody, setSelectedBody] = useState(null); - const [bodyEvents, setBodyEvents] = useState([]); + const [bodies, setBodies] = useState([]); + const [selectedBody, setSelectedBody] = useState(null); + const [events, setEvents] = useState([]); const [eventsLoading, setEventsLoading] = useState(false); const toast = useToast(); - useEffect(() => { - loadFollowedBodies(); - }, []); + const loadEvents = useCallback(async (body: FollowedBody) => { + setEventsLoading(true); + try { + const { data } = await request.get('/events', { params: { body_id: body.id, limit: 100 } }); + setEvents(data || []); + } catch { + toast.error('加载天体事件失败'); + setEvents([]); + } finally { + setEventsLoading(false); + } + }, [toast]); - const loadFollowedBodies = async () => { + const loadFollowedBodies = useCallback(async () => { setLoading(true); try { - const { data } = await request.get('/social/follows'); - setFollowedBodies(data || []); - // 如果有数据,默认选中第一个 - if (data && data.length > 0) { - handleSelectBody(data[0]); + const { data } = await request.get('/social/follows'); + const list = data || []; + setBodies(list); + if (list.length > 0) { + const next = list.find((item) => item.id === selectedBody?.id) ?? list[0]; + setSelectedBody(next); + await loadEvents(next); + } else { + setSelectedBody(null); + setEvents([]); } - } catch (error) { + } catch { toast.error('加载关注列表失败'); } finally { setLoading(false); } - }; + }, [loadEvents, selectedBody?.id, toast]); - const handleSelectBody = async (body: CelestialBody) => { + useEffect(() => { + void loadFollowedBodies(); + // 仅在首次进入页面时加载关注列表,后续交互自行刷新。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSelectBody = async (body: FollowedBody) => { setSelectedBody(body); - setEventsLoading(true); - try { - const { data } = await request.get(`/events`, { - params: { - body_id: body.id, - limit: 100 - } - }); - setBodyEvents(data || []); - } catch (error) { - toast.error('加载天体事件失败'); - setBodyEvents([]); - } finally { - setEventsLoading(false); - } + await loadEvents(body); }; const handleUnfollow = async (bodyId: string) => { try { await request.delete(`/social/follow/${bodyId}`); toast.success('已取消关注'); - - // 重新加载列表 - await loadFollowedBodies(); - - // 如果取消关注的是当前选中的天体,清空右侧显示 if (selectedBody?.id === bodyId) { setSelectedBody(null); - setBodyEvents([]); + setEvents([]); } - } catch (error) { + await loadFollowedBodies(); + } catch { toast.error('取消关注失败'); } }; - const getBodyTypeLabel = (type: string) => { - const labelMap: Record = { - 'star': '恒星', - 'planet': '行星', - 'dwarf_planet': '矮行星', - 'satellite': '卫星', - 'comet': '彗星', - 'asteroid': '小行星', - 'probe': '探测器', - }; - return labelMap[type] || type; - }; - - const getBodyTypeColor = (type: string) => { - const colorMap: Record = { - 'star': 'gold', - 'planet': 'blue', - 'dwarf_planet': 'cyan', - 'satellite': 'geekblue', - 'comet': 'purple', - 'asteroid': 'volcano', - 'probe': 'magenta', - }; - return colorMap[type] || 'default'; - }; - - const getEventTypeLabel = (type: string) => { - const labelMap: Record = { - 'approach': '接近', - 'close_approach': '近距离接近', - 'eclipse': '食', - 'conjunction': '合', - 'opposition': '冲', - 'transit': '凌', - }; - return labelMap[type] || type; - }; - - const getEventTypeColor = (type: string) => { - const colorMap: Record = { - 'approach': 'blue', - 'close_approach': 'magenta', - 'eclipse': 'purple', - 'conjunction': 'cyan', - 'opposition': 'orange', - 'transit': 'green', - }; - return colorMap[type] || 'default'; - }; - - const eventColumns: ColumnsType = [ - { - title: '事件', - dataIndex: 'title', - key: 'title', - ellipsis: true, - width: '40%', - }, + const eventColumns: ColumnsType = [ + { title: '事件', dataIndex: 'title', key: 'title', ellipsis: true, width: '40%' }, { title: '类型', dataIndex: 'event_type', key: 'event_type', - width: 200, - render: (type) => ( - - {getEventTypeLabel(type)} - + width: 160, + render: (type: string) => ( + {EVENT_TYPE_LABELS[type] || type} ), - filters: [ - { text: '接近', value: 'approach' }, - { text: '近距离接近', value: 'close_approach' }, - { text: '食', value: 'eclipse' }, - { text: '合', value: 'conjunction' }, - { text: '冲', value: 'opposition' }, - { text: '凌', value: 'transit' }, - ], + filters: Object.entries(EVENT_TYPE_LABELS).map(([value, text]) => ({ text, value })), onFilter: (value, record) => record.event_type === value, }, { @@ -176,192 +153,142 @@ export function MyCelestialBodies() { dataIndex: 'event_time', key: 'event_time', width: 180, - render: (time) => new Date(time).toLocaleString('zh-CN'), + render: (time: string) => new Date(time).toLocaleString('zh-CN'), sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(), }, ]; return ( - - {/* 左侧:关注的天体列表 */} - - - - 我的天体 - {followedBodies.length} - - } - extra={ - - } - bordered={false} - style={{ height: '100%', overflow: 'hidden' }} - bodyStyle={{ height: 'calc(100% - 57px)', overflowY: 'auto', padding: 0 }} - > - {followedBodies.length === 0 && !loading ? ( - -

- 在主页面点击天体,查看详情后可以关注 -

-
- ) : ( - ( - handleSelectBody(body)} - style={{ - cursor: 'pointer', - backgroundColor: selectedBody?.id === body.id ? '#f0f5ff' : 'transparent', - padding: '12px 16px', - transition: 'background-color 0.3s', - }} - actions={[ + } + title="我的天体" description="查看已关注天体及其相关天象事件"> + +
+ + + 关注列表 + {bodies.length} + + } + extra={} + style={{ height: 520 }} + > + {bodies.length === 0 && !loading ? ( + +
在可视化首页点击天体,进入详情后即可关注
+
+ ) : ( +
+ {bodies.map((body) => ( +
void handleSelectBody(body)} + > + +
+
+ {body.name_zh || body.name} + + {BODY_TYPE_LABELS[body.type] || body.type} + +
+
+ {body.followed_at + ? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}` + : body.name} +
+
, - ]} - > - } - title={ - - {body.name_zh || body.name} - - {getBodyTypeLabel(body.type)} - - - } - description={ - body.followed_at - ? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}` - : body.name_zh ? body.name : undefined - } - /> - - )} - /> - )} - - + 取消关注 + +
+ ))} +
+ )} +
+ - {/* 右侧:天体详情和事件 */} -
- {selectedBody ? ( - - {/* 天体资料 */} + +
- - {selectedBody.name_zh || selectedBody.name} - - {getBodyTypeLabel(selectedBody.type)} - - + selectedBody ? ( + + + {selectedBody.name_zh || selectedBody.name} + + {BODY_TYPE_LABELS[selectedBody.type] || selectedBody.type} + + + ) : '天体资料' } - bordered={false} > - - {selectedBody.id} - - {getBodyTypeLabel(selectedBody.type)} - - - {selectedBody.name_zh || '-'} - - - {selectedBody.name} - - - - {selectedBody.is_active ? '活跃' : '已归档'} - - - - {selectedBody.followed_at - ? new Date(selectedBody.followed_at).toLocaleString('zh-CN') - : '-'} - - + {selectedBody ? ( + + {selectedBody.id} + + {BODY_TYPE_LABELS[selectedBody.type] || selectedBody.type} + + {selectedBody.name_zh || '-'} + {selectedBody.name} + + + {selectedBody.is_active ? '活跃' : '已归档'} + + + + {selectedBody.followed_at ? new Date(selectedBody.followed_at).toLocaleString('zh-CN') : '-'} + + + ) : ( + + )} - {/* 天体事件列表 */} - +
`共 ${total} 条`, - }} + pagination={{ pageSize: 10, showSizeChanger: false, showTotal: (count) => `共 ${count} 条` }} locale={{ - emptyText: ( - - ), + emptyText: , }} expandable={{ expandedRowRender: (record) => ( -
-

- 描述: - {record.description} -

- {record.details && ( -

- 详情: - {JSON.stringify(record.details, null, 2)} -

- )} +
+
描述:{record.description || '-'}
+ {record.details ? ( +
{JSON.stringify(record.details, null, 2)}
+ ) : null}
), }} /> - - ) : ( - - - - )} - - +
+ + + ); } diff --git a/frontend/src/pages/admin/Rockets.tsx b/frontend/src/pages/admin/Rockets.tsx index 0e4eef1..31ecdc4 100644 --- a/frontend/src/pages/admin/Rockets.tsx +++ b/frontend/src/pages/admin/Rockets.tsx @@ -1,7 +1,10 @@ import { useEffect, useState } from 'react'; import { Button, Col, Form, Input, InputNumber, Modal, Row, Switch, Tabs, Tag } from 'antd'; +import { RocketOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { DataTable } from '../../components/admin/DataTable'; +import { useListPageSize } from './useListPageSize'; +import { AdminPage } from '../../components/admin/AdminPage'; import type { RocketConfig } from '../../features/rocket-simulator/types'; import { request } from '../../utils/request'; import { useToast } from '../../contexts/ToastContext'; @@ -11,7 +14,7 @@ const defaultStage2 = { name: '二级推进器', dry_mass_kg: 4000, fuel_mass_kg 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 ; + return ; } function StageFields({ index }: { index: 1 | 2 }) { @@ -34,7 +37,14 @@ export function Rockets() { const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); + const systemPageSize = useListPageSize(); + const [pageSize, setPageSize] = useState(systemPageSize); + + // 系统参数加载完成后同步分页大小 + useEffect(() => { + setPageSize(systemPageSize); + setPage(1); + }, [systemPageSize]); const [search, setSearch] = useState(''); const [open, setOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -97,9 +107,9 @@ export function Rockets() { const columns: ColumnsType = [ { title: '排序', dataIndex: 'sort_order', width: 70 }, - { title: '火箭', key: 'name', width: 210, render: (_, record) =>
{record.name_zh || record.name}
{record.name} · {record.code}
}, + { title: '火箭', key: 'name', width: 210, render: (_, record) =>
{record.name_zh || record.name}
{record.name} · {record.code}
}, { title: '制造方', dataIndex: 'manufacturer', width: 180, render: (value) => value || '-' }, - { title: '发射场', key: 'launch_site', width: 180, render: (_, record) =>
{record.launch_site_name}
{record.launch_latitude_deg.toFixed(4)}, {record.launch_longitude_deg.toFixed(4)}
}, + { title: '发射场', key: 'launch_site', width: 180, render: (_, record) =>
{record.launch_site_name}
{record.launch_latitude_deg.toFixed(4)}, {record.launch_longitude_deg.toFixed(4)}
}, { title: '总体尺寸', key: 'size', width: 130, render: (_, record) => {record.height_m} m × {record.diameter_m} m }, { title: '一级', key: 'stage1', width: 170, render: (_, record) => {record.stage_1.engine_count} 台 / {(record.stage_1.max_thrust_n / 1_000_000).toFixed(2)} MN }, { title: '二级', key: 'stage2', width: 170, render: (_, record) => {record.stage_2.engine_count} 台 / {(record.stage_2.max_thrust_n / 1_000_000).toFixed(2)} MN }, @@ -108,9 +118,37 @@ export function Rockets() { ]; return ( - <> - { setPage(nextPage); setPageSize(nextSize); }} onSearch={(value) => { setSearch(value); setPage(1); }} onAdd={showCreate} onEdit={showEdit} onDelete={remove} /> - setOpen(false)} afterOpenChange={syncFormValues} width={820} footer={[, ]} destroyOnHidden> + } + title="火箭数据管理" description="发射模拟使用的运载火箭参数(质量、推力、几何尺寸与发射场)"> + { setPage(nextPage); setPageSize(nextSize); }} + onRefresh={() => void load()} + onSearch={(value) => { setSearch(value); setPage(1); }} + searchPlaceholder="搜索火箭名称 / 编码" + onAdd={showCreate} + addText="新增火箭" + onEdit={showEdit} + onDelete={remove} + deleteConfirmTitle="确认删除该火箭?" + deleteConfirmDescription="删除后前台发射模拟将不再显示该运载器" + /> + setOpen(false)} + afterOpenChange={syncFormValues} + width={820} + footer={[, ]} + destroyOnHidden + >
}, @@ -120,6 +158,6 @@ export function Rockets() { ]} /> - + ); } diff --git a/frontend/src/pages/admin/ScheduledJobs.tsx b/frontend/src/pages/admin/ScheduledJobs.tsx index 645c09e..e1a511e 100644 --- a/frontend/src/pages/admin/ScheduledJobs.tsx +++ b/frontend/src/pages/admin/ScheduledJobs.tsx @@ -1,9 +1,11 @@ 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 { Badge, Button, Form, Tag, Tooltip } from 'antd'; +import { ClockCircleOutlined, PlayCircleOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { DataTable } from '../../components/admin/DataTable'; +import { AdminPage } from '../../components/admin/AdminPage'; +import { useListPageSize } from './useListPageSize'; import { useToast } from '../../contexts/ToastContext'; import { request } from '../../utils/request'; import { ScheduledJobModal } from './scheduled-jobs/ScheduledJobModal'; @@ -18,6 +20,8 @@ function getErrorMessage(error: unknown) { } export function ScheduledJobs() { + // 每页数量由系统参数 page_size 控制 + const systemPageSize = useListPageSize(); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); const [keyword, setKeyword] = useState(''); @@ -144,7 +148,12 @@ export function ScheduledJobs() { { title: 'ID', dataIndex: 'id', width: 60 }, { title: '任务名称', dataIndex: 'name', width: 200, - render: (text, record) =>
{text}
{record.description &&
{record.description}
}
, + render: (text, record) => ( +
+
{text}
+ {record.description ?
{record.description}
: null} +
+ ), }, { title: '类型', dataIndex: 'job_type', width: 120, @@ -152,7 +161,7 @@ export function ScheduledJobs() { }, { title: '任务函数', dataIndex: 'predefined_function', width: 200, - render: (func, record) => record.job_type === 'predefined' ? {func} : -, + render: (func, record) => record.job_type === 'predefined' ? {func} : -, }, { title: 'Cron 表达式', dataIndex: 'cron_expression', width: 130, render: (text) => {text} }, { title: '状态', dataIndex: 'is_active', width: 80, render: (active) => }, @@ -160,34 +169,35 @@ export function ScheduledJobs() { title: '上次执行', width: 200, render: (_, record) => record.last_run_at ? (
{new Date(record.last_run_at).toLocaleString()}
{record.last_run_status === 'success' ? '成功' : '失败'}
- ) : 从未执行, - }, - { - title: '操作', key: 'action', width: 150, - render: (_, record) => ( - - - - {record.id !== 1 && handleDelete(record.id)} okText="删除" cancelText="取消" okButtonProps={{ danger: true }}>} - , - }, ]; return ( -
+ } + title="恒星系统管理" description="管理恒星系统及其包含的天体,太阳系不可删除"> { setCurrentPage(page); setPageSize(size); }} onAdd={handleAdd} + addText="新增恒星系统" + onEdit={handleEdit} + onDelete={(record) => handleDelete(record.id)} + deleteConfirmTitle="确认删除该恒星系统?" + deleteConfirmDescription="这将同时删除该系统下的所有天体,且无法撤销" + onRefresh={() => void loadData()} onSearch={(value) => { setSearchKeyword(value); setCurrentPage(1); }} searchPlaceholder="搜索恒星系统名称..." + customActions={(record) => ( + +
+ ); } diff --git a/frontend/src/pages/admin/StaticData.tsx b/frontend/src/pages/admin/StaticData.tsx index 3d64e6d..049551b 100644 --- a/frontend/src/pages/admin/StaticData.tsx +++ b/frontend/src/pages/admin/StaticData.tsx @@ -1,10 +1,13 @@ /** * Static Data Management Page */ -import { useState, useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Modal, Form, Input, Select } from 'antd'; +import { DatabaseOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { DataTable } from '../../components/admin/DataTable'; +import { AdminPage } from '../../components/admin/AdminPage'; +import { useListPageSize } from './useListPageSize'; import { request } from '../../utils/request'; import { useToast } from '../../contexts/ToastContext'; @@ -17,41 +20,42 @@ interface StaticDataItem { } export function StaticData() { + // 每页数量由系统参数 page_size 控制 + const systemPageSize = useListPageSize(); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); - const [filteredData, setFilteredData] = useState([]); + const [keyword, setKeyword] = useState(''); const [isModalOpen, setIsModalOpen] = useState(false); const [editingRecord, setEditingRecord] = useState(null); const [form] = Form.useForm(); const toast = useToast(); - useEffect(() => { - loadData(); - }, []); - - const loadData = async () => { + const loadData = useCallback(async () => { setLoading(true); try { const { data: result } = await request.get('/celestial/static/list'); setData(result.items || []); - setFilteredData(result.items || []); - } catch (error) { + } catch { toast.error('加载数据失败'); } finally { setLoading(false); } - }; + }, [toast]); - const handleSearch = (keyword: string) => { - const lowerKeyword = keyword.toLowerCase(); - const filtered = data.filter( + useEffect(() => { + void loadData(); + }, [loadData]); + + const filteredData = useMemo(() => { + const lowerKeyword = keyword.trim().toLowerCase(); + if (!lowerKeyword) return data; + return data.filter( (item) => item.name.toLowerCase().includes(lowerKeyword) || item.name_zh?.toLowerCase().includes(lowerKeyword) || item.category.toLowerCase().includes(lowerKeyword) ); - setFilteredData(filtered); - }; + }, [data, keyword]); const handleAdd = () => { setEditingRecord(null); @@ -74,8 +78,8 @@ export function StaticData() { try { await request.delete(`/celestial/static/${record.id}`); toast.success('删除成功'); - loadData(); - } catch (error) { + await loadData(); + } catch { toast.error('删除失败'); } }; @@ -101,9 +105,9 @@ export function StaticData() { } setIsModalOpen(false); - loadData(); - } catch (error) { - console.error(error); + await loadData(); + } catch { + toast.error('保存失败'); } }; @@ -142,24 +146,28 @@ export function StaticData() { title: '数据 (JSON)', dataIndex: 'data', ellipsis: true, - render: (text) => JSON.stringify(text), + render: (text) => {JSON.stringify(text)}, }, ]; return ( - <> + } + title="静态数据管理" description="星座、星系、星云、小行星带等静态天文数据(JSON)"> void loadData()} + onSearch={setKeyword} + searchPlaceholder="搜索名称 / 分类" onAdd={handleAdd} + addText="新增数据" onEdit={handleEdit} onDelete={handleDelete} rowKey="id" - pageSize={10} + pageSize={systemPageSize} /> setIsModalOpen(false)} width={700} + okText="保存" + cancelText="取消" + forceRender >
- +
); } diff --git a/frontend/src/pages/admin/SystemSettings.tsx b/frontend/src/pages/admin/SystemSettings.tsx index db903f7..f6d2037 100644 --- a/frontend/src/pages/admin/SystemSettings.tsx +++ b/frontend/src/pages/admin/SystemSettings.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect } from 'react'; -import { Modal, Form, Input, InputNumber, Switch, Select, Button, Card, Badge, Space, Popconfirm, Alert, Divider } from 'antd'; -import { ClearOutlined, SyncOutlined } from '@ant-design/icons'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Modal, Form, Input, InputNumber, Switch, Select, Button, Badge, Popconfirm } from 'antd'; +import { ClearOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { DataTable } from '../../components/admin/DataTable'; +import { AdminPage } from '../../components/admin/AdminPage'; +import { useListPageSize } from './useListPageSize'; import { request } from '../../utils/request'; import { useToast } from '../../contexts/ToastContext'; @@ -29,9 +31,11 @@ const CATEGORY_MAP: Record = { }; export function SystemSettings() { + // 每页数量由系统参数 page_size 控制 + const systemPageSize = useListPageSize(); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); - const [filteredData, setFilteredData] = useState([]); + const [keyword, setKeyword] = useState(''); const [isModalOpen, setIsModalOpen] = useState(false); const [editingRecord, setEditingRecord] = useState(null); const [form] = Form.useForm(); @@ -39,34 +43,32 @@ export function SystemSettings() { const [reloading, setReloading] = useState(false); const toast = useToast(); - useEffect(() => { - loadData(); - }, []); - - const loadData = async () => { + const loadData = useCallback(async () => { setLoading(true); try { const { data: result } = await request.get('/system/settings'); setData(result.settings || []); - setFilteredData(result.settings || []); - } catch (error) { + } catch { toast.error('加载数据失败'); } finally { setLoading(false); } - }; + }, [toast]); - // Search handler - const handleSearch = (keyword: string) => { - const lowerKeyword = keyword.toLowerCase(); - const filtered = data.filter( + useEffect(() => { + void loadData(); + }, [loadData]); + + const filteredData = useMemo(() => { + const lowerKeyword = keyword.trim().toLowerCase(); + if (!lowerKeyword) return data; + return data.filter( (item) => item.key.toLowerCase().includes(lowerKeyword) || - item.label?.toLowerCase().includes(lowerKeyword) || + item.label.toLowerCase().includes(lowerKeyword) || item.category?.toLowerCase().includes(lowerKeyword) ); - setFilteredData(filtered); - }; + }, [data, keyword]); // Add handler const handleAdd = () => { @@ -102,8 +104,8 @@ export function SystemSettings() { try { await request.delete(`/system/settings/${record.key}`); toast.success('删除成功'); - loadData(); - } catch (error) { + await loadData(); + } catch { toast.error('删除失败'); } }; @@ -134,9 +136,9 @@ export function SystemSettings() { } setIsModalOpen(false); - loadData(); - } catch (error) { - console.error(error); + await loadData(); + } catch { + toast.error('保存失败'); } }; @@ -148,14 +150,14 @@ export function SystemSettings() { toast.success( <>
{data.message}
-
+
位置缓存: {data.redis_cache.positions_keys} 个键 | NASA缓存: {data.redis_cache.nasa_keys} 个键
, 5 ); - loadData(); - } catch (error) { + await loadData(); + } catch { toast.error('清除缓存失败'); } finally { setClearingCache(false); @@ -168,7 +170,7 @@ export function SystemSettings() { try { const { data } = await request.post('/system/settings/reload'); toast.success(data.message); - } catch (error) { + } catch { toast.error('重载配置失败'); } finally { setReloading(false); @@ -184,7 +186,7 @@ export function SystemSettings() { fixed: 'left', render: (key: string, record) => (
-
{key}
+
{key}
{record.is_public && ( )} @@ -208,20 +210,12 @@ export function SystemSettings() { } if (record.value_type === 'json' || typeof value === 'object') { return ( -
+
{JSON.stringify(value)}
); } - return {String(value)}; + return {String(value)}; }, }, { @@ -269,34 +263,18 @@ export function SystemSettings() { ]; return ( - <> - {/* Cache Management Card */} - - - 系统维护 - - } - style={{ marginBottom: 16 }} - styles={{ body: { padding: 16 } }} - > - -
    -
  • 清除缓存:清空所有内存缓存和 Redis 缓存,下次查询可能会较慢。
  • -
  • 重载配置:从数据库重新加载系统参数到内存,使配置修改立即生效(无需重启)。
  • -
-
- } - type="info" - showIcon - style={{ marginBottom: 16 }} - /> - - + } + title="系统设置" + description="维护系统参数与缓存,修改后无需重启即可生效" + meta={ + <> + 清除缓存:清空内存与 Redis 缓存,下次查询可能较慢。 + 重载配置:从数据库重新加载系统参数并立即生效。 + + } + actions={ + <> - - - - - + + } + > - - - {/* Settings Table */} + {/* 系统参数 */} void loadData()} + onSearch={setKeyword} + searchPlaceholder="搜索参数键 / 名称 / 分类" onAdd={handleAdd} + addText="新增参数" onEdit={handleEdit} onDelete={handleDelete} + deleteConfirmTitle="确认删除该参数?" + deleteConfirmDescription="删除后依赖该参数的功能可能失效" rowKey="id" - pageSize={15} + pageSize={systemPageSize} scroll={{ x: 1200 }} /> @@ -350,6 +320,9 @@ export function SystemSettings() { onOk={handleModalOk} onCancel={() => setIsModalOpen(false)} width={700} + okText="保存" + cancelText="取消" + forceRender >
- + ); } diff --git a/frontend/src/pages/admin/Tasks.tsx b/frontend/src/pages/admin/Tasks.tsx index 8ef4d13..e03e72c 100644 --- a/frontend/src/pages/admin/Tasks.tsx +++ b/frontend/src/pages/admin/Tasks.tsx @@ -1,11 +1,19 @@ -import { useState, useEffect, useRef } from 'react'; -import { Tag, Progress, Button, Modal, Descriptions, Badge, Typography } from 'antd'; -import { EyeOutlined } from '@ant-design/icons'; +/** + * 系统任务 + * + * 列表每 3 秒静默刷新一次,运行中的任务进度会自动更新。 + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Badge, Button, Descriptions, Modal, Progress, Tag, Tooltip } from 'antd'; +import type { BadgeProps } from 'antd'; +import { EyeOutlined, ScheduleOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; -import { DataTable } from '../../components/admin/DataTable'; -import { request } from '../../utils/request'; -const { Text } = Typography; +import { DataTable } from '../../components/admin/DataTable'; +import { AdminPage } from '../../components/admin/AdminPage'; +import { useListPageSize } from './useListPageSize'; +import { request } from '../../utils/request'; +import { useToast } from '../../contexts/ToastContext'; interface Task { id: number; @@ -17,160 +25,160 @@ interface Task { started_at?: string; completed_at?: string; error_message?: string; - result?: any; + result?: unknown; } +const STATUS_META: Record = { + pending: { badge: 'default', label: '等待中' }, + running: { badge: 'processing', label: '执行中' }, + completed: { badge: 'success', label: '已完成' }, + failed: { badge: 'error', label: '失败' }, + cancelled: { badge: 'warning', label: '已取消' }, +}; + export function Tasks() { + // 每页数量由系统参数 page_size 控制 + const systemPageSize = useListPageSize(); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); + const [keyword, setKeyword] = useState(''); const [currentTask, setCurrentTask] = useState(null); - const [detailsVisible, setDetailsVisible] = useState(false); - - // Auto-refresh logic - const timerRef = useRef | null>(null); + const toast = useToast(); - const loadData = async () => { - setLoading(true); + const loadData = useCallback(async (silent = false) => { + if (!silent) setLoading(true); try { - const res = await request.get('/tasks?limit=50'); - setData(res.data); + const { data: result } = await request.get('/tasks', { params: { limit: 50 } }); + setData(result); + } catch { + if (!silent) toast.error('加载任务列表失败'); } finally { - setLoading(false); + if (!silent) setLoading(false); } - }; + }, [toast]); - // Polling for active tasks + // 运行中的任务需要持续刷新进度,这里用固定间隔静默拉取,避免表格闪烁。 useEffect(() => { - loadData(); + void loadData(); + const timer = setInterval(() => void loadData(true), 3000); + return () => clearInterval(timer); + }, [loadData]); - timerRef.current = setInterval(() => { - // Silent refresh to avoid table flickering - request.get('/tasks?limit=50').then(res => { - setData(res.data); - }); - }, 3000); - - return () => { - if (timerRef.current) clearInterval(timerRef.current); - }; - }, []); + const filteredData = useMemo(() => { + const lowerKeyword = keyword.trim().toLowerCase(); + if (!lowerKeyword) return data; + return data.filter((task) => + task.task_type.toLowerCase().includes(lowerKeyword) + || task.description?.toLowerCase().includes(lowerKeyword) + || task.status.toLowerCase().includes(lowerKeyword)); + }, [data, keyword]); const columns: ColumnsType = [ - { - title: 'ID', - dataIndex: 'id', - width: 80, - }, + { title: 'ID', dataIndex: 'id', width: 70 }, { title: '任务类型', dataIndex: 'task_type', - width: 150, - render: (type: string) => {type} - }, - { - title: '描述', - dataIndex: 'description', - ellipsis: true, + width: 160, + render: (type: string) => {type}, }, + { title: '描述', dataIndex: 'description', ellipsis: true }, { title: '状态', dataIndex: 'status', - width: 120, + width: 110, render: (status: string) => { - const colors: Record = { - pending: 'default', - running: 'processing', - completed: 'success', - failed: 'error', - cancelled: 'warning' - }; - return ; - } + const meta = STATUS_META[status] ?? { badge: 'default' as BadgeProps['status'], label: status }; + return ; + }, }, { title: '进度', dataIndex: 'progress', - width: 200, + width: 180, render: (progress: number, record: Task) => ( - - ) + ), }, { title: '创建时间', dataIndex: 'created_at', width: 180, - render: (time: string) => new Date(time).toLocaleString() + render: (time: string) => new Date(time).toLocaleString('zh-CN'), }, - { - title: '操作', - key: 'action', - width: 100, - fixed: 'right', - render: (_, record) => ( - - ) - } ]; + const isRunning = data.some((task) => task.status === 'running' || task.status === 'pending'); + return ( -
+ } + title="系统任务" + description={isRunning ? '有任务正在执行,列表每 3 秒自动刷新' : '后台异步任务(数据下载、轨道生成等)执行记录'} + > void loadData()} + onSearch={setKeyword} + searchPlaceholder="搜索任务类型 / 描述 / 状态" rowKey="id" + pageSize={systemPageSize} + showAdd={false} + showEdit={false} + customActions={(record) => ( + + } + width={720} + destroyOnHidden > {currentTask && ( -
- - {currentTask.id} - {currentTask.task_type} - - + + {currentTask.task_type} + + + + {Math.round(currentTask.progress ?? 0)}% + {currentTask.description} + {new Date(currentTask.created_at).toLocaleString('zh-CN')} + {currentTask.started_at && ( + {new Date(currentTask.started_at).toLocaleString('zh-CN')} + )} + {currentTask.completed_at && ( + {new Date(currentTask.completed_at).toLocaleString('zh-CN')} + )} + {currentTask.error_message && ( + + {currentTask.error_message} - {currentTask.description} - {currentTask.error_message && ( - - {currentTask.error_message} - - )} - -
- {currentTask.result ? ( -
-                        {JSON.stringify(currentTask.result, null, 2)}
-                      
- ) : ( - 暂无结果 - )} -
-
-
-
+ )} + +
+ {currentTask.result + ?
{JSON.stringify(currentTask.result, null, 2)}
+ : 暂无结果} +
+
+ )} -
+ ); } diff --git a/frontend/src/pages/admin/UserProfile.tsx b/frontend/src/pages/admin/UserProfile.tsx index 11cc29f..ed02f8e 100644 --- a/frontend/src/pages/admin/UserProfile.tsx +++ b/frontend/src/pages/admin/UserProfile.tsx @@ -1,110 +1,142 @@ /** - * User Profile Page - * 个人信息页面 + * 个人资料 + * + * 管理员与普通用户共用,可修改姓名、邮箱与头像。 */ -import { useState, useEffect } from 'react'; -import { Form, Input, Button, Card, Avatar, Descriptions, Row, Col, Upload } from 'antd'; -import { UserOutlined, MailOutlined, IdcardOutlined, UploadOutlined } from '@ant-design/icons'; +import { useCallback, useEffect, useState } from 'react'; +import { Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Tag, Upload } from 'antd'; +import type { UploadProps } from 'antd'; +import { IdcardOutlined, LockOutlined, MailOutlined, UploadOutlined, UserOutlined } from '@ant-design/icons'; + import { request } from '../../utils/request'; import { auth } from '../../utils/auth'; import { useToast } from '../../contexts/ToastContext'; +import { AdminPage } from '../../components/admin/AdminPage'; + +interface ProfileData { + username: string; + full_name?: string | null; + email?: string | null; + role?: string; + roles?: string[]; + avatar_url?: string | null; + created_at?: string; +} + +interface PasswordForm { + old_password: string; + new_password: string; + confirm_password: string; +} export function UserProfile() { const [form] = Form.useForm(); + const [passwordForm] = Form.useForm(); const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [changingPassword, setChangingPassword] = useState(false); const [uploading, setUploading] = useState(false); - const [userProfile, setUserProfile] = useState(null); + const [profile, setProfile] = useState(null); + // 头像 URL 不变但内容会变,上传后加时间戳避免浏览器缓存旧图。 + const [avatarStamp, setAvatarStamp] = useState(() => Date.now()); const toast = useToast(); - const user = auth.getUser(); - useEffect(() => { - loadUserProfile(); - }, []); - - const loadUserProfile = async () => { + const loadProfile = useCallback(async () => { setLoading(true); try { - const { data } = await request.get('/users/me'); - setUserProfile(data); + const { data } = await request.get('/users/me'); + setProfile(data); form.setFieldsValue({ email: data.email || '', full_name: data.full_name || '', }); - } catch (error) { + } catch { toast.error('获取用户信息失败'); } finally { setLoading(false); } - }; + }, [form, toast]); - const handleSubmit = async (values: any) => { - setLoading(true); + useEffect(() => { + void loadProfile(); + }, [loadProfile]); + + const handleSubmit = async (values: { full_name: string; email?: string }) => { + setSaving(true); try { await request.put('/users/me/profile', { full_name: values.full_name, email: values.email || null, }); - toast.success('个人信息更新成功'); - - // Update local user info - const updatedUser = { ...user, full_name: values.full_name, email: values.email }; - auth.setUser(updatedUser); - - // Reload profile - await loadUserProfile(); - } catch (error: any) { - toast.error(error.response?.data?.detail || '更新失败'); + toast.success('个人信息已保存'); + auth.setUser({ ...auth.getUser(), full_name: values.full_name, email: values.email }); + await loadProfile(); + } catch (error: unknown) { + const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + toast.error(detail || '保存失败'); } finally { - setLoading(false); + setSaving(false); } }; - const handleAvatarUpload = async (options: any) => { - const { file } = options; + const handleAvatarUpload: UploadProps['customRequest'] = async (options) => { const formData = new FormData(); - formData.append('file', file); + formData.append('file', options.file as File); setUploading(true); try { const { data } = await request.post('/users/me/avatar', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, + headers: { 'Content-Type': 'multipart/form-data' }, }); - toast.success('头像上传成功'); - - // Update local user info with new avatar URL - auth.setUser({ ...user, avatar_url: data.avatar_url }); - - // Reload profile to get new avatar URL from backend - await loadUserProfile(); - } catch (error: any) { - toast.error(error.response?.data?.detail || '头像上传失败'); + auth.setUser({ ...auth.getUser(), avatar_url: data.avatar_url }); + setAvatarStamp(Date.now()); + toast.success('头像已更新'); + await loadProfile(); + } catch (error: unknown) { + const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + toast.error(detail || '头像上传失败'); } finally { setUploading(false); } }; - // Construct full avatar URL - const getAvatarUrl = () => { - if (!userProfile?.avatar_url) return null; - // Use relative path to allow proxying (Vite/Nginx) - // The backend returns a relative path like "user/1/avatar/avatar.png" - return `/upload/${userProfile.avatar_url}?t=${new Date().getTime()}`; + /** 修改密码:与个人资料放在同一页面,避免再多一层入口。 */ + const handlePasswordSubmit = async (values: PasswordForm) => { + setChangingPassword(true); + try { + await request.put('/users/me/password', { + old_password: values.old_password, + new_password: values.new_password, + }); + toast.success('密码修改成功'); + passwordForm.resetFields(); + } catch (error: unknown) { + const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + toast.error(detail || '密码修改失败'); + } finally { + setChangingPassword(false); + } }; + const avatarUrl = profile?.avatar_url + ? `/upload/${profile.avatar_url}?t=${avatarStamp}` + : undefined; + const roleName = profile?.role === 'admin' || profile?.roles?.includes('admin') ? '管理员' : '普通用户'; + return ( - -
- {/* User Avatar and Basic Info Card */} - -
-
- } - /> + } + title="个人资料" description="维护头像、姓名、邮箱,并在此修改登录密码"> + +
+ +
+ } /> +

{profile?.full_name || profile?.username || '用户'}

+
@{profile?.username}
+ + {roleName} +
-
-

- {userProfile?.full_name || userProfile?.username || '用户'} -

-

- @{userProfile?.username} -

- {userProfile && ( - - - {userProfile.role === 'admin' ? '管理员' : '普通用户'} - - - {new Date(userProfile.created_at).toLocaleString('zh-CN')} - - - )} + + {profile?.username || '-'} + {roleName} + + {profile?.created_at ? new Date(profile.created_at).toLocaleString('zh-CN') : '-'} + + +
+ + +
+
+ +
+ + } placeholder="请输入您的姓名" /> + + + + } placeholder="请输入邮箱地址(可选)" /> + + + + + + +
+ + 修改后请使用新密码登录} + > +
+ + } + placeholder="请输入当前密码" + autoComplete="current-password" + /> + + + + } + placeholder="请输入新密码(至少 6 位)" + autoComplete="new-password" + /> + + + ({ + validator(_, value) { + if (!value || getFieldValue('new_password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + } + placeholder="请再次输入新密码" + autoComplete="new-password" + /> + + + + + + +
- - - -
- {/* Edit Profile Form */} - -
- - } - placeholder="请输入您的姓名" - size="large" - /> - - - - } - placeholder="请输入邮箱地址(可选)" - size="large" - /> - - - - - - -
- - + + + ); } diff --git a/frontend/src/pages/admin/Users.tsx b/frontend/src/pages/admin/Users.tsx index 2dd5a1b..6508f21 100644 --- a/frontend/src/pages/admin/Users.tsx +++ b/frontend/src/pages/admin/Users.tsx @@ -1,12 +1,15 @@ /** - * User Management Page + * 用户管理 */ -import { useState, useEffect } from 'react'; -import { Button, Popconfirm } from 'antd'; -import { ReloadOutlined } from '@ant-design/icons'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Popconfirm, Tag, Tooltip } from 'antd'; +import { ReloadOutlined, TeamOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; + import { request } from '../../utils/request'; import { DataTable } from '../../components/admin/DataTable'; +import { AdminPage } from '../../components/admin/AdminPage'; +import { useListPageSize } from './useListPageSize'; import { useToast } from '../../contexts/ToastContext'; interface UserItem { @@ -20,51 +23,50 @@ interface UserItem { created_at: string; } +const ROLE_LABELS: Record = { + admin: '管理员', + user: '普通用户', +}; + export function Users() { + // 每页数量由系统参数 page_size 控制 + const systemPageSize = useListPageSize(); const [data, setData] = useState([]); - const [filteredData, setFilteredData] = useState([]); const [loading, setLoading] = useState(false); + const [keyword, setKeyword] = useState(''); const toast = useToast(); - const loadData = async () => { + const loadData = useCallback(async () => { setLoading(true); try { const { data: result } = await request.get('/users/list'); setData(result.users || []); - setFilteredData(result.users || []); - } catch (error) { - console.error(error); + } catch { toast.error('加载用户数据失败'); } finally { setLoading(false); } - }; + }, [toast]); useEffect(() => { - loadData(); - }, []); + void loadData(); + }, [loadData]); - const handleSearch = (keyword: string) => { - const lowerKeyword = keyword.toLowerCase(); - const filtered = data.filter(item => - item.username.toLowerCase().includes(lowerKeyword) || - (item.email && item.email.toLowerCase().includes(lowerKeyword)) || - (item.full_name && item.full_name.toLowerCase().includes(lowerKeyword)) - ); - setFilteredData(filtered); - }; + const filteredData = useMemo(() => { + const lowerKeyword = keyword.trim().toLowerCase(); + if (!lowerKeyword) return data; + return data.filter((item) => + item.username.toLowerCase().includes(lowerKeyword) + || item.email?.toLowerCase().includes(lowerKeyword) + || item.full_name?.toLowerCase().includes(lowerKeyword)); + }, [data, keyword]); const handleStatusChange = async (record: UserItem, checked: boolean) => { try { await request.put(`/users/${record.id}/status`, { is_active: checked }); - toast.success(`用户 ${record.username} 状态更新成功`); - // Update local state - const newData = data.map(item => item.id === record.id ? { ...item, is_active: checked } : item); - setData(newData); - setFilteredData(newData); // Also update filtered view if needed, simplified here - loadData(); // Reload to be sure - } catch (error) { - console.error(error); + setData((current) => current.map((item) => (item.id === record.id ? { ...item, is_active: checked } : item))); + toast.success(`用户 ${record.username} 状态已更新`); + } catch { toast.error('状态更新失败'); } }; @@ -73,79 +75,89 @@ export function Users() { try { await request.post(`/users/${record.id}/reset-password`); toast.success(`用户 ${record.username} 密码已重置`); - } catch (error) { + } catch { toast.error('密码重置失败'); } }; const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 70, sorter: (a, b) => a.id - b.id }, { - title: 'ID', - dataIndex: 'id', - width: 80, - sorter: (a, b) => a.id - b.id, - }, - { - title: '用户名', + title: '用户', dataIndex: 'username', sorter: (a, b) => a.username.localeCompare(b.username), - }, - { - title: '姓名', - dataIndex: 'full_name', + render: (username: string, record) => ( +
+
{username}
+ {record.full_name ?
{record.full_name}
: null} +
+ ), }, { title: '邮箱', dataIndex: 'email', + render: (email: string | null) => email || 未填写, }, { title: '角色', dataIndex: 'roles', - render: (roles: string[]) => roles.join(', '), + width: 120, + render: (roles: string[]) => ( + roles.length > 0 + ? roles.map((role) => ( + + {ROLE_LABELS[role] ?? role} + + )) + : - + ), }, { title: '最近登录', dataIndex: 'last_login_at', - render: (text) => text ? new Date(text).toLocaleString() : '从未', + width: 180, + render: (text: string | null) => (text ? new Date(text).toLocaleString('zh-CN') : 从未登录), }, { title: '注册时间', dataIndex: 'created_at', - render: (text) => new Date(text).toLocaleDateString(), - }, - { - title: '操作', - key: 'action', - width: 120, - render: (_, record) => ( - handleResetPassword(record)} - okText="确认" - cancelText="取消" - > - - - ), + width: 130, + render: (text: string) => new Date(text).toLocaleDateString('zh-CN'), }, ]; return ( - + } + title="用户管理" description="查看平台注册用户、启用状态与角色,并可重置密码"> + void loadData()} + onSearch={setKeyword} + searchPlaceholder="搜索用户名 / 姓名 / 邮箱" + onStatusChange={handleStatusChange} + statusField="is_active" + rowKey="id" + pageSize={systemPageSize} + showAdd={false} + showEdit={false} + customActions={(record) => ( + handleResetPassword(record)} + okText="确认" + cancelText="取消" + > + + -
+
({(icon.file_size / 1024).toFixed(2)} KB)
@@ -108,7 +100,7 @@ export function ResourceManager({
{resource.file_path} - + ({(resource.file_size / 1024).toFixed(2)} KB) onDelete(resource.id)} okText="删除" cancelText="取消"> @@ -118,7 +110,7 @@ export function ResourceManager({ {key === 'model' && (
- 显示缩放: + 显示缩放: toast.error('更新失败')); }} /> - (推荐: Webb=0.3, 旅行者=1.5) + (推荐: Webb=0.3, 旅行者=1.5)
)} diff --git a/frontend/src/pages/admin/nasa-download/NasaDownloadView.tsx b/frontend/src/pages/admin/nasa-download/NasaDownloadView.tsx index 0364e2d..d30bac9 100644 --- a/frontend/src/pages/admin/nasa-download/NasaDownloadView.tsx +++ b/frontend/src/pages/admin/nasa-download/NasaDownloadView.tsx @@ -1,7 +1,17 @@ -import { Alert, Badge, Button, Card, Calendar, Checkbox, Col, Collapse, DatePicker, Modal, Progress, Row, Space, Spin, Table, Tag, Typography } from 'antd'; +/** + * NASA 位置数据下载视图 + * + * 左侧选择天体,右侧选择日期区间并查看每日数据可用性; + * 点击日历中的日期可直接下载该天的位置数据。 + */ +import { + Badge, Button, Calendar, Card, Checkbox, Col, Collapse, DatePicker, Modal, + Progress, Row, Space, Spin, Table, Tag, Tooltip, Typography, +} from 'antd'; import { DeleteOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons'; import type { Dayjs } from 'dayjs'; +import { AdminPage } from '../../../components/admin/AdminPage'; import type { DateRange, GroupedBodies, ViewingDateData } from './types'; const { Text } = Typography; @@ -45,93 +55,185 @@ export function NasaDownloadView({ const body = allBodies.find((item) => item.id === bodyId); return `${body?.name_zh || body?.name || bodyId} (${bodyId})`; }; + const actionsDisabled = selectedBodies.length === 0 || downloading || deleting; return ( -
- {cutoffDate && ( - - )} - - -
- 已选择: {selectedBodies.length}}> - bodies[type]?.length).map((type) => { - const typeBodies = bodies[type]; - return { - key: type, - label: ( -
- {typeNames[type] || type} - selectedBodies.includes(body.id))} - indeterminate={typeBodies.some((body) => selectedBodies.includes(body.id)) && !typeBodies.every((body) => selectedBodies.includes(body.id))} - onChange={(event) => { event.stopPropagation(); onTypeSelectAll(type, event.target.checked); }} - >全选 -
- ), - children: ( - - {typeBodies.map((body) => ( - onBodySelect(body.id, event.target.checked)}> - {body.name_zh || body.name} ({body.id}) - {!body.is_active && } + } + title="NASA 数据下载" + description="按天补全天体的位置数据(00:00 UTC),下载任务可在“系统任务”中查看进度" + meta={ + cutoffDate ? ( + <> + + 数据截止日期: + {`${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`} + + 选择左侧天体后,右侧日历显示数据可用性;点击未下载的日期即可下载该天数据。 + + ) : undefined + } + actions={ + <> + + + + + + + + + } + > + +
+ 选择天体} + extra={已选 {selectedBodies.length} 个} + loading={loading} + style={{ height: 620 }} + > +
+ bodies[type]?.length).map((type) => { + const typeBodies = bodies[type]; + const selectedCount = typeBodies.filter((body) => selectedBodies.includes(body.id)).length; + return { + key: type, + label: ( +
+ + {typeNames[type] || type} + + {selectedCount}/{typeBodies.length} + + + 0 && selectedCount < typeBodies.length} + onChange={(event) => { event.stopPropagation(); onTypeSelectAll(type, event.target.checked); }} + > + 全选 - ))} - - ), - }; - })} - /> +
+ ), + children: ( + + {typeBodies.map((body) => ( + onBodySelect(body.id, event.target.checked)} + > + {body.name_zh || body.name} ({body.id}) + {!body.is_active && } + + ))} + + ), + }; + })} + /> +
-
- - - - - - )} - > + + 选择日期}> }> - {selectedBodies.length > 1 && ( + {selectedBodies.length > 0 ? (
- 天体列表: - {selectedBodies.map((bodyId) => onActiveBodyChange(bodyId)}>{getBodyLabel(bodyId)})} -
点击标签切换查看不同天体的数据状态
+ {selectedBodies.length > 1 ? '天体列表:' : '当前天体:'} + + {selectedBodies.map((bodyId) => ( + onActiveBodyChange(bodyId)} + > + {getBodyLabel(bodyId)} + + ))} + + {selectedBodies.length > 1 ? ( +
点击标签可切换查看不同天体的数据状态
+ ) : null} +
+ ) : ( +
请先在左侧选择至少一个天体
+ )} + +
+ + + + +
+ + {downloading && ( +
+ + 正在下载:{downloadProgress.current} / {downloadProgress.total}
)} - {selectedBodies.length === 1 && activeBodyForCalendar &&
当前天体:{getBodyLabel(activeBodyForCalendar)}
} -
- {downloading &&
正在下载: {downloadProgress.current} / {downloadProgress.total}
} - + +
- - {viewingDateData && (
value?.toFixed(6) || '-' }, - { title: 'Y (AU)', dataIndex: 'y', key: 'y', render: (value?: number) => value?.toFixed(6) || '-' }, - { title: 'Z (AU)', dataIndex: 'z', key: 'z', render: (value?: number) => value?.toFixed(6) || '-' }, - { title: 'VX (AU/day)', dataIndex: 'vx', key: 'vx', render: (value?: number) => value?.toFixed(8) || '-' }, - { title: 'VY (AU/day)', dataIndex: 'vy', key: 'vy', render: (value?: number) => value?.toFixed(8) || '-' }, - { title: 'VZ (AU/day)', dataIndex: 'vz', key: 'vz', render: (value?: number) => value?.toFixed(8) || '-' }, - ]} /> {viewingDateData.bodies.length === 0 &&
该日期暂无数据
})} + 关闭} + width={900} + destroyOnHidden + > + {viewingDateData && ( + +
value?.toFixed(6) || '-' }, + { title: 'Y (AU)', dataIndex: 'y', key: 'y', render: (value?: number) => value?.toFixed(6) || '-' }, + { title: 'Z (AU)', dataIndex: 'z', key: 'z', render: (value?: number) => value?.toFixed(6) || '-' }, + { title: 'VX (AU/day)', dataIndex: 'vx', key: 'vx', render: (value?: number) => value?.toFixed(8) || '-' }, + { title: 'VY (AU/day)', dataIndex: 'vy', key: 'vy', render: (value?: number) => value?.toFixed(8) || '-' }, + { title: 'VZ (AU/day)', dataIndex: 'vz', key: 'vz', render: (value?: number) => value?.toFixed(8) || '-' }, + ]} + /> + + )} - + ); } diff --git a/frontend/src/pages/admin/scheduled-jobs/ScheduledJobModal.tsx b/frontend/src/pages/admin/scheduled-jobs/ScheduledJobModal.tsx index 24c5f66..d6aa211 100644 --- a/frontend/src/pages/admin/scheduled-jobs/ScheduledJobModal.tsx +++ b/frontend/src/pages/admin/scheduled-jobs/ScheduledJobModal.tsx @@ -32,15 +32,15 @@ function CodeEditor({ const lineNumbers = Array.from({ length: lineCount }, (_, index) => index + 1).join('\n'); return ( -
-
+
+
{lineNumbers}
- Cron 表达式 - + )} rules={[{ required: true, message: '请输入 Cron 表达式' }]} > - + @@ -125,7 +125,7 @@ export function ScheduledJobModal({ {selectedTask && ( 任务参数配置} style={{ marginBottom: 16 }}> - + {selectedTask.parameters.map((parameter) => (

可用变量:

@@ -179,7 +179,16 @@ export function ScheduledJobModal({ ); return ( - + void }) { - return ( - - {record && ( +import type { StarSystemBody, StarSystemWithBodies } from './types'; + +const BODY_TYPE_LABELS: Record = { + star: '恒星', + planet: '行星', + dwarf_planet: '矮行星', + satellite: '卫星', + comet: '彗星', + probe: '探测器', +}; + +export function StarSystemDetailsModal({ + record, + open, + onClose, +}: { + record: StarSystemWithBodies | null; + open: boolean; + onClose: () => void; +}) { + const columns: ColumnsType = [ + { + title: '天体', + key: 'name', + render: (_, body) => (
+
{body.name_zh || body.name}
+
{body.id}
+
+ ), + }, + { + title: '类型', + dataIndex: 'type', + width: 110, + render: (type: string) => {BODY_TYPE_LABELS[type] ?? type}, + }, + { + title: '轨道参数', + key: 'orbit', + width: 320, + render: (_, body) => ( +
+ {body.extra_data?.semi_major_axis_au != null ?
半长轴:{body.extra_data.semi_major_axis_au.toFixed(4)} AU
: null} + {body.extra_data?.period_days != null ?
公转周期:{body.extra_data.period_days.toFixed(2)} 天
: null} + {body.extra_data?.radius_earth != null ?
半径:{body.extra_data.radius_earth.toFixed(2)} R⊕
: null} +
+ ), + }, + { title: '描述', dataIndex: 'description', ellipsis: true }, + ]; + + return ( + 关闭} + width={900} + destroyOnHidden + > + {record ? ( +
- {record.id} + {record.id} {record.host_star_name} - {record.distance_pc ? `${record.distance_pc.toFixed(2)} pc (~${(record.distance_ly || record.distance_pc * 3.26).toFixed(2)} ly)` : '-'} + + {record.distance_pc + ? `${record.distance_pc.toFixed(2)} pc(约 ${(record.distance_ly || record.distance_pc * 3.26).toFixed(2)} ly)` + : '-'} + {record.spectral_type || '-'} - {record.radius_solar ? `${record.radius_solar.toFixed(2)} R☉` : '-'} - {record.mass_solar ? `${record.mass_solar.toFixed(2)} M☉` : '-'} - {record.temperature_k ? `${record.temperature_k.toFixed(0)} K` : '-'} + + {record.radius_solar ? `${record.radius_solar.toFixed(2)} R☉` : '-'} + + + {record.mass_solar ? `${record.mass_solar.toFixed(2)} M☉` : '-'} + + + {record.temperature_k ? `${record.temperature_k.toFixed(0)} K` : '-'} + {record.body_count} - {record.bodies.length > 0 && ( -
-

天体列表

-
- {record.bodies.map((body) => ( -
-
-
{body.name_zh || body.name}
{body.id}
- {body.type} -
- {body.description &&
{body.description}
} - {body.extra_data &&
- {body.extra_data.semi_major_axis_au &&
半长轴: {body.extra_data.semi_major_axis_au.toFixed(4)} AU
} - {body.extra_data.period_days &&
周期: {body.extra_data.period_days.toFixed(2)} 天
} - {body.extra_data.radius_earth &&
半径: {body.extra_data.radius_earth.toFixed(2)} R⊕
} -
} -
- ))} -
-
- )} +
}} + /> - )} + ) : null} ); } diff --git a/frontend/src/pages/admin/star-systems/StarSystemModal.tsx b/frontend/src/pages/admin/star-systems/StarSystemModal.tsx index 81bd4b2..9c155b9 100644 --- a/frontend/src/pages/admin/star-systems/StarSystemModal.tsx +++ b/frontend/src/pages/admin/star-systems/StarSystemModal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Form, Input, InputNumber, Modal, Tabs } from 'antd'; +import { Col, Form, Input, InputNumber, Modal, Row, Tabs } from 'antd'; import type { FormInstance } from 'antd'; import MdEditor from 'react-markdown-editor-lite'; import MarkdownIt from 'markdown-it'; @@ -18,37 +18,39 @@ interface StarSystemModalProps { } function StarSystemFields() { + const field = (span: number, node: React.ReactNode) => {node}; + return ( <> -
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - -
+ + {field(8, )} + {field(8, )} + {field(8, )} + + + {field(8, )} + {field(8, )} + {field(8, )} + + + {field(8, )} + {field(8, )} + {field(8, )} + + + {field(8, )} + {field(8, )} + {field(8, )} + + + {field(8, )} + {field(8, )} + {field(8, )} + + + {field(8, )} + {field(8, )} + ); @@ -58,7 +60,16 @@ export function StarSystemModal({ form, record, open, onOk, onCancel }: StarSyst const [activeTab, setActiveTab] = useState('basic'); return ( - + {record ? ( ('page_size', fallback); + const size = Number(value); + if (!Number.isFinite(size) || size < MIN_PAGE_SIZE || size > MAX_PAGE_SIZE) return fallback; + return Math.floor(size); +} diff --git a/backend/scripts/create_db.py b/scripts/create_db.py similarity index 88% rename from backend/scripts/create_db.py rename to scripts/create_db.py index 17a7b98..628bc8b 100644 --- a/backend/scripts/create_db.py +++ b/scripts/create_db.py @@ -6,10 +6,14 @@ Usage: python scripts/create_db.py """ import asyncio +import os import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent)) +# Scripts live in /scripts; app code and .env live in /backend +BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend" +sys.path.insert(0, str(BACKEND_DIR)) +os.chdir(BACKEND_DIR) from app.config import settings import asyncpg diff --git a/deploy.sh b/scripts/deploy.sh similarity index 93% rename from deploy.sh rename to scripts/deploy.sh index af60b56..16114ea 100755 --- a/deploy.sh +++ b/scripts/deploy.sh @@ -1,7 +1,7 @@ #!/bin/bash # Cosmo Docker Deployment Script -# Usage: ./deploy.sh [--init|--start|--stop|--restart|--logs|--clean] +# Usage: ./scripts/deploy.sh [--init|--start|--stop|--restart|--logs|--clean] set -e @@ -12,8 +12,8 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color -# Project root directory -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Project root directory (this script lives in /scripts) +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DATA_ROOT="/opt/cosmo/data" # Log function @@ -122,7 +122,7 @@ init_system() { log "" log "Data stored at: $DATA_ROOT" log "" - log "Run './deploy.sh --logs' to view logs" + log "Run './scripts/deploy.sh --logs' to view logs" } # Start services @@ -255,7 +255,7 @@ show_help() { cat << EOF Cosmo Docker Deployment Script -Usage: ./deploy.sh [OPTION] +Usage: ./scripts/deploy.sh [OPTION] Options: --init Initialize and start the system (first time setup) @@ -279,10 +279,10 @@ Data Location: - backups/ Backup archives Examples: - ./deploy.sh --init # First time setup - ./deploy.sh --start # Start services - ./deploy.sh --logs # View logs - ./deploy.sh --backup # Create backup + ./scripts/deploy.sh --init # First time setup + ./scripts/deploy.sh --start # Start services + ./scripts/deploy.sh --logs # View logs + ./scripts/deploy.sh --backup # Create backup EOF } diff --git a/backend/scripts/init_db.py b/scripts/init_db.py similarity index 91% rename from backend/scripts/init_db.py rename to scripts/init_db.py index 9c33321..d11e4a6 100755 --- a/backend/scripts/init_db.py +++ b/scripts/init_db.py @@ -8,11 +8,14 @@ Usage: python scripts/init_db.py """ import asyncio +import os import sys from pathlib import Path -# Add parent directory to path to import app modules -sys.path.insert(0, str(Path(__file__).parent.parent)) +# Scripts live in /scripts; app code and .env live in /backend +BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend" +sys.path.insert(0, str(BACKEND_DIR)) +os.chdir(BACKEND_DIR) from app.database import init_db, close_db, engine from app.config import settings diff --git a/backend/scripts/init_db.sql b/scripts/init_db.sql similarity index 100% rename from backend/scripts/init_db.sql rename to scripts/init_db.sql diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..2003624 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,244 @@ +#!/bin/bash +# +# Cosmo 开发环境一键启动脚本 +# +# 先检查运行环境(Python / Node / 依赖 / 配置 / 数据库 / Redis / 端口), +# 检查通过后启动: +# - 后端 FastAPI http://localhost:8000 (API 前缀 /api) +# - 前端 Vite http://localhost:5173 +# +# 用法: +# ./scripts/run.sh # 检查环境并启动前后端(Ctrl+C 停止) +# ./scripts/run.sh --check # 只检查环境,不启动服务 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKEND_DIR="$PROJECT_ROOT/backend" +FRONTEND_DIR="$PROJECT_ROOT/frontend" + +BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}" +BACKEND_PORT="${BACKEND_PORT:-8000}" +FRONTEND_PORT="${PORT:-5173}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +ok() { echo -e "${GREEN}[ OK ]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +fail() { echo -e "${RED}[FAIL]${NC} $1"; exit 1; } + +CHECK_ONLY=0 +if [ "${1:-}" = "--check" ] || [ "${1:-}" = "-c" ]; then + CHECK_ONLY=1 +fi + +print_header() { + echo "=================================================================" + echo " Cosmo 开发环境启动脚本" + echo " 项目目录: $PROJECT_ROOT" + echo "=================================================================" +} + +# ---------------------------------------------------------------- 后端环境 + +PYTHON="" + +check_backend_env() { + info "检查后端环境..." + + if [ -x "$BACKEND_DIR/venv/bin/python" ]; then + PYTHON="$BACKEND_DIR/venv/bin/python" + else + PYTHON="$(command -v python3 || true)" + [ -n "$PYTHON" ] || fail "未找到 python3,请先安装 Python 3.11+" + info "未找到 backend/venv,正在创建虚拟环境..." + "$PYTHON" -m venv "$BACKEND_DIR/venv" || fail "创建虚拟环境失败" + PYTHON="$BACKEND_DIR/venv/bin/python" + fi + + "$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)' \ + || fail "Python 版本过低(需 3.11+): $("$PYTHON" --version 2>&1)" + ok "Python: $("$PYTHON" --version 2>&1) ($PYTHON)" + + if ! "$PYTHON" -c 'import fastapi, uvicorn, sqlalchemy, asyncpg' &> /dev/null; then + info "后端依赖不完整,正在安装 requirements.txt..." + "$PYTHON" -m pip install -r "$BACKEND_DIR/requirements.txt" || fail "依赖安装失败" + fi + ok "后端依赖已就绪" + + if [ ! -f "$BACKEND_DIR/.env" ]; then + if [ -f "$BACKEND_DIR/.env.example" ]; then + cp "$BACKEND_DIR/.env.example" "$BACKEND_DIR/.env" + warn "已从 .env.example 生成 backend/.env,请核对数据库等配置" + else + fail "缺少 backend/.env,且未找到 backend/.env.example" + fi + fi + ok "配置文件: backend/.env" +} + +# ---------------------------------------------------------------- 前端环境 + +check_frontend_env() { + info "检查前端环境..." + + command -v node &> /dev/null || fail "未找到 node,请先安装 Node.js 20+" + local node_major + node_major="$(node -p 'process.versions.node.split(".")[0]')" + if [ "$node_major" -lt 20 ]; then + fail "Node.js 版本过低(需 20+): $(node --version)" + fi + ok "Node.js: $(node --version)" + + command -v yarn &> /dev/null || fail "未找到 yarn,请先安装 Yarn" + ok "Yarn: $(yarn --version)" + + if [ ! -d "$FRONTEND_DIR/node_modules" ]; then + info "未找到 frontend/node_modules,正在安装前端依赖..." + (cd "$FRONTEND_DIR" && yarn install --ignore-engines) || fail "前端依赖安装失败" + fi + ok "前端依赖已就绪" +} + +# ------------------------------------------------------- 数据库 / Redis / 端口 + +check_services() { + info "检查数据库与 Redis 连接..." + + local output status + set +e + output="$(cd "$BACKEND_DIR" && "$PYTHON" - <<'PY' 2>&1 +import socket +import sys + +from app.config import settings + + +def reachable(host: str, port: int, timeout: float = 3.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +database_ok = reachable(settings.database_host, settings.database_port) +redis_ok = reachable(settings.redis_host, settings.redis_port) + +print(f"database|{settings.database_host}:{settings.database_port}|{'ok' if database_ok else 'fail'}") +print(f"redis|{settings.redis_host}:{settings.redis_port}|{'ok' if redis_ok else 'fail'}") + +sys.exit(0 if database_ok else 1) +PY +)" + status=$? + set -e + + echo "$output" | sed -u 's/^/ /' + + [ "$status" -eq 0 ] || fail "无法连接 PostgreSQL,请先启动数据库(如 docker compose up -d postgres redis)" + ok "PostgreSQL 连接正常" + + if echo "$output" | grep -q '^redis|.*|fail$'; then + warn "Redis 未连接,后端将降级为内存缓存" + else + ok "Redis 连接正常" + fi +} + +port_in_use() { + "$PYTHON" - "$1" <<'PY' +import socket +import sys + +with socket.socket() as sock: + sock.settimeout(1.0) + sys.exit(0 if sock.connect_ex(("127.0.0.1", int(sys.argv[1]))) == 0 else 1) +PY +} + +# ---------------------------------------------------------------- 服务启动 + +PIDS=() + +start_backend() { + info "启动后端: http://localhost:$BACKEND_PORT (API 文档 /api/docs)" + (cd "$BACKEND_DIR" && exec "$PYTHON" -m uvicorn app.main:app \ + --host "$BACKEND_HOST" --port "$BACKEND_PORT" --reload) \ + > >(sed -u 's/^/[后端] /') 2>&1 & + PIDS+=("$!") +} + +start_frontend() { + info "启动前端: http://localhost:$FRONTEND_PORT" + (cd "$FRONTEND_DIR" && PORT="$FRONTEND_PORT" exec yarn dev) \ + > >(sed -u 's/^/[前端] /') 2>&1 & + PIDS+=("$!") +} + +cleanup() { + trap - INT TERM + echo + info "正在停止服务..." + if [ "${#PIDS[@]}" -gt 0 ]; then + for pid in "${PIDS[@]}"; do + kill "$pid" 2> /dev/null || true + done + fi + wait 2> /dev/null || true + ok "服务已停止" +} + +main() { + print_header + + check_backend_env + check_frontend_env + check_services + + info "检查端口占用..." + local backend_free=1 frontend_free=1 + if port_in_use "$BACKEND_PORT"; then + warn "端口 $BACKEND_PORT 已被占用,跳过启动后端" + backend_free=0 + fi + if port_in_use "$FRONTEND_PORT"; then + warn "端口 $FRONTEND_PORT 已被占用,跳过启动前端" + frontend_free=0 + fi + if [ "$backend_free" = 1 ] && [ "$frontend_free" = 1 ]; then + ok "端口 $BACKEND_PORT / $FRONTEND_PORT 均可用" + fi + + if [ "$CHECK_ONLY" = 1 ]; then + echo + ok "环境检查完成" + exit 0 + fi + + echo + if [ "$backend_free" = 1 ]; then + start_backend + fi + if [ "$frontend_free" = 1 ]; then + start_frontend + fi + + if [ "${#PIDS[@]}" -eq 0 ]; then + warn "前后端均已在运行,未启动新服务" + exit 0 + fi + + echo + ok "启动完成,按 Ctrl+C 停止服务" + trap cleanup INT TERM + wait +} + +main "$@" diff --git a/backend/scripts/seed_admin.py b/scripts/seed_admin.py similarity index 97% rename from backend/scripts/seed_admin.py rename to scripts/seed_admin.py index 1c6b354..f38c973 100644 --- a/backend/scripts/seed_admin.py +++ b/scripts/seed_admin.py @@ -11,10 +11,14 @@ Usage: python scripts/seed_admin.py """ import asyncio +import os import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent)) +# Scripts live in /scripts; app code and .env live in /backend +BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend" +sys.path.insert(0, str(BACKEND_DIR)) +os.chdir(BACKEND_DIR) from sqlalchemy import select from app.database import AsyncSessionLocal diff --git a/backend/scripts/setup.sh b/scripts/setup.sh similarity index 82% rename from backend/scripts/setup.sh rename to scripts/setup.sh index 8da63e8..cc22483 100755 --- a/backend/scripts/setup.sh +++ b/scripts/setup.sh @@ -3,6 +3,16 @@ set -e # 遇到错误立即退出 +# 脚本位于 /scripts,后端代码位于 /backend +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_DIR="$PROJECT_ROOT/backend" + +# 优先使用后端虚拟环境 +PYTHON="$BACKEND_DIR/venv/bin/python" +if [ ! -x "$PYTHON" ]; then + PYTHON="$(command -v python3)" +fi + # 颜色定义 RED='\033[0;31m' GREEN='\033[0;32m' @@ -89,7 +99,7 @@ check_redis() { check_dependencies() { log_info "检查 Python 依赖包..." - cd "$(dirname "$0")/.." # 切换到 backend 目录 + cd "$BACKEND_DIR" # 检查 requirements.txt 是否存在 if [ ! -f "requirements.txt" ]; then @@ -98,9 +108,9 @@ check_dependencies() { fi # 检查关键依赖是否已安装 - if ! python3 -c "import fastapi" &> /dev/null; then + if ! "$PYTHON" -c "import fastapi" &> /dev/null; then log_warning "依赖包未完全安装,正在安装..." - pip install -r requirements.txt + "$PYTHON" -m pip install -r requirements.txt log_success "依赖包安装完成" else log_success "依赖包已安装" @@ -111,7 +121,7 @@ check_dependencies() { check_env_file() { log_info "检查配置文件..." - cd "$(dirname "$0")/.." # 确保在 backend 目录 + cd "$BACKEND_DIR" if [ ! -f ".env" ]; then log_warning ".env 文件不存在,从 .env.example 创建..." @@ -131,9 +141,9 @@ check_env_file() { create_database() { log_info "创建数据库..." - cd "$(dirname "$0")/.." # 确保在 backend 目录 + cd "$BACKEND_DIR" - if python3 scripts/create_db.py; then + if "$PYTHON" "$PROJECT_ROOT/scripts/create_db.py"; then log_success "数据库创建完成" else log_error "数据库创建失败" @@ -145,9 +155,9 @@ create_database() { init_database() { log_info "初始化数据库表结构..." - cd "$(dirname "$0")/.." # 确保在 backend 目录 + cd "$BACKEND_DIR" - if python3 scripts/init_db.py; then + if "$PYTHON" "$PROJECT_ROOT/scripts/init_db.py"; then log_success "数据库表结构初始化完成" else log_error "数据库表结构初始化失败" @@ -155,11 +165,25 @@ init_database() { fi } +# 初始化默认管理员、角色与菜单 +seed_admin_data() { + log_info "初始化默认管理员数据..." + + cd "$BACKEND_DIR" + + if "$PYTHON" "$PROJECT_ROOT/scripts/seed_admin.py"; then + log_success "默认管理员数据初始化完成" + else + log_error "默认管理员数据初始化失败" + exit 1 + fi +} + # 创建上传目录 create_upload_dir() { log_info "创建上传目录..." - cd "$(dirname "$0")/.." # 确保在 backend 目录 + cd "$BACKEND_DIR" if [ ! -d "upload" ]; then mkdir -p upload @@ -209,6 +233,7 @@ main() { # 4. 数据库初始化 create_database init_database + seed_admin_data # 5. 创建必要目录 create_upload_dir