优化了样式

main
mula.liu 2026-09-20 13:48:43 +08:00
parent 5433892bf6
commit 0d38e1da47
64 changed files with 6073 additions and 4661 deletions

View File

@ -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
```
**前端初始化**

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1 +0,0 @@
- tools

View File

@ -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)
## 🎯 性能优化建议

View File

@ -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生产环境

View File

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

371
README.md
View File

@ -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 倍的固定比例放大,等整个流程跑通了,再加入动态缩放的逻辑来提升体验。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <path_to_sql_file>")
sys.exit(1)
sql_file = sys.argv[1]
asyncio.run(run_sql_file(sql_file))

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
其余文档按用途归档:

View File

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

View File

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

View File

@ -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 <Navigate to="/login" replace />;
}
if (adminOnly) {
const roles = (auth.getUser()?.roles as string[] | undefined) ?? [];
if (!roles.includes('admin')) {
return <Navigate to="/user/profile" replace />;
}
}
return <>{children}</>;
}
@ -65,19 +75,21 @@ export function Router() {
>
<Route path="profile" element={<UserProfile />} />
<Route path="follow" element={<MyCelestialBodies />} />
{/* 修改密码已合并到个人资料页,旧链接重定向过去 */}
<Route path="change-password" element={<Navigate to="/user/profile" replace />} />
</Route>
{/* Admin routes (protected) */}
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
<ProtectedRoute adminOnly>
<AdminLayout />
</ProtectedRoute>
}
>
<Route path="dashboard" element={<Dashboard />} />
<Route path="change-password" element={<ChangePassword />} />
<Route path="change-password" element={<Navigate to="/user/profile" replace />} />
<Route path="celestial-bodies" element={<CelestialBodies />} />
<Route path="celestial-events" element={<CelestialEvents />} />
<Route path="star-systems" element={<StarSystems />} />

View File

@ -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 (
<div className="adm-page">
<header className="adm-page-head">
<div className="adm-page-head-main">
{icon ? <span className="adm-page-icon">{icon}</span> : null}
<div>
<h1 className="adm-page-title">{t(title)}</h1>
{description ? <p className="adm-page-desc">{t(description)}</p> : null}
{meta ? <div className="adm-page-meta">{meta}</div> : null}
</div>
</div>
{actions ? <div className="adm-page-actions">{actions}</div> : null}
</header>
<div className="adm-page-body">{children}</div>
</div>
);
}
/**
* 使
*/
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 (
<div className="adm-stat-card">
<div className="adm-stat-head">
<span className="adm-stat-icon">{icon}</span>
<span className="adm-stat-label">{t(label)}</span>
</div>
<div className="adm-stat-value">
{loading ? '—' : value}
{unit && !loading ? <em>{t(unit)}</em> : null}
</div>
{footnote ? <div className="adm-stat-foot">{t(footnote)}</div> : null}
</div>
);
}

View File

@ -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<T> {
title?: string;
title?: ReactNode;
description?: ReactNode;
columns: ColumnsType<T>;
dataSource: T[];
loading?: boolean;
@ -15,20 +25,28 @@ interface DataTableProps<T> {
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<T>['scroll'];
}
export function DataTable<T extends object>({
title,
description,
columns,
dataSource,
loading,
@ -39,79 +57,79 @@ export function DataTable<T extends object>({
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<T>) {
// Inject action columns if callbacks are provided
const tableColumns: ColumnsType<T> = [
...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<T> = 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) => (
<Switch
checked={value}
onChange={(checked) => onStatusChange(record, checked)}
size="small"
/>
<Tooltip title={value ? '点击停用' : '点击启用'}>
<Switch checked={value} onChange={(checked) => onStatusChange(record, checked)} size="small" />
</Tooltip>
),
});
}
// 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) => (
<Space size="middle">
{customActions && customActions(record)}
<Space size={4}>
{customActions?.(record)}
{onEdit && showEdit && (
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
size="small"
className="text-blue-600 hover:text-blue-500"
onClick={() => onEdit(record)}
/>
<Button type="text" icon={<EditOutlined />} size="small" onClick={() => onEdit(record)} />
</Tooltip>
)}
{onDelete && (
<Popconfirm
title="确认删除?"
description="此操作无法撤销"
title={t(deleteConfirmTitle)}
description={t(deleteConfirmDescription)}
onConfirm={() => onDelete(record)}
okText="删除"
cancelText="取消"
okText={t('删除')}
cancelText={t('取消')}
okButtonProps={{ danger: true }}
>
<Tooltip title="删除">
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
danger
/>
<Button type="text" icon={<DeleteOutlined />} size="small" danger />
</Tooltip>
</Popconfirm>
)}
@ -120,52 +138,78 @@ export function DataTable<T extends object>({
});
}
const toolbarNode = (
<div className="adm-table-toolbar">
<div className="adm-table-toolbar-left">
{onSearch && (
<Input.Search
placeholder={t(searchPlaceholder)}
allowClear
onSearch={onSearch}
onChange={(event) => {
if (!event.target.value) onSearch('');
}}
style={{ width: 240 }}
/>
)}
</div>
<div className="adm-table-toolbar-right">
{toolbar}
{onRefresh && (
<Tooltip title={t('刷新')}>
<Button icon={<ReloadOutlined />} onClick={onRefresh} />
</Tooltip>
)}
{onAdd && showAdd && (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{t(addText)}
</Button>
)}
</div>
</div>
);
return (
<Card
title={title}
extra={
<Space>
{onSearch && (
<Input.Search
placeholder={searchPlaceholder}
allowClear
onSearch={onSearch}
onChange={(e) => {
if (!e.target.value) onSearch('');
}}
style={{ width: 250 }}
/>
)}
{onAdd && showAdd && (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
)}
</Space>
className="adm-panel"
title={
title ? (
<div>
<div>{title}</div>
{description ? <div className="adm-cell-sub" style={{ fontWeight: 400 }}>{description}</div> : null}
</div>
) : undefined
}
extra={onSearch || onAdd || toolbar ? toolbarNode : undefined}
styles={{ body: { padding: 0 } }}
>
<Table
className="adm-table"
columns={tableColumns}
dataSource={dataSource}
loading={loading}
rowKey={rowKey}
pagination={
onPageChange
? {
current: currentPage,
pageSize: pageSize,
total: total,
onChange: onPageChange,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}
: {
defaultPageSize: pageSize,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}
}
locale={{
emptyText: (
<Empty
className="adm-table-empty"
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('暂无数据')}
/>
),
}}
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}
/>
</Card>

View File

@ -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 (
<group>
{/* 混凝土地坪 */}
<mesh position={[0, -0.74, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[130, 64]} />
<meshStandardMaterial color="#2a3630" metalness={0} roughness={1} />
</mesh>
<mesh position={[0, -0.7, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[26, 48]} />
<meshStandardMaterial color="#4a5350" metalness={0.05} roughness={0.95} />
</mesh>
{/* 尾焰熏黑区域 */}
<mesh position={[0, -0.69, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[8.5, 32]} />
<meshStandardMaterial color="#171b1a" metalness={0.1} roughness={1} />
</mesh>
{/* 发射台 + 导流槽 */}
<mesh position={[0, -0.34, 0]} receiveShadow>
<boxGeometry args={[19, 0.7, 13]} />
<meshStandardMaterial color="#6d736f" metalness={0.08} roughness={0.92} />
</mesh>
<mesh position={[0, -0.5, 0]}>
<boxGeometry args={[7.4, 0.6, 15]} />
<meshStandardMaterial color="#1b1f21" metalness={0.3} roughness={0.7} />
</mesh>
{/* 导流锥 */}
<mesh position={[0, -0.18, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[3.1, 1.1, 4]} />
<meshStandardMaterial color="#343b3f" metalness={0.4} roughness={0.6} />
</mesh>
{/* 压紧机构 */}
{[[-2.6, -2.6], [2.6, -2.6], [-2.6, 2.6], [2.6, 2.6]].map(([x, z]) => (
<mesh key={`${x}-${z}`} position={[x, 0.1, z]}>
<boxGeometry args={[0.5, 0.9, 0.5]} />
<meshStandardMaterial color="#7c848a" metalness={0.5} roughness={0.5} />
</mesh>
))}
{/* 服务塔 */}
<group position={[-6.2, 0, 0]}>
{[-1.2, 1.2].map((offset) => (
<mesh key={offset} position={[offset, towerHeight / 2, 0]}>
<boxGeometry args={[0.42, towerHeight, 0.42]} />
<meshStandardMaterial color="#59636a" metalness={0.5} roughness={0.5} />
</mesh>
))}
{[3.2, 6.4, 9.6, 12.2].map((y) => (
<mesh key={y} position={[0, y, 0]}>
<boxGeometry args={[3.4, 0.28, 0.3]} />
<meshStandardMaterial color="#6b757b" metalness={0.45} roughness={0.55} />
</mesh>
))}
{/* 摆杆 / 加注臂 */}
{[5.4, 9.4].map((y, index) => (
<mesh key={y} position={[2.1 + (index === 0 ? 0.6 : 0), y, 0]}>
<boxGeometry args={[3.6, 0.24, 0.5]} />
<meshStandardMaterial color="#78838a" metalness={0.45} roughness={0.5} />
</mesh>
))}
{/* 塔顶工作平台(避雷针只装在四周的避雷塔上) */}
<mesh position={[0, towerHeight + 0.15, 0]}>
<boxGeometry args={[3.2, 0.3, 3.2]} />
<meshStandardMaterial color="#6b757b" metalness={0.45} roughness={0.55} />
</mesh>
{[-1.55, 1.55].map((offset) => (
<mesh key={offset} position={[offset, towerHeight + 0.85, 0]}>
<boxGeometry args={[0.1, 1.1, 3.2]} />
<meshStandardMaterial color="#828c92" metalness={0.4} roughness={0.6} transparent opacity={0.7} />
</mesh>
))}
<mesh position={[0, towerHeight + 0.85, -1.55]}>
<boxGeometry args={[3.2, 1.1, 0.1]} />
<meshStandardMaterial color="#828c92" metalness={0.4} roughness={0.6} transparent opacity={0.7} />
</mesh>
</group>
{/* 避雷塔(四根柱子顶部的避雷针) */}
{[[-14, -9], [14, -9], [-14, 9], [14, 9]].map(([x, z]) => (
<group key={`${x}-${z}`} position={[x, 0, z]}>
<mesh position={[0, 7.5, 0]}>
<cylinderGeometry args={[0.16, 0.28, 15, 8]} />
<meshStandardMaterial color="#4c555b" metalness={0.5} roughness={0.55} />
</mesh>
<mesh position={[0, 15.4, 0]}>
<cylinderGeometry args={[0.03, 0.07, 1.6, 6]} />
<meshStandardMaterial color="#aeb7bd" metalness={0.7} roughness={0.35} />
</mesh>
</group>
))}
{/* 场坪编号,让地坪有尺度参照 */}
<mesh position={[0, -0.68, 12]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[6, 1.2]} />
<meshStandardMaterial color="#8b949e" metalness={0} roughness={0.9} />
</mesh>
</group>
);
}
/**
* /
*
*/
function PadExhaust({ state }: { state: SimulationState }) {
const groupRef = useRef<THREE.Group>(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 (
<group ref={groupRef} visible={false}>
{puffs.map((_, index) => (
<mesh key={index}>
<sphereGeometry args={[1, 10, 8]} />
<meshBasicMaterial color="#dfe6ea" transparent opacity={0.2} depthWrite={false} />
</mesh>
))}
</group>
);
}
/** 地面场景容器:随飞行高度下移、随射程后移。 */
function LaunchRack({ state }: { state: SimulationState }) {
const groupRef = useRef<THREE.Group>(null);
@ -88,26 +239,8 @@ function LaunchRack({ state }: { state: SimulationState }) {
return (
<group ref={groupRef}>
<mesh position={[0, -0.72, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[120, 64]} />
<meshStandardMaterial color="#26342c" metalness={0} roughness={1} />
</mesh>
<mesh position={[0, -0.35, 0]} receiveShadow>
<boxGeometry args={[18, 0.7, 13]} />
<meshStandardMaterial color="#656b68" metalness={0.06} roughness={0.94} />
</mesh>
<group position={[-5.6, 0, 0]}>
<mesh position={[0, 5.5, 0]}>
<boxGeometry args={[0.65, 11, 0.8]} />
<meshStandardMaterial color="#505b60" metalness={0.52} roughness={0.5} />
</mesh>
{[5.4, 9.2].map((y) => (
<mesh key={y} position={[2.1, y, 0]}>
<boxGeometry args={[4.2, 0.26, 0.42]} />
<meshStandardMaterial color="#667176" metalness={0.5} roughness={0.48} />
</mesh>
))}
</group>
<LaunchPadStructure />
<PadExhaust state={state} />
</group>
);
}
@ -333,7 +466,9 @@ function SceneContents({ rocket, state, cameraMode, viewScale, viewScaleResetTri
const orbitControlsRef = useRef<OrbitControlsImpl>(null);
const manualDistanceRef = useRef<number | null>(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],

View File

@ -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
* 99 + 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 (
<mesh position={[0, y, 0]} rotation={[0, facing, 0]}>
<cylinderGeometry args={[radius * 1.004, radius * 1.004, height, 24, 1, true, -0.34, 0.68]} />
<meshStandardMaterial map={texture} transparent roughness={0.5} metalness={0.1} />
</mesh>
);
}
/** 箭体加强环:打断大面积圆柱面,让细长箭体有机械层次。 */
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 (
<group>
{ys.map((y) => (
<mesh key={y} position={[0, y, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[radius * 1.002, radius * 0.012, 6, 40]} />
<meshStandardMaterial color={METAL} metalness={0.75} roughness={0.4} />
</mesh>
))}
</group>
);
}
/** 电缆罩(贯穿箭体的走线通道),真实运载火箭的显著特征之一。 */
function Raceway({ radius, height, baseY = 0 }: { radius: number; height: number; baseY?: number }) {
return (
<mesh position={[radius * 0.99, baseY + height / 2, 0]} castShadow>
<boxGeometry args={[radius * 0.16, height * 0.82, radius * 0.34]} />
<meshStandardMaterial color={DARK} metalness={0.5} roughness={0.55} />
</mesh>
);
}
/** 单个喷管:收敛段 + 扩张段 + 喷管出口。 */
function Nozzle({ radius, length, color = METAL }: { radius: number; length: number; color?: string }) {
return (
<group>
<mesh position={[0, -length * 0.28, 0]}>
<cylinderGeometry args={[radius * 0.45, radius * 0.7, length * 0.44, 14, 1, true]} />
<meshStandardMaterial color={color} metalness={0.85} roughness={0.3} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, -length * 0.76, 0]}>
<cylinderGeometry args={[radius * 0.7, radius, length * 0.52, 16, 1, true]} />
<meshStandardMaterial color={shade(color, -0.25)} metalness={0.8} roughness={0.35} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
/**
* + +
* 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 (
<group>
{/* 尾段 / 隔热盘 */}
<mesh position={[0, radius * 0.34, 0]}>
<cylinderGeometry args={[radius * 0.86, radius, radius * 0.72, 32]} />
<meshStandardMaterial color={DARK} metalness={0.55} roughness={0.55} />
</mesh>
<mesh position={[0, -radius * 0.02, 0]} rotation={[Math.PI, 0, 0]}>
<circleGeometry args={[radius * 0.9, 32]} />
<meshStandardMaterial color="#2b3138" metalness={0.6} roughness={0.5} />
</mesh>
{placements.map((placement, index) => (
<group
key={index}
position={[placement.x, -radius * 0.16, placement.z]}
rotation={(() => {
// 外圈发动机沿径向向外偏摆(真实矢量布局)。
const angle = Math.atan2(placement.z, placement.x);
return [-Math.sin(angle) * placement.cant, 0, Math.cos(angle) * placement.cant];
})()}
>
<Nozzle radius={placement.bellRadius} length={placement.bellLength} color={bellColor} />
{showActuators && placement.bellRadius > radius * 0.2 && (
<mesh position={[placement.bellRadius * 0.9, placement.bellLength * 0.25, 0]}>
<boxGeometry args={[placement.bellRadius * 0.22, placement.bellLength * 0.5, placement.bellRadius * 0.22]} />
<meshStandardMaterial color="#6a7079" metalness={0.8} roughness={0.35} />
</mesh>
)}
</group>
))}
</group>
);
}
/**
* + +
* 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<THREE.Group>(null);
const lightRef = useRef<THREE.PointLight>(null);
const diamondRef = useRef<THREE.Group>(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 (
<group ref={anchorRef} position={[0, nozzleY, 0]}>
<mesh position={[0, -length * 0.9, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 2.1, length * 1.8, 20, 1, true]} />
<meshBasicMaterial color="#ff7a2a" transparent opacity={0.32} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
{/* 外层燃气 */}
<mesh position={[0, -length * 0.95, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 2.2 * spread, length * 1.9, 24, 1, true]} />
<meshBasicMaterial color="#ff7326" transparent opacity={0.26} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, -length * 0.5, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 1.15, length, 20, 1, true]} />
<meshBasicMaterial color="#fff2c2" transparent opacity={0.9} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
{/* 内层亮芯 */}
<mesh position={[0, -length * 0.55, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 1.1, length * 1.1, 20, 1, true]} />
<meshBasicMaterial color="#fff3cd" transparent opacity={0.92} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<pointLight ref={lightRef} color="#ff9a3c" intensity={6} distance={radius * 30} decay={2} position={[0, -length * 0.4, 0]} />
</group>
);
}
/** Nozzle cluster drawn as a dark boat-tail plus a few nozzle cones. */
function EngineCluster({ radius, count }: { radius: number; count: number }) {
const nozzles = Math.min(count, 9);
const ring = useMemo(() => {
if (nozzles <= 1) return [[0, 0]] as Array<[number, number]>;
const points: Array<[number, number]> = [[0, 0]];
const outer = nozzles - 1;
for (let i = 0; i < outer; i += 1) {
const angle = (i / outer) * Math.PI * 2;
points.push([Math.cos(angle) * radius * 0.5, Math.sin(angle) * radius * 0.5]);
}
return points;
}, [nozzles, radius]);
return (
<group>
<mesh position={[0, radius * 0.4, 0]}>
<cylinderGeometry args={[radius * 0.82, radius, radius * 0.8, 24]} />
<meshStandardMaterial color={DARK} metalness={0.6} roughness={0.5} />
{/* 喷管根部的蓝色激波 */}
<mesh position={[0, -length * 0.08, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 0.95, length * 0.2, 16, 1, true]} />
<meshBasicMaterial color="#8ecbff" transparent opacity={0.6} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
{ring.map(([x, z], i) => (
<mesh key={i} position={[x, -radius * 0.2, z]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 0.16, radius * 0.55, 12, 1, true]} />
<meshStandardMaterial color="#3a4149" metalness={0.7} roughness={0.35} side={THREE.DoubleSide} />
</mesh>
))}
</group>
);
}
/** Grid-fin / stabiliser fins around the base of the first stage. */
function Fins({ radius, height }: { radius: number; height: number }) {
return (
<group>
{[0, 1, 2, 3].map((i) => (
<mesh key={i} position={[0, height * 0.5, 0]} rotation={[0, (i / 4) * Math.PI * 2, 0]}>
<boxGeometry args={[radius * 0.12, height, radius * 2.4]} />
<meshStandardMaterial color={DARK} metalness={0.5} roughness={0.6} />
</mesh>
))}
{/* 马赫环 */}
<group ref={diamondRef}>
{[0.34, 0.56, 0.76].map((offset) => (
<mesh key={offset} position={[0, -length * offset, 0]}>
<octahedronGeometry args={[radius * (0.5 - offset * 0.32), 0]} />
<meshBasicMaterial color="#ffd9a0" transparent opacity={0.75} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
))}
</group>
<pointLight
ref={lightRef}
color="#ff9a3c"
intensity={8}
distance={radius * 34}
decay={2}
position={[0, -length * 0.35, 0]}
/>
</group>
);
}
@ -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 (
<group>
<mesh position={[0, height * 0.5, 0]} castShadow receiveShadow>
<cylinderGeometry args={[radius, radius, height, 48]} />
<meshStandardMaterial color={color} metalness={0.32} roughness={0.44} />
</mesh>
{/* 底部防热 / 烟熏段 */}
<mesh position={[0, height * 0.045, 0]}>
<cylinderGeometry args={[radius * 1.004, radius * 1.004, height * 0.09, 48]} />
<meshStandardMaterial color={SOOT} metalness={0.35} roughness={0.85} />
</mesh>
{/* 顶部深色带 */}
<mesh position={[0, height * 0.94, 0]}>
<cylinderGeometry args={[radius * 1.004, radius * 1.004, height * 0.055, 48]} />
<meshStandardMaterial color={accent} metalness={0.4} roughness={0.5} />
</mesh>
<StringerRings radius={radius} height={height} count={3} />
<Raceway radius={radius} height={height} />
{gridFins && <GridFins radius={radius} y={height * 0.88} color={accent} />}
<BodyDecal
radius={radius}
height={height * 0.13}
y={height * 0.74}
text={label}
textColor={shade(color, -0.7)}
background={shade(color, 0.06)}
/>
<group position={[0, 0, 0]}>
<EngineCluster radius={radius} count={engineCount} bellColor={shade(METAL, -0.15)} />
</group>
</group>
);
}
/** 栅格翼:细长单芯级回收构型(猎鹰 9靠近一级顶部的四片格栅。 */
function GridFins({ radius, y, color }: { radius: number; y: number; color: string }) {
return (
<group position={[0, y, 0]}>
{[0, 1, 2, 3].map((index) => {
const angle = (index / 4) * Math.PI * 2 + Math.PI / 4;
return (
<group key={index} position={[Math.cos(angle) * radius, 0, Math.sin(angle) * radius]} rotation={[0, -angle, 0]}>
{/* 翼盒 */}
<mesh>
<boxGeometry args={[radius * 0.72, radius * 0.5, radius * 0.12]} />
<meshStandardMaterial color={color} metalness={0.6} roughness={0.45} />
</mesh>
{/* 格栅叶片 */}
{[-0.24, 0, 0.24].map((offset) => (
<mesh key={offset} position={[offset * radius, 0, radius * 0.02]}>
<boxGeometry args={[radius * 0.06, radius * 0.46, radius * 0.16]} />
<meshStandardMaterial color={shade(color, -0.25)} metalness={0.7} roughness={0.35} />
</mesh>
))}
</group>
);
})}
</group>
);
}
/**
* 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 (
<group position={[Math.cos(angle) * offset, 0, Math.sin(angle) * offset]}>
{/* 助推器箭体 */}
<mesh position={[0, height * 0.5, 0]} castShadow>
<cylinderGeometry args={[radius * 0.52, radius * 0.52, height, 28]} />
<meshStandardMaterial color={color} metalness={0.32} roughness={0.44} />
</mesh>
{/* 头锥 */}
<mesh position={[0, height + noseHeight * 0.5, 0]}>
<coneGeometry args={[radius * 0.52, noseHeight, 24]} />
<meshStandardMaterial color={shade(color, -0.5)} metalness={0.35} roughness={0.45} />
</mesh>
{/* 涂装环 */}
<mesh position={[0, height * 0.9, 0]}>
<cylinderGeometry args={[radius * 0.528, radius * 0.528, height * 0.06, 28]} />
<meshStandardMaterial color={shade(color, -0.6)} metalness={0.4} roughness={0.5} />
</mesh>
{/* 与芯级的连接件 */}
<mesh position={[-radius * 0.26, height * 0.62, 0]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[radius * 0.06, radius * 0.06, radius * 0.5, 10]} />
<meshStandardMaterial color={DARK} metalness={0.6} roughness={0.45} />
</mesh>
{/* 助推器发动机 */}
<group position={[0, -radius * 0.08, 0]}>
<EngineCluster radius={radius * 0.56} count={engines} bellColor={shade(METAL, -0.15)} showActuators={false} />
</group>
</group>
);
}
/** 级间段:深色结构段 + 分离火箭。 */
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 (
<group>
<mesh position={[0, height * 0.5, 0]}>
<cylinderGeometry args={[radius, radius, height, 32]} />
<meshStandardMaterial color={color} metalness={0.35} roughness={0.45} />
<cylinderGeometry args={[radius * 0.995, radius * 1.002, height, 40]} />
<meshStandardMaterial color={DARK} metalness={0.55} roughness={0.5} />
</mesh>
{/* Livery band near the top of the stage */}
<mesh position={[0, height * 0.9, 0]}>
<cylinderGeometry args={[radius * 1.005, radius * 1.005, height * 0.05, 32]} />
<meshStandardMaterial color={DARK} metalness={0.4} roughness={0.5} />
<mesh position={[0, height * 0.12, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[radius * 1.004, radius * 0.018, 6, 40]} />
<meshStandardMaterial color={METAL} metalness={0.7} roughness={0.4} />
</mesh>
<group position={[0, height * 0.16, 0]}>
<Fins radius={radius} height={height * 0.22} />
</group>
<EngineCluster radius={radius} count={engineCount} />
{motors.map((angle) => (
<mesh
key={angle}
position={[Math.cos(angle) * radius * 0.72, height * 0.82, Math.sin(angle) * radius * 0.72]}
rotation={[Math.PI, 0, 0]}
>
<coneGeometry args={[radius * 0.09, radius * 0.22, 10]} />
<meshStandardMaterial color="#3c434b" metalness={0.6} roughness={0.45} />
</mesh>
))}
</group>
);
}
/** Interstage + second stage + payload fairing/nose; base sits at local y=0. */
function UpperStack({
radius,
interstageHeight,
stage2Height,
noseHeight,
color,
engineCount,
}: {
radius: number;
interstageHeight: number;
stage2Height: number;
noseHeight: number;
color: string;
engineCount: number;
}) {
const stage2Base = interstageHeight;
const noseBase = interstageHeight + stage2Height;
/** 二级姿态控制推力器组RCS。 */
function RcsCluster({ radius, y, angle }: { radius: number; y: number; angle: number }) {
return (
<group>
{/* Interstage (dark) */}
<mesh position={[0, interstageHeight * 0.5, 0]}>
<cylinderGeometry args={[radius * 0.98, radius, interstageHeight, 32]} />
<group position={[Math.cos(angle) * radius * 0.97, y, Math.sin(angle) * radius * 0.97]} rotation={[0, -angle, 0]}>
{[-0.35, 0, 0.35].map((offset) => (
<mesh key={offset} position={[0, offset * radius * 0.3, 0]} rotation={[0, 0, Math.PI / 2]}>
<coneGeometry args={[radius * 0.05, radius * 0.14, 8]} />
<meshStandardMaterial color="#c9d2d8" metalness={0.5} roughness={0.4} />
</mesh>
))}
<mesh>
<boxGeometry args={[radius * 0.14, radius * 0.7, radius * 0.2]} />
<meshStandardMaterial color={DARK} metalness={0.5} roughness={0.5} />
</mesh>
{/* Second-stage engine bell tucked under the interstage */}
<group position={[0, stage2Base, 0]}>
<EngineCluster radius={radius * 0.7} count={Math.min(engineCount, 3)} />
</group>
{/* Second-stage body */}
<mesh position={[0, stage2Base + stage2Height * 0.5, 0]}>
<cylinderGeometry args={[radius * 0.96, radius * 0.98, stage2Height, 32]} />
<meshStandardMaterial color={color} metalness={0.35} roughness={0.45} />
</group>
);
}
/** 二级:箭体 + 涂装 + 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 (
<group>
<mesh position={[0, height * 0.5, 0]} castShadow>
<cylinderGeometry args={[bodyRadius, bodyRadius * 1.005, height, 44]} />
<meshStandardMaterial color={color} metalness={0.32} roughness={0.44} />
</mesh>
{/* Payload fairing / nose cone */}
<mesh position={[0, noseBase + noseHeight * 0.5, 0]}>
<coneGeometry args={[radius * 0.96, noseHeight, 32]} />
<meshStandardMaterial color={METAL} metalness={0.4} roughness={0.4} />
<mesh position={[0, height * 0.93, 0]}>
<cylinderGeometry args={[bodyRadius * 1.005, bodyRadius * 1.005, height * 0.06, 44]} />
<meshStandardMaterial color={accent} metalness={0.4} roughness={0.5} />
</mesh>
<StringerRings radius={bodyRadius} height={height} count={2} />
<BodyDecal
radius={bodyRadius}
height={height * 0.16}
y={height * 0.68}
text={label}
textColor={shade(color, -0.7)}
background={shade(color, 0.06)}
/>
{[0, 1, 2, 3].map((index) => (
<RcsCluster key={index} radius={bodyRadius} y={height * 0.86} angle={(index / 4) * Math.PI * 2} />
))}
<group position={[0, 0, 0]}>
<EngineCluster radius={bodyRadius} count={engineCount} bellColor={shade(METAL, -0.1)} showActuators={engineCount <= 4} />
</group>
</group>
);
}
/** 整流罩头锥的卵形母线,比圆锥更接近真实整流罩。 */
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 (
<group>
<mesh position={[0, barrelHeight * 0.5, 0]} castShadow>
<cylinderGeometry args={[radius, radius, barrelHeight, 28, 1, true, startAngle, Math.PI]} />
<meshStandardMaterial color={color} metalness={0.28} roughness={0.38} side={THREE.DoubleSide} transparent />
</mesh>
<mesh position={[0, barrelHeight, 0]}>
<latheGeometry args={[profile, 24, startAngle, Math.PI]} />
<meshStandardMaterial color={color} metalness={0.28} roughness={0.36} side={THREE.DoubleSide} transparent />
</mesh>
{/* 对接框 */}
<mesh position={[0, 0.01, 0]}>
<cylinderGeometry args={[radius * 1.004, radius * 1.004, barrelHeight * 0.05, 28, 1, true, startAngle, Math.PI]} />
<meshStandardMaterial color={METAL} metalness={0.6} roughness={0.45} side={THREE.DoubleSide} transparent />
</mesh>
</group>
);
}
/** 载荷适配器:二级顶部与卫星之间的锥形对接结构。 */
function PayloadAdapter({ radius, height }: { radius: number; height: number }) {
return (
<mesh position={[0, height * 0.5, 0]}>
<cylinderGeometry args={[radius * 0.52, radius * 0.86, height, 28, 1, true]} />
<meshStandardMaterial color="#8d959c" metalness={0.62} roughness={0.4} side={THREE.DoubleSide} />
</mesh>
);
}
/** 载荷卫星:星体 + 通信天线 + 太阳翼(部署时展开)。 */
function PayloadSatellite({ radius, panelProgress }: { radius: number; panelProgress: number }) {
const satelliteRef = useRef<THREE.Group>(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 (
<group ref={satelliteRef}>
<mesh>
<boxGeometry args={[radius * 1.35, radius * 1.15, radius * 1.2]} />
<meshStandardMaterial color="#d2a84f" metalness={0.65} roughness={0.38} />
<group ref={satelliteRef} rotation={[0.28, 0, 0.12]}>
{/* 星体(金色多层隔热材料) */}
<mesh castShadow>
<boxGeometry args={[radius * 1.4, radius * 1.2, radius * 1.24]} />
<meshStandardMaterial color="#d8b25c" metalness={0.7} roughness={0.34} />
</mesh>
<mesh position={[0, radius * 0.76, 0]}>
<cylinderGeometry args={[radius * 0.28, radius * 0.42, radius * 0.42, 20]} />
<meshStandardMaterial color="#d9ddda" metalness={0.65} roughness={0.28} />
<mesh position={[0, radius * 0.8, 0]}>
<cylinderGeometry args={[radius * 0.26, radius * 0.4, radius * 0.42, 20]} />
<meshStandardMaterial color="#dfe4e2" metalness={0.6} roughness={0.3} />
</mesh>
<mesh position={[0, radius * 1.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
<sphereGeometry args={[radius * 0.54, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial color="#eff2ef" metalness={0.35} roughness={0.4} side={THREE.BackSide} />
{/* 抛物面天线 */}
<mesh position={[0, radius * 1.12, 0]} rotation={[Math.PI / 2, 0, 0]}>
<sphereGeometry args={[radius * 0.56, 28, 14, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial color="#f2f5f3" metalness={0.35} roughness={0.35} side={THREE.BackSide} />
</mesh>
<mesh position={[0, radius * 1.12, 0]}>
<cylinderGeometry args={[radius * 0.05, radius * 0.05, radius * 0.4, 8]} />
<meshStandardMaterial color="#b9c2c8" metalness={0.7} roughness={0.3} />
</mesh>
{/* 天线阵 */}
{[-1, 1].map((direction) => (
<group key={direction} position={[direction * radius * 0.78, 0, 0]}>
<mesh
position={[direction * radius * 1.55 * easedProgress, 0, 0]}
scale={[Math.max(0.04, easedProgress), 1, 1]}
>
<boxGeometry args={[radius * 3.1, radius * 0.72, radius * 0.08]} />
<meshStandardMaterial color="#24507a" metalness={0.25} roughness={0.55} />
</mesh>
<mesh position={[direction * radius * 0.08, 0, 0]}>
<boxGeometry args={[radius * 0.18, radius * 0.18, radius * 0.16]} />
<mesh key={direction} position={[direction * radius * 0.42, -radius * 0.78, 0]}>
<cylinderGeometry args={[radius * 0.03, radius * 0.03, radius * 0.5, 6]} />
<meshStandardMaterial color="#c8d0d6" metalness={0.6} roughness={0.4} />
</mesh>
))}
{/* 太阳翼:每侧两块板,展开时沿转轴伸出 */}
{[-1, 1].map((direction) => (
<group key={direction}>
<mesh position={[direction * radius * 0.78, 0, 0]}>
<boxGeometry args={[radius * 0.18, radius * 0.2, radius * 0.18]} />
<meshStandardMaterial color={DARK} metalness={0.7} roughness={0.35} />
</mesh>
{[0, 1].map((panelIndex) => (
<group
key={panelIndex}
position={[
direction * (radius * 0.9 + eased * (radius * 1.35 + panelIndex * panelLength)),
0,
0,
]}
scale={[Math.max(0.02, eased), 1, 1]}
>
<mesh>
<boxGeometry args={[panelLength * 0.94, radius * 0.78, radius * 0.07]} />
<meshStandardMaterial color="#1d3f68" metalness={0.3} roughness={0.5} />
</mesh>
{cells.map((cell) => (
<mesh key={cell} position={[(cell - 2.5) * (panelLength * 0.15), 0, radius * 0.042]}>
<boxGeometry args={[panelLength * 0.13, radius * 0.68, radius * 0.01]} />
<meshStandardMaterial color="#2f6099" metalness={0.45} roughness={0.35} />
</mesh>
))}
</group>
))}
</group>
))}
</group>
@ -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<THREE.Group>(null);
const boosterRef = useRef<THREE.Group>(null);
const upperStackRef = useRef<THREE.Group>(null);
const fairingRef = useRef<THREE.Group>(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 (
<group>
{/* Upper stack: active through insertion, then discarded after deployment. */}
{/* 上面级:级间段 + 二级 + 整流罩 + 载荷 */}
<group ref={upperStackRef} position={[0, upperBaseY, 0]}>
<UpperStack
radius={geo.radius}
interstageHeight={geo.interstageHeight}
stage2Height={geo.stage2Height}
noseHeight={geo.noseHeight}
color={rocket.color}
engineCount={rocket.stage_2.engine_count}
/>
{/* Stage-2 plume: fires from the second-stage bell (above interstage) */}
<group position={[0, geo.interstageHeight, 0]}>
<Interstage radius={geo.radius} height={geo.interstageHeight} />
<group position={[0, stage2BaseY, 0]}>
<SecondStageBody
radius={geo.radius}
height={geo.stage2Height}
color={shade(rocket.color, 0.02)}
engineCount={geo.stage2Engines}
label={secondStageLabel}
/>
{/* 二级尾焰:自二级喷管出口向下喷出 */}
<EnginePlume
radius={geo.radius * 0.7}
length={geo.radius * 7}
nozzleY={-geo.radius * 0.7 * 0.4}
length={geo.radius * 8}
nozzleY={-geo.radius * 0.5}
throttle={state.throttle}
active={engines.stage2}
running={state.isRunning}
/>
</group>
{/* 载荷适配器 + 卫星(整流罩抛离后可见) */}
<group position={[0, fairingBaseY, 0]}>
<PayloadAdapter radius={geo.radius * 0.9} height={geo.fairingBarrelHeight * 0.3} />
</group>
<group ref={fairingRef} position={[0, fairingBaseY, 0]}>
{/* 每瓣外面再包一层,动画只作用于外层,避免覆盖半瓣自身的朝向 */}
<group>
<FairingHalf
radius={geo.fairingRadius}
barrelHeight={geo.fairingBarrelHeight}
coneHeight={geo.fairingConeHeight}
color={fairingColor}
startAngle={0}
/>
</group>
<group>
<FairingHalf
radius={geo.fairingRadius}
barrelHeight={geo.fairingBarrelHeight}
coneHeight={geo.fairingConeHeight}
color={fairingColor}
startAngle={Math.PI}
/>
</group>
</group>
</group>
{/* 载荷:部署后脱离上面级独立存在,上面级淡出后载荷仍然保留在场景中 */}
{deployAge !== null && (
<group position={[deployProgress * 1.8, payloadY + deployProgress * 2.6, 0]} scale={1.25}>
<group
position={[
deployProgress * 0.8,
upperBaseY + payloadY - deployProgress * 6.2,
deployProgress * 0.4,
]}
scale={1.2}
>
<PayloadSatellite radius={geo.radius} panelProgress={deployProgress} />
</group>
)}
{/* First stage: attached below the stack, or detached and falling */}
{/* 一级:未分离时位于上面级下方,分离后翻滚坠落 */}
<group ref={firstStageRef}>
<FirstStageBody
radius={geo.radius}
height={geo.stage1Height}
color={rocket.color}
engineCount={rocket.stage_1.engine_count}
engineCount={geo.layout === 'boosters' ? geo.coreEngines : geo.stage1Engines}
label={firstStageLabel}
gridFins={geo.slenderCore}
/>
{/* Stage-1 plume: fires from the engine cluster at the base (y≈0) */}
{/* 捆绑助推器:先于芯级分离,分离后向四周散开 */}
{geo.layout === 'boosters' && (
<group ref={boosterRef}>
{Array.from({ length: geo.boosterCount }, (_, index) => (
<group key={index}>
<StrapOnBooster
radius={geo.radius}
height={geo.stage1Height * 0.86}
color={shade(rocket.color, -0.06)}
engines={geo.enginesPerBooster}
angle={(index / geo.boosterCount) * Math.PI * 2 + Math.PI / 4}
/>
</group>
))}
</group>
)}
<EnginePlume
radius={geo.radius}
length={geo.radius * 10}
nozzleY={-geo.radius * 0.4}
nozzleY={-geo.radius * 0.7}
throttle={state.throttle}
active={engines.stage1}
running={state.isRunning}
spread={1 + geo.stage1Engines * 0.04}
/>
</group>
</group>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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<StackMode, { code: string; zh: string; detail: string }> = {
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 / payloadCSS
*/
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) => (
<path
key={index}
d={`M ${bell.x - bell.width / 2} ${baseY} l 0 6 l ${bell.width * 0.2} 2.4 l ${bell.width * 0.6} 0 l ${bell.width * 0.2} -2.4 l 0 -6 Z`}
fill="#39414a"
/>
));
};
return (
<div className="sx-arc-gauge">
<svg viewBox="0 0 120 72" aria-hidden="true">
<path className="sx-arc-track" d="M 10 62 A 50 50 0 0 1 110 62" pathLength="100" />
<path className="sx-arc-value" d="M 10 62 A 50 50 0 0 1 110 62" pathLength="100" style={{ strokeDasharray: `${progress * 100} 100` }} />
<line className="sx-arc-needle" x1="60" y1="62" x2="60" y2="21" transform={`rotate(${needleAngle} 60 62)`} />
<circle cx="60" cy="62" r="3" />
</svg>
<span>{label}</span>
<strong>{value}</strong>
<small>{unit}</small>
<svg className="sx-stack" viewBox="0 0 120 240" role="img" aria-label="运载器结构示意图">
{/* measurement rail */}
<line className="sx-stack-axis" x1={CX} y1={4} x2={CX} y2={padY - 2} />
<line className="sx-stack-grid" x1={14} y1={padY} x2={102} y2={padY} />
<text className="sx-stack-dim" x={14} y={padY - 5}>{rocket.height_m.toFixed(1)} M</text>
<text className="sx-stack-dim" x={72} y={padY - 5}>Ø {rocket.diameter_m.toFixed(1)} M</text>
<line className="sx-stack-seam" x1={12} y1={s2Top} x2={104} y2={s2Top} />
<line className="sx-stack-seam" x1={12} y1={interTop} x2={104} y2={interTop} />
<line className="sx-stack-seam" x1={12} y1={s1Top} x2={104} y2={s1Top} />
{/* 助推器(长征五号一类):画在芯级两侧,先于芯级绘制 */}
{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 (
<g key={direction} className="sx-stack-booster">
<rect x={bx - br} y={boosterTop} width={br * 2} height={boosterH} rx={1.6} fill="#dfe4e8" className="sx-stack-body" />
<path
d={`M ${bx - br} ${boosterTop} Q ${bx} ${boosterTop - br * 2.6} ${bx + br} ${boosterTop} Z`}
fill="#b9c2c8"
/>
<rect x={bx - br} y={boosterTop + boosterH * 0.08} width={br * 2} height={boosterH * 0.06} fill="#c8d0d6" />
{engineBells(geo.enginesPerBooster, geo.radius * 0.56, boosterTop + boosterH)}
</g>
);
})}
{/* Stage 1 — drops away after separation */}
<g className="sx-stack-s1">
<rect x={CX - r1} y={s1Top} width={r1 * 2} height={s1H} rx={2.5} fill={rocket.color} className="sx-stack-body" />
<rect x={CX - r1} y={s1Top + s1H * 0.9} width={r1 * 2} height={s1H * 0.1} fill="#14171a" />
<rect x={CX - r1} y={s1Top + s1H * 0.06} width={r1 * 2} height={s1H * 0.05} fill="#2b3138" />
<rect x={CX + r1 - 2.4} y={s1Top + s1H * 0.12} width={2.4} height={s1H * 0.78} fill="#20262c" />
<rect x={CX - r1 - 1} y={engineY} width={r1 * 2 + 2} height={5} rx={2} fill="#101418" />
{/* 栅格翼(细长单芯级回收构型) */}
{geo.slenderCore && [-1, 1].map((direction) => (
<path
key={direction}
d={`M ${CX + direction * r1} ${s1Top + s1H * 0.08} l ${direction * 7} 0 l 0 6 l ${-direction * 7} 0 Z`}
fill="#39414a"
/>
))}
{engineBells(geo.layout === 'boosters' ? geo.coreEngines : geo.stage1Engines, geo.radius, engineY + 5)}
</g>
{/* Upper stack (interstage + S2) */}
<g className="sx-stack-s2">
<rect x={CX - r2} y={interTop} width={r2 * 2} height={interH} fill="#1c2228" />
<rect x={CX - r2} y={s2Top} width={r2 * 2} height={s2H} rx={2} fill={rocket.color} className="sx-stack-body" />
<rect x={CX - r2} y={s2Top + s2H * 0.07} width={r2 * 2} height={s2H * 0.07} fill="#2b3138" />
<rect x={CX - r2 - 0.8} y={s2Top + s2H} width={r2 * 2 + 1.6} height={3.4} fill="#101418" />
{engineBells(geo.stage2Engines, geo.radius * 0.97, s2Top + s2H + 3.4)}
</g>
{/* Payload fairing — 两瓣卵形整流罩,部署载荷时分离 */}
<g className="sx-stack-fairing">
<path
className="sx-fairing sx-fairing-left"
d={`M ${CX} ${fairingBottom} L ${CX - rf} ${fairingBottom} L ${CX - rf} ${barrelTop} Q ${CX - rf * 0.92} ${(barrelTop + coneTop) / 2}, ${CX} ${coneTop} Z`}
fill={metal}
/>
<path
className="sx-fairing sx-fairing-right"
d={`M ${CX} ${fairingBottom} L ${CX + rf} ${fairingBottom} L ${CX + rf} ${barrelTop} Q ${CX + rf * 0.92} ${(barrelTop + coneTop) / 2}, ${CX} ${coneTop} Z`}
fill={metal}
/>
<line className="sx-stack-seam" x1={CX} y1={coneTop} x2={CX} y2={fairingBottom} />
<line className="sx-stack-seam" x1={12} y1={barrelTop} x2={104} y2={barrelTop} />
</g>
{/* Payload satellite — revealed at deploy */}
<g className="sx-stack-payload">
<circle cx={CX} cy={barrelTop - coneH * 0.2} r={3.4} fill="#e8ece9" />
<rect x={CX - 8} y={barrelTop + coneH * 0.04} width={16} height={11} rx={1.5} fill="#d8b25c" />
<g className="sx-stack-panel l">
<rect x={CX - 34} y={barrelTop + coneH * 0.08} width={25} height={5.5} rx={1} fill="#1d3f68" />
</g>
<g className="sx-stack-panel r">
<rect x={CX + 9} y={barrelTop + coneH * 0.08} width={25} height={5.5} rx={1} fill="#1d3f68" />
</g>
</g>
</svg>
);
}
function VehiclePanel({ rocket, mode, stage1FuelPct, stage2FuelPct }: { rocket: RocketConfig; mode: StackMode; stage1FuelPct: number; stage2FuelPct: number }) {
const meta = STACK_MODE_LABEL[mode];
return (
<div className="sx-vehicle">
<div className="sx-panel-title"><span>VEHICLE</span><b></b></div>
<div className={`sx-vehicle-body sx-stack--${mode}`}>
<StackDiagram rocket={rocket} />
<div className="sx-vehicle-side">
<div className="sx-stack-caption">
<span>{meta.code}</span>
<strong>{meta.zh}</strong>
<small>{meta.detail}</small>
</div>
<div className="sx-spec">
<div className="sx-spec-row"><span></span><b className="sx-spec-name">{rocket.stage_1.name}</b><em>{stage1FuelPct.toFixed(0)}%</em></div>
<div className="sx-spec-row"><span></span><b className="sx-spec-name">{rocket.stage_2.name}</b><em>{stage2FuelPct.toFixed(0)}%</em></div>
<div className="sx-spec-divider" />
<div className="sx-spec-row"><span></span><b>{rocket.height_m.toFixed(1)} × Ø{rocket.diameter_m.toFixed(1)} m</b></div>
<div className="sx-spec-row"><span></span><b>{rocket.stage_1.engine_count} + {rocket.stage_2.engine_count} </b></div>
<div className="sx-spec-row"><span></span><b>{(rocket.stage_1.max_thrust_n / 1e6).toFixed(2)} MN</b></div>
<div className="sx-spec-row"><span></span><b>{(rocket.stage_2.max_thrust_n / 1e6).toFixed(2)} MN</b></div>
<div className="sx-spec-row"><span></span><b>{(rocket.payload_mass_kg / 1000).toFixed(1)} t</b></div>
<div className="sx-spec-row"><span></span><b>{rocket.target_orbit_km} km</b></div>
{rocket.manufacturer ? (
<div className="sx-spec-row"><span></span><b className="sx-spec-name">{rocket.manufacturer}</b></div>
) : null}
</div>
</div>
</div>
</div>
);
}
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 (
<div className="sx-engine-gauge" aria-label={`${stage}级发动机 ${active}/${total} 点火`}>
<div className="sx-profile">
<div className="sx-panel-title"><span>MISSION PROFILE</span><b></b></div>
<div className="sx-profile-list">
{milestones.map((m) => {
const time = eventTime(m.id);
const reached = time !== null;
const isNext = next?.id === m.id && running;
return (
<div key={m.id} className={`sx-profile-row ${reached ? 'reached' : ''} ${isNext ? 'next' : ''}`}>
<i />
<b>{m.code}</b>
<span>{m.label}</span>
<time>{reached ? formatTime(time as number).replace('T+', '+') : '--:--'}</time>
</div>
);
})}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Flight data panel (big green monospace numbers) */
/* ------------------------------------------------------------------ */
function FlightData({ state, history, totalFuelPct, stage1FuelPct, stage2FuelPct }: {
state: ReturnType<typeof useRocketSimulation>['state'];
history: ReturnType<typeof useRocketSimulation>['state']['history'];
totalFuelPct: number;
stage1FuelPct: number;
stage2FuelPct: number;
}) {
return (
<div className="sx-data">
<div className="sx-panel-title"><span>FLIGHT DATA</span><b></b></div>
<div className="sx-data-grid">
<div className="sx-data-row hero">
<span>ALTITUDE</span>
<div><strong>{(state.altitude / 1000).toFixed(1)}</strong><em>KM</em></div>
</div>
<div className="sx-data-row hero">
<span>VELOCITY</span>
<div><strong>{(state.velocity * 3.6).toFixed(0)}</strong><em>KM/H</em></div>
</div>
<div className="sx-data-row">
<span>ACCELERATION</span>
<div><strong>{(state.acceleration / 9.80665).toFixed(2)}</strong><em>G</em></div>
</div>
<div className="sx-data-row">
<span>DYNAMIC PRESS</span>
<div><strong>{(state.dynamicPressure / 1000).toFixed(1)}</strong><em>KPA</em></div>
</div>
<div className="sx-data-row">
<span>DOWNRANGE</span>
<div><strong>{(state.downrange / 1000).toFixed(1)}</strong><em>KM</em></div>
</div>
<div className="sx-data-row">
<span>PITCH</span>
<div><strong>{state.pitch.toFixed(0)}</strong><em>°</em></div>
</div>
</div>
<div className="sx-chart-wrap">
<div className="sx-chart-title"><span>ALT / VEL TRACE</span><b> · </b></div>
<TelemetryChart history={history} />
</div>
<div className="sx-fuel">
<div className="sx-fuel-head"><span> / PROPELLANT</span><b>{totalFuelPct.toFixed(0)}%</b></div>
<div className="sx-fuel-row"><span>S1</span><div className="sx-fuel-track"><i className="s1" style={{ width: `${stage1FuelPct}%` }} /></div><b>{stage1FuelPct.toFixed(0)}%</b></div>
<div className="sx-fuel-row"><span>S2</span><div className="sx-fuel-track"><i className="s2" style={{ width: `${stage2FuelPct}%` }} /></div><b>{stage2FuelPct.toFixed(0)}%</b></div>
<div className="sx-fuel-total-row"><span></span><b>{totalFuelPct.toFixed(0)}%</b></div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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 (
<div className="sx-engine-gauge" aria-label={`${stage === 1 ? '一级' : '二级'}发动机 ${active}/${total} 点火`}>
<div className="sx-engine-ring">
<i className={`sx-engine-center ${active > 0 ? 'active' : ''}`} />
{Array.from({ length: outerCount }, (_, index) => (
{placements.map((placement, index) => (
<i
key={index}
className={`sx-engine-outer ${index + 1 < active ? 'active' : ''}`}
style={{ '--engine-angle': `${index / outerCount * 360}deg` } as CSSProperties}
className={`sx-engine-dot ${index < active ? 'active' : ''}`}
style={{ '--dx': placement.x, '--dy': placement.z } as CSSProperties}
/>
))}
</div>
@ -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 (
<main className="rocket-simulator-page">
@ -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 */}
<header className="sx-topbar">
<button className="sx-icon-btn" onClick={() => navigate('/')} title="返回首页" aria-label="返回首页"><ArrowLeft size={18} /></button>
<div className="sx-brand"><Rocket size={18} /><div><strong>COSMO LAUNCH</strong><span>{rocket.name_zh || rocket.name} · {rocket.name}</span></div></div>
@ -157,6 +448,24 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) {
{rockets.map((item) => <option key={item.code} value={item.code}>{item.name_zh || item.name}</option>)}
</select><ChevronDown size={13} /></div>
</label>
{/* 完整任务约 9 分钟,提供三段倍速开关便于观察分离、入轨与载荷部署 */}
<label className="sx-select sx-select--speed">
<span> / SPEED</span>
<div className="sx-speed-switch" role="group" aria-label="模拟倍速">
{[1, 2, 4].map((value) => (
<button
key={value}
type="button"
className={speed === value ? 'active' : ''}
onClick={() => setSpeed(value)}
aria-pressed={speed === value}
title={`${value} 倍速播放`}
>
{value}×
</button>
))}
</div>
</label>
<div className="sx-seg" role="group" aria-label="视角模式">
<button className={cameraMode === 'follow' ? 'active' : ''} onClick={() => setCameraMode('follow')} title="跟随视角"><Camera size={14} /></button>
<button className={cameraMode === 'global' ? 'active' : ''} onClick={() => setCameraMode('global')} title="全局视角"><Orbit size={14} /></button>
@ -170,62 +479,47 @@ function RocketMission({ rockets }: { rockets: RocketConfig[] }) {
</div>
</header>
<div className="sx-environment" aria-live="polite">
<span>{environment.code}</span>
<strong>{environment.label}</strong>
<small>{rocket.launch_site_name} · {environment.detail}</small>
</div>
{/* Minimal telemetry side panel */}
<aside className="sx-side">
<div className="sx-panel-title"><span>FLIGHT DATA</span><b></b></div>
<div className="sx-side-row"><span></span><strong>{(state.acceleration / 9.80665).toFixed(2)}<em>g</em></strong></div>
<div className="sx-side-row"><span> Q</span><strong>{(state.dynamicPressure / 1000).toFixed(1)}<em>kPa</em></strong></div>
<div className="sx-side-row"><span></span><strong>{(state.downrange / 1000).toFixed(1)}<em>km</em></strong></div>
<div className="sx-side-divider" />
<div className="sx-fuel">
<div className="sx-fuel-head"><span></span><strong>{totalFuelPct.toFixed(0)}%</strong></div>
<div className="sx-fuel-row"><span></span><div className="sx-fuel-track"><i className="s1" style={{ width: `${stage1FuelPct}%` }} /></div><b>{stage1FuelPct.toFixed(0)}%</b></div>
<div className="sx-fuel-row"><span></span><div className="sx-fuel-track"><i className="s2" style={{ width: `${stage2FuelPct}%` }} /></div><b>{stage2FuelPct.toFixed(0)}%</b></div>
<div className="sx-fuel-total-row"><span></span><b>{totalFuelPct.toFixed(0)}%</b></div>
</div>
{/* Left: mission profile + vehicle stack */}
<aside className="sx-left">
<MissionProfile events={state.events} next={nextMilestone} running={state.isRunning} milestones={milestones} />
<VehiclePanel rocket={rocket} mode={stackMode} stage1FuelPct={stage1FuelPct} stage2FuelPct={stage2FuelPct} />
</aside>
{/* Bottom: milestone track (single source of truth) + telemetry strip + controls */}
{/* Right: flight telemetry */}
<aside className="sx-right">
<FlightData state={state} history={state.history} totalFuelPct={totalFuelPct} stage1FuelPct={stage1FuelPct} stage2FuelPct={stage2FuelPct} />
</aside>
{/* Bottom: environment + mission clock + progress + gauges + controls */}
<div className="sx-bottom">
<div className="sx-track" aria-label="飞行里程碑">
{MILESTONES.map((m) => {
const time = eventTime(m.id);
const reached = time !== null;
const isNext = nextMilestone?.id === m.id && state.isRunning;
return (
<div key={m.id} className={`sx-node ${reached ? 'reached' : ''} ${isNext ? 'next' : ''}`}>
<i />
<b>{m.code}</b>
<time>{reached ? formatTime(time as number).replace('T+', '+') : '--:--'}</time>
</div>
);
})}
<div className="sx-bottom-left">
<div className="sx-env">
<span className="sx-env-code">{environment.code}</span>
<strong>{environment.label}</strong>
<small>{rocket.launch_site_name} · {environment.detail}</small>
</div>
<div className="sx-next-event">
<span>NEXT EVENT</span>
<b>{nextMilestone ? nextMilestone.code : (state.phase === 'mission_complete' ? 'COMPLETE' : '—')}</b>
<small>{nextMilestone ? nextMilestone.label : '任务完成'}</small>
</div>
</div>
<div className="sx-hud">
<div className="sx-gauge-cluster">
<ArcGauge label="SPEED" value={(state.velocity * 3.6).toFixed(0)} unit="KM/H" fill={state.velocity * 3.6 / 28000} />
<ArcGauge label="ALTITUDE" value={(state.altitude / 1000).toFixed(1)} unit="KM" fill={state.altitude / (rocket.target_orbit_km * 1000)} />
<div className="sx-bottom-center">
<div className="sx-clock">{clockText}</div>
<div className="sx-realtime"><Clock3 size={12} />MISSION TIME · REAL TIME</div>
<div className="sx-progress">
<div className="sx-progress-track"><i style={{ width: `${progress}%` }} /></div>
<span>{progress.toFixed(0)}%</span>
</div>
</div>
<div className="sx-clock">
<div className="sx-clock-time">{formatTime(state.time)}</div>
<div className="sx-realtime"><Clock3 size={12} />REAL TIME</div>
<div className="sx-controls">
<button className="sx-icon-btn" onClick={reset} title="重置" aria-label="重置"><RotateCcw size={16} /></button>
<button className="sx-launch" onClick={toggle}>{state.isRunning ? <Pause size={15} /> : <Play size={15} />}{launchLabel}</button>
</div>
</div>
<div className="sx-gauge-cluster sx-gauge-cluster-right">
<AttitudeGauge pitch={state.pitch} />
<EngineGauge stage={stage} total={stageConfig.engine_count} active={activeEngines} />
<div className="sx-bottom-right">
<EngineGauge stage={stage} total={stageConfig.engine_count} active={activeEngines} />
<AttitudeGauge pitch={state.pitch} />
<div className="sx-controls">
<button className="sx-icon-btn" onClick={reset} title="重置" aria-label="重置"><RotateCcw size={16} /></button>
<button className="sx-launch" onClick={toggle}>{state.isRunning ? <Pause size={15} /> : <Play size={15} />}{launchLabel}</button>
</div>
</div>
</div>

View File

@ -1,37 +1,63 @@
/**
* Admin Layout with Sidebar
*
*
* =/ +
* Header
*
*/
import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Avatar, Dropdown } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Avatar, ConfigProvider, Layout, Menu, Popover, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
CalendarOutlined,
ClockCircleOutlined,
ControlOutlined,
DashboardOutlined,
DatabaseOutlined,
DownOutlined,
DownloadOutlined,
UserOutlined,
LogoutOutlined,
RocketOutlined,
SettingOutlined,
TeamOutlined,
ControlOutlined,
LockOutlined,
GlobalOutlined,
HomeOutlined,
IdcardOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MoonOutlined,
RocketOutlined,
ScheduleOutlined,
SettingOutlined,
StarOutlined,
SunOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { authAPI } from '../../utils/request';
import { auth } from '../../utils/auth';
import { useToast } from '../../contexts/ToastContext';
import { adminThemeDark, adminThemeLight } from './adminTheme';
import { AdminPrefsProvider, useAdminPrefs } from './AdminPrefsContext';
import './admin.css';
const { Header, Sider, Content } = Layout;
const { Sider, Content } = Layout;
// Icon mapping
const iconMap: Record<string, any> = {
interface MenuNode {
id: number;
parent_id: number | null;
name: string;
title: string;
icon?: string | null;
path?: string | null;
children?: MenuNode[];
}
const iconMap: Record<string, React.ReactNode> = {
dashboard: <DashboardOutlined />,
database: <DatabaseOutlined />,
planet: <RocketOutlined />,
planet: <GlobalOutlined />,
data: <DatabaseOutlined />,
download: <DownloadOutlined />,
settings: <SettingOutlined />,
@ -40,171 +66,291 @@ const iconMap: Record<string, any> = {
profile: <IdcardOutlined />,
star: <StarOutlined />,
rocket: <RocketOutlined />,
schedule: <ScheduleOutlined />,
// 后端菜单里也直接存了组件名,这里一并兼容
DashboardOutlined: <DashboardOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
DownloadOutlined: <DownloadOutlined />,
SettingOutlined: <SettingOutlined />,
TeamOutlined: <TeamOutlined />,
ControlOutlined: <ControlOutlined />,
IdcardOutlined: <IdcardOutlined />,
StarOutlined: <StarOutlined />,
RocketOutlined: <RocketOutlined />,
CalendarOutlined: <CalendarOutlined />,
ClockCircleOutlined: <ClockCircleOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
GlobalOutlined: <GlobalOutlined />,
};
export function AdminLayout() {
function roleLabelOf(user: ReturnType<typeof auth.getUser>): string {
const roles = (user?.roles as string[] | undefined) ?? [];
if (roles.includes('admin')) return '管理员';
if (roles.length > 0) return '普通用户';
return '已登录用户';
}
function AdminShell() {
const [collapsed, setCollapsed] = useState(false);
const [menus, setMenus] = useState<any[]>([]);
const [user, setUser] = useState<any>(auth.getUser());
const [menus, setMenus] = useState<MenuNode[]>([]);
const [loadingMenus, setLoadingMenus] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [user, setUser] = useState(() => auth.getUser());
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const { lang, themeMode, setLang, setThemeMode, t } = useAdminPrefs();
const locale = lang === 'en' ? enUS : zhCN;
// Sync user state
const isAdmin = ((user?.roles as string[] | undefined) ?? []).includes('admin');
// 用户信息可能在其它标签页或个人信息页被更新,这里保持同步。
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'cosmo_user') {
setUser(e.newValue ? JSON.parse(e.newValue) : null);
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'cosmo_user') {
setUser(event.newValue ? JSON.parse(event.newValue) : null);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
// Helper to get full avatar URL
const getAvatarUrl = () => {
if (!user?.avatar_url) return null;
return `/upload/${user.avatar_url}`;
};
const loadMenus = useCallback(async () => {
setLoadingMenus(true);
try {
const { data } = await authAPI.getMenus();
setMenus(Array.isArray(data) ? data : []);
} catch {
setMenus([]);
toast.error('加载菜单失败');
} finally {
setLoadingMenus(false);
}
}, [toast]);
// Load menus from backend
useEffect(() => {
loadMenus();
}, []);
void loadMenus();
}, [loadMenus]);
// Redirect to first menu if on root path
// 切换页面时收起用户二级菜单,避免菜单悬停在旧页面上。
useEffect(() => {
if (menus.length > 0 && (location.pathname === '/admin' || location.pathname === '/user')) {
const firstMenu = menus[0];
if (firstMenu.path) {
navigate(firstMenu.path, { replace: true });
}
setUserMenuOpen(false);
}, [location.pathname]);
// 访问 /admin 或 /user 根路径时跳到第一个可用菜单。
useEffect(() => {
if (menus.length === 0) return;
if (location.pathname === '/admin' || location.pathname === '/user') {
const firstPath = menus.find((menu) => menu.path)?.path;
if (firstPath) navigate(firstPath, { replace: true });
}
}, [menus, location.pathname, navigate]);
const loadMenus = async () => {
try {
const { data } = await authAPI.getMenus();
setMenus(data);
} catch (error) {
toast.error('加载菜单失败');
}
};
// Convert backend menu to Ant Design menu format
const convertMenus = (menus: any[], isChild = false): MenuProps['items'] => {
return menus.map((menu) => {
const item: any = {
key: menu.path || menu.name,
icon: isChild ? null : (iconMap[menu.icon || ''] || null),
const menuItems = useMemo<MenuProps['items']>(
() =>
menus.map((menu) => ({
key: menu.path || `menu-${menu.id}`,
icon: iconMap[menu.icon || ''] ?? undefined,
label: menu.title,
};
children: menu.children?.length
? menu.children.map((child) => ({
key: child.path || `menu-${child.id}`,
label: child.title,
}))
: undefined,
})),
[menus],
);
if (menu.children && menu.children.length > 0) {
item.children = convertMenus(menu.children, true);
}
/** 顶栏左侧显示当前所属分组(页面标题由页面自己的 Header 卡片展示)。 */
const headerSection = useMemo(() => {
for (const menu of menus) {
if (menu.children?.some((child) => child.path === location.pathname)) return menu.title;
}
if (location.pathname.endsWith('/profile')) return '账号';
if (location.pathname.startsWith('/user')) return '用户中心';
return 'COSMO 管理后台';
}, [menus, location.pathname]);
return item;
});
};
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
};
const handleLogout = async () => {
const handleLogout = useCallback(async () => {
setUserMenuOpen(false);
try {
await authAPI.logout();
auth.logout();
toast.success('登出成功');
navigate('/login');
} catch (error) {
// Even if API fails, clear local auth
auth.logout();
navigate('/login');
} catch {
// 后端不可用时也要清理本地登录态。
}
auth.logout();
toast.success('已退出登录');
navigate('/login', { replace: true });
}, [navigate, toast]);
const go = (path: string) => {
setUserMenuOpen(false);
navigate(path);
};
const userMenuItems: MenuProps['items'] = [
{
key: 'change-password',
icon: <LockOutlined />,
label: '修改密码',
onClick: () => navigate('/admin/change-password'),
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
];
const avatarUrl = user?.avatar_url ? `/upload/${user.avatar_url}` : undefined;
const displayName = (user?.full_name as string) || (user?.username as string) || '未登录';
const userMenuContent = (
<div style={{ width: 232 }}>
<div className="adm-user-menu-head">
<Avatar size={38} src={avatarUrl} icon={<UserOutlined />} />
<div className="adm-user-menu-head-meta">
<strong>{displayName}</strong>
<span>{user?.email || t(roleLabelOf(user))}</span>
</div>
</div>
<div className="adm-user-menu-group">{t('个人资料')}</div>
<button
type="button"
className={`adm-user-menu-item ${location.pathname.endsWith('/profile') ? 'is-active' : ''}`}
onClick={() => go('/user/profile')}
>
<IdcardOutlined />
{t('个人资料')}
</button>
<div className="adm-user-menu-divider" />
<button type="button" className="adm-user-menu-item" onClick={() => go('/')}>
<HomeOutlined />
{t('返回可视化首页')}
</button>
<button type="button" className="adm-user-menu-item is-danger" onClick={() => void handleLogout()}>
<LogoutOutlined />
{t('退出登录')}
</button>
</div>
);
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed}>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
}}
<ConfigProvider theme={themeMode === 'dark' ? adminThemeDark : adminThemeLight} locale={locale}>
<Layout className="adm-shell" data-adm-theme={themeMode}>
<Sider
width={236}
theme={themeMode === 'dark' ? 'dark' : 'light'}
trigger={null}
collapsible
collapsed={collapsed}
className={`adm-sider ${collapsed ? 'adm-sider--collapsed' : ''}`}
>
{collapsed ? '🌌' : '🌌 COSMO'}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={convertMenus(menus)}
onClick={handleMenuClick}
/>
</Sider>
<Layout>
<Header
style={{
padding: '0 16px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div
onClick={() => setCollapsed(!collapsed)}
style={{ fontSize: 18, cursor: 'pointer' }}
{/* 品牌区 = 收起 / 展开按钮:收起后悬停显示展开图标 */}
<button
type="button"
className="adm-brand"
onClick={() => setCollapsed((value) => !value)}
aria-label={collapsed ? t('展开菜单') : t('收起菜单')}
title={collapsed ? t('展开菜单') : t('收起菜单')}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
<span className="adm-brand-mark">🌌</span>
<MenuUnfoldOutlined className="adm-brand-hover-icon" />
{!collapsed && (
<>
<span className="adm-brand-text">
<strong>COSMO</strong>
<span>{isAdmin ? t('管理后台') : t('用户中心')}</span>
</span>
<MenuFoldOutlined className="adm-brand-toggle" />
</>
)}
</button>
<div className="adm-menu">
{loadingMenus ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '24px 0' }}>
<Spin size="small" />
</div>
) : (
<Menu
theme={themeMode === 'dark' ? 'dark' : 'light'}
mode="inline"
selectedKeys={[location.pathname]}
defaultOpenKeys={menus.filter((menu) => menu.children?.length).map((menu) => `menu-${menu.id}`)}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ background: 'transparent', borderInlineEnd: 'none' }}
/>
)}
</div>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<Avatar src={getAvatarUrl()} icon={<UserOutlined />} style={{ marginRight: 8 }} />
<span>{user?.username || 'User'}</span>
{/* 用户胶囊:位于菜单下方,点击展开二级菜单 */}
<div className={`adm-user-dock ${collapsed ? 'adm-user-dock--collapsed' : ''}`}>
<Popover
open={userMenuOpen}
onOpenChange={setUserMenuOpen}
trigger="click"
placement="topLeft"
arrow={false}
overlayClassName="adm-user-menu"
content={userMenuContent}
>
<button type="button" className="adm-user-pill" aria-expanded={userMenuOpen}>
<Avatar size={30} src={avatarUrl} icon={<UserOutlined />} />
{!collapsed && (
<>
<span className="adm-user-pill-meta">
<strong>{displayName}</strong>
<span>{t(roleLabelOf(user))}</span>
</span>
<DownOutlined className="adm-user-pill-arrow" />
</>
)}
</button>
</Popover>
</div>
</Sider>
<Layout>
{/* 统一框架 Header左侧当前分组右侧全局开关 */}
<header className="adm-header">
<div className="adm-header-left">
<span className="adm-header-section">{t(headerSection)}</span>
</div>
</Dropdown>
</Header>
<Content
style={{
margin: '16px',
padding: 24,
background: '#fff',
minHeight: 280,
overflow: 'auto',
maxHeight: 'calc(100vh - 64px - 32px)',
}}
>
<Outlet />
</Content>
<div className="adm-header-right">
<div className="adm-switch-group" role="group" aria-label={t('语言切换')}>
<button
type="button"
className={lang === 'zh' ? 'is-active' : ''}
onClick={() => setLang('zh')}
title="中文"
>
</button>
<button
type="button"
className={lang === 'en' ? 'is-active' : ''}
onClick={() => setLang('en')}
title="English"
>
EN
</button>
</div>
<button
type="button"
className="adm-icon-switch"
onClick={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
aria-label={t('主题切换')}
title={themeMode === 'dark' ? t('浅色') : t('深色')}
>
{themeMode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
</button>
</div>
</header>
<Content className="adm-content">
<Outlet />
</Content>
</Layout>
</Layout>
</Layout>
</ConfigProvider>
);
}
export function AdminLayout() {
return (
<AdminPrefsProvider>
<AdminShell />
</AdminPrefsProvider>
);
}

View File

@ -0,0 +1,71 @@
/**
* //
* localStorage
*/
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { translate } from './adminI18n';
import type { AdminLang } from './adminI18n';
export type AdminThemeMode = 'light' | 'dark';
interface AdminPrefs {
lang: AdminLang;
themeMode: AdminThemeMode;
setLang: (lang: AdminLang) => void;
setThemeMode: (mode: AdminThemeMode) => void;
/** 按当前语言翻译文案(未收录时原样返回) */
t: (text: string) => string;
}
const STORAGE_KEY = 'cosmo_admin_prefs';
const AdminPrefsContext = createContext<AdminPrefs | null>(null);
function readStoredPrefs(): { lang: AdminLang; themeMode: AdminThemeMode } {
const fallback = { lang: 'zh' as AdminLang, themeMode: 'light' as AdminThemeMode };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as Partial<{ lang: AdminLang; themeMode: AdminThemeMode }>;
return {
lang: parsed.lang === 'en' ? 'en' : 'zh',
themeMode: parsed.themeMode === 'dark' ? 'dark' : 'light',
};
} catch {
return fallback;
}
}
export function AdminPrefsProvider({ children }: { children: ReactNode }) {
const [prefs, setPrefs] = useState(readStoredPrefs);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
}, [prefs]);
const setLang = useCallback((lang: AdminLang) => setPrefs((current) => ({ ...current, lang })), []);
const setThemeMode = useCallback(
(themeMode: AdminThemeMode) => setPrefs((current) => ({ ...current, themeMode })),
[],
);
const value = useMemo<AdminPrefs>(
() => ({
lang: prefs.lang,
themeMode: prefs.themeMode,
setLang,
setThemeMode,
t: (text: string) => translate(text, prefs.lang),
}),
[prefs.lang, prefs.themeMode, setLang, setThemeMode],
);
return <AdminPrefsContext.Provider value={value}>{children}</AdminPrefsContext.Provider>;
}
export function useAdminPrefs(): AdminPrefs {
const context = useContext(AdminPrefsContext);
if (!context) throw new Error('useAdminPrefs must be used inside AdminPrefsProvider');
return context;
}

View File

@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Card, Form, Modal, Popconfirm, Select, Space, Tag, Tooltip } from 'antd';
import { StarOutlined } from '@ant-design/icons';
import { Badge, Button, Form, Modal, Popconfirm, Select, Space, Tag, Tooltip } from 'antd';
import { GlobalOutlined, StarOutlined } 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 { CelestialBodyModal } from './celestial-bodies/CelestialBodyModal';
@ -34,6 +36,8 @@ function filterBodies(items: CelestialBody[], searchTerm: string) {
}
export function CelestialBodies() {
// 每页数量由系统参数 page_size 控制
const systemPageSize = useListPageSize();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<CelestialBody[]>([]);
const [keyword, setKeyword] = useState('');
@ -300,47 +304,53 @@ export function CelestialBodies() {
const selectedSystem = starSystems.find((system) => system.id === selectedSystemId);
return (
<>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<StarOutlined style={{ color: '#1890ff', fontSize: 18 }} />
<span style={{ fontWeight: 500 }}></span>
<Select
showSearch
style={{ width: 400, maxWidth: '100%' }}
value={selectedSystemId}
onChange={setSelectedSystemId}
placeholder="请选择恒星系统"
loading={starSystems.length === 0}
options={starSystems.map((system) => ({ value: system.id, label: system.name_zh || system.name }))}
filterOption={(input, option) => {
const system = starSystems.find((item) => item.id === option?.value);
const searchText = input.toLowerCase();
return Boolean(system && (
system.name.toLowerCase().includes(searchText)
|| system.name_zh?.toLowerCase().includes(searchText)
|| system.id.toString().includes(searchText)
));
}}
/>
{selectedSystem && <span style={{ color: '#888', fontSize: 12 }}>{selectedSystem.name_zh || selectedSystem.name}</span>}
</Space>
</Card>
<AdminPage
icon={<GlobalOutlined />}
title="天体数据管理" description="按恒星系统维护天体基础信息、显示资源与轨道数据">
<DataTable
title="天体数据管理"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onRefresh={() => void reloadData()}
onSearch={handleSearch}
searchPlaceholder="搜索 ID / 英文名 / 中文名"
onAdd={handleAdd}
addText="新增天体"
onEdit={handleEdit}
onDelete={handleDelete}
deleteConfirmTitle="确认删除该天体?"
deleteConfirmDescription="删除后该天体的位置与资源数据将不可用"
onStatusChange={handleStatusChange}
statusField="is_active"
rowKey="id"
pageSize={10}
pageSize={systemPageSize}
toolbar={
<Space size={8}>
<StarOutlined style={{ color: 'var(--adm-primary)' }} />
<Select
showSearch
style={{ width: 260 }}
value={selectedSystemId}
onChange={setSelectedSystemId}
placeholder="选择恒星系统"
loading={starSystems.length === 0}
options={starSystems.map((system) => ({ value: system.id, label: system.name_zh || system.name }))}
filterOption={(input, option) => {
const system = starSystems.find((item) => item.id === option?.value);
const searchText = input.toLowerCase();
return Boolean(system && (
system.name.toLowerCase().includes(searchText)
|| system.name_zh?.toLowerCase().includes(searchText)
|| system.id.toString().includes(searchText)
));
}}
/>
{selectedSystem ? (
<span className="adm-cell-sub">{selectedSystem.name_zh || selectedSystem.name}</span>
) : null}
</Space>
}
customActions={(record) => {
const canGenerateOrbit = ['planet', 'dwarf_planet'].includes(record.type);
return (
@ -352,8 +362,10 @@ export function CelestialBodies() {
cancelText="取消"
disabled={!canGenerateOrbit}
>
<Tooltip title={canGenerateOrbit ? '生成轨道' : '仅行星和矮行星可生成轨道'}>
<Button type="text" size="small" loading={loading} disabled={!canGenerateOrbit}></Button>
<Tooltip title={canGenerateOrbit ? '生成轨道数据' : '仅行星和矮行星可生成轨道'}>
<Button type="text" size="small" disabled={!canGenerateOrbit}>
</Button>
</Tooltip>
</Popconfirm>
);
@ -377,6 +389,6 @@ export function CelestialBodies() {
onResourceDelete={handleResourceDelete}
toast={toast}
/>
</>
</AdminPage>
);
}

View File

@ -2,10 +2,13 @@
* Celestial Events Management Page
*
*/
import { useState, useEffect } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Tag } from 'antd';
import { CalendarOutlined } 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';
@ -34,28 +37,25 @@ interface CelestialEvent {
}
export function CelestialEvents() {
// 每页数量由系统参数 page_size 控制
const systemPageSize = useListPageSize();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<CelestialEvent[]>([]);
const [filteredData, setFilteredData] = useState<CelestialEvent[]>([]);
const [keyword, setKeyword] = useState('');
const [bodyFilters, setBodyFilters] = useState<{ text: string; value: string }[]>([]);
const toast = useToast();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
const loadData = useCallback(async () => {
setLoading(true);
try {
const { data: result } = await request.get('/events', {
params: { limit: 500 }
});
setData(result || []);
setFilteredData(result || []);
// Generate body filters from data
const uniqueBodies = new Map<string, { id: string; name: string; name_zh?: string }>();
result?.forEach((event: CelestialEvent) => {
(result || []).forEach((event: CelestialEvent) => {
if (event.body && !uniqueBodies.has(event.body.id)) {
uniqueBodies.set(event.body.id, event.body);
}
@ -66,29 +66,33 @@ export function CelestialEvents() {
value: body.id
}));
setBodyFilters(filters);
} 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.title.toLowerCase().includes(lowerKeyword) ||
item.description?.toLowerCase().includes(lowerKeyword)
item.description?.toLowerCase().includes(lowerKeyword),
);
setFilteredData(filtered);
};
}, [data, keyword]);
const handleDelete = async (record: CelestialEvent) => {
try {
await request.delete(`/events/${record.id}`);
toast.success('删除成功');
loadData();
} catch (error) {
await loadData();
} catch {
toast.error('删除失败');
}
};
@ -216,18 +220,25 @@ export function CelestialEvents() {
];
return (
<DataTable
title="天体事件"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onSearch={handleSearch}
onDelete={handleDelete}
rowKey="id"
pageSize={20}
showAdd={false}
showEdit={false}
/>
<AdminPage
icon={<CalendarOutlined />}
title="天体事件" description="来自 NASA 的接近、食、合、冲等天象事件">
<DataTable
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onRefresh={() => void loadData()}
onSearch={setKeyword}
searchPlaceholder="搜索事件标题 / 描述"
onDelete={handleDelete}
deleteConfirmTitle="确认删除该事件?"
deleteConfirmDescription="删除后前台将不再展示该事件"
rowKey="id"
pageSize={systemPageSize}
showAdd={false}
showEdit={false}
/>
</AdminPage>
);
}

View File

@ -1,95 +0,0 @@
/**
* Change Password Page
*
*/
import { Form, Input, Button, Card } from 'antd';
import { LockOutlined } from '@ant-design/icons';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
export function ChangePassword() {
const [form] = Form.useForm();
const toast = useToast();
const handleSubmit = async (values: any) => {
try {
await request.put('/users/me/password', {
old_password: values.old_password,
new_password: values.new_password,
});
toast.success('密码修改成功');
form.resetFields();
} catch (error: any) {
toast.error(error.response?.data?.detail || '密码修改失败');
}
};
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Card title="修改密码" bordered={false}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
>
<Form.Item
label="当前密码"
name="old_password"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入当前密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
label="新密码"
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入新密码至少6位"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label="确认新密码"
name="confirm_password"
dependencies={['new_password']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}

View File

@ -1,74 +1,379 @@
/**
* Dashboard Page
*
*
*
* /system/statistics/tasks/system/cache/stats
* /system/data-cutoff-date/star-systems/statistics /events
*/
import { Card, Row, Col, Statistic } from 'antd';
import { GlobalOutlined, RocketOutlined, UserOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Badge, Button, Card, Col, Empty, Row, Space, Table, Tag, Tooltip } from 'antd';
import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
DashboardOutlined,
GlobalOutlined,
ReloadOutlined,
RocketOutlined,
StarOutlined,
SyncOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
import { AdminPage, StatCard } from '../../components/admin/AdminPage';
import { useAdminPrefs } from './AdminPrefsContext';
interface DashboardStats {
interface SystemStatistics {
total_bodies: number;
total_probes: number;
total_users: number;
}
interface StarSystemStatistics {
total_systems: number;
exo_systems: number;
total_planets: number;
exo_planets: number;
solar_system_planets: number;
}
interface CacheStats {
redis: {
connected: boolean;
used_memory_human: string;
keyspace_hits: number;
keyspace_misses: number;
total_commands_processed: number;
};
}
interface TaskItem {
id: number;
task_type: string;
status: string;
progress: number;
description: string;
created_at: string;
}
interface EventItem {
id: number;
title: string;
event_type: string;
event_time: string;
body?: { id: string; name: string; name_zh?: string | null };
}
const TASK_STATUS: Record<string, { badge: BadgeProps['status']; label: string }> = {
pending: { badge: 'default', label: '等待中' },
running: { badge: 'processing', label: '执行中' },
completed: { badge: 'success', label: '已完成' },
failed: { badge: 'error', label: '失败' },
cancelled: { badge: 'warning', label: '已取消' },
};
const EVENT_TYPE_LABELS: Record<string, string> = {
approach: '接近',
close_approach: '近距离接近',
eclipse: '食',
conjunction: '合',
opposition: '冲',
transit: '凌',
};
const EVENT_TYPE_COLORS: Record<string, string> = {
approach: 'blue',
close_approach: 'magenta',
eclipse: 'purple',
conjunction: 'cyan',
opposition: 'orange',
transit: 'green',
};
function formatTime(value: string) {
return new Date(value).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [stats, setStats] = useState<SystemStatistics | null>(null);
const [systemStats, setSystemStats] = useState<StarSystemStatistics | null>(null);
const [cacheStats, setCacheStats] = useState<CacheStats | null>(null);
const [cutoffDate, setCutoffDate] = useState<string | null>(null);
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [events, setEvents] = useState<EventItem[]>([]);
const [loading, setLoading] = useState(true);
const toast = useToast();
const navigate = useNavigate();
const { t } = useAdminPrefs();
useEffect(() => {
const fetchStatistics = async () => {
try {
setLoading(true);
const response = await request.get('/system/statistics');
setStats(response.data);
} catch (error) {
console.error('Failed to fetch statistics:', error);
toast.error('无法获取统计数据');
} finally {
setLoading(false);
}
};
fetchStatistics();
const fetchAll = useCallback(async () => {
const now = new Date().toISOString();
return Promise.allSettled([
request.get('/system/statistics'),
request.get('/star-systems/statistics'),
request.get('/system/cache/stats'),
request.get('/system/data-cutoff-date'),
request.get('/tasks', { params: { limit: 5 } }),
request.get('/events', { params: { start_time: now, limit: 5 } }),
]);
}, []);
type FetchResults = Awaited<ReturnType<typeof fetchAll>>;
const applyResults = useCallback((results: FetchResults) => {
const [statsRes, systemRes, cacheRes, cutoffRes, tasksRes, eventsRes] = results;
if (statsRes.status === 'fulfilled') setStats(statsRes.value.data);
if (systemRes.status === 'fulfilled') setSystemStats(systemRes.value.data);
if (cacheRes.status === 'fulfilled') setCacheStats(cacheRes.value.data);
if (cutoffRes.status === 'fulfilled') setCutoffDate(cutoffRes.value.data.cutoff_date);
if (tasksRes.status === 'fulfilled') setTasks(tasksRes.value.data || []);
if (eventsRes.status === 'fulfilled') setEvents(eventsRes.value.data || []);
if (results.every((item) => item.status === 'rejected')) {
toast.error('无法获取控制台数据');
}
setLoading(false);
}, [toast]);
const loadData = useCallback(async () => {
setLoading(true);
applyResults(await fetchAll());
}, [applyResults, fetchAll]);
useEffect(() => {
// 首屏加载:异步完成后才写入 state避免在 effect 内同步 setState。
void (async () => {
applyResults(await fetchAll());
})();
}, [applyResults, fetchAll]);
const taskColumns: ColumnsType<TaskItem> = [
{
title: t('状态'),
dataIndex: 'status',
width: 96,
render: (status: string) => {
const meta = TASK_STATUS[status] ?? { badge: 'default' as BadgeProps['status'], label: status };
return <Badge status={meta.badge} text={meta.label} />;
},
},
{
title: t('任务'),
dataIndex: 'description',
ellipsis: true,
render: (description: string, record) => (
<Tooltip title={description}>
<div>
<div className="adm-cell-strong"> #{record.id}</div>
<div className="adm-cell-sub">{description}</div>
</div>
</Tooltip>
),
},
{
title: t('时间'),
dataIndex: 'created_at',
width: 120,
render: (value: string) => <span className="adm-cell-sub">{formatTime(value)}</span>,
},
];
const eventColumns: ColumnsType<EventItem> = [
{
title: t('事件'),
dataIndex: 'title',
ellipsis: true,
render: (title: string, record) => (
<div>
<div className="adm-cell-strong">{title}</div>
<div className="adm-cell-sub">{record.body?.name_zh || record.body?.name || '-'}</div>
</div>
),
},
{
title: t('类型'),
dataIndex: 'event_type',
width: 130,
render: (type: string) => (
<Tag color={EVENT_TYPE_COLORS[type] || 'default'}>{EVENT_TYPE_LABELS[type] || type}</Tag>
),
},
{
title: t('时间'),
dataIndex: 'event_time',
width: 160,
render: (value: string) => new Date(value).toLocaleString('zh-CN'),
},
];
const redis = cacheStats?.redis;
const hitRate = redis && redis.keyspace_hits + redis.keyspace_misses > 0
? (redis.keyspace_hits / (redis.keyspace_hits + redis.keyspace_misses) * 100).toFixed(1)
: '—';
return (
<div>
<h1></h1>
<Row gutter={16} style={{ marginTop: 24 }}>
<Col span={8}>
<Card>
<Statistic
title="天体总数"
value={stats?.total_bodies ?? '-'}
<AdminPage
icon={<DashboardOutlined />}
title="控制台"
description="平台数据总览"
meta={
cutoffDate ? (
<Tag color="green">{t('数据截止日期')}{cutoffDate}</Tag>
) : undefined
}
actions={
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
</Button>
}
>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} xl={6}>
<StatCard
icon={<GlobalOutlined />}
label="天体总数"
value={stats?.total_bodies ?? 0}
unit="个"
footnote="包含行星、卫星、探测器等全部登记天体"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} xl={6}>
<StatCard
icon={<RocketOutlined />}
label="探测器"
value={stats?.total_probes ?? 0}
unit="个"
footnote="NASA Horizons 实时位置追踪"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} xl={6}>
<StatCard
icon={<StarOutlined />}
label="恒星系统"
value={systemStats?.total_systems ?? 0}
unit="个"
footnote="含太阳系与系外星系"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} xl={6}>
<StatCard
icon={<TeamOutlined />}
label="注册用户"
value={stats?.total_users ?? 0}
unit="人"
footnote="包含管理员与普通用户"
loading={loading}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} xl={14} className="adm-col-stretch">
<Card
className="adm-panel"
title={<span className="adm-section-title">{t('近期任务')}</span>}
extra={
<Button type="link" size="small" onClick={() => navigate('/admin/tasks')}>
{t('查看全部')}
</Button>
}
styles={{ body: { padding: 0 } }}
>
<Table
className="adm-table"
columns={taskColumns}
dataSource={tasks}
rowKey="id"
size="small"
loading={loading}
prefix={<GlobalOutlined />}
pagination={false}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('暂无任务记录')} />,
}}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="探测器"
value={stats?.total_probes ?? '-'}
loading={loading}
prefix={<RocketOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="注册用户数"
value={stats?.total_users ?? '-'}
loading={loading}
prefix={<UserOutlined />}
/>
<Col xs={24} xl={10} className="adm-col-stretch">
<Card className="adm-panel" title={<span className="adm-section-title">{t('系统状态')}</span>} loading={loading}>
<div className="adm-status-list">
<div className="adm-status-row">
<span>{t('Redis 缓存')}</span>
<b>
<Badge status={redis?.connected ? 'success' : 'error'} />
{redis?.connected ? t('已连接') : t('未连接')}
</b>
</div>
<div className="adm-status-row">
<span>{t('缓存内存占用')}</span>
<b>{redis?.used_memory_human ?? '—'}</b>
</div>
<div className="adm-status-row">
<span>{t('缓存命中率')}</span>
<b>{hitRate === '—' ? '—' : `${hitRate}%`}</b>
</div>
<div className="adm-status-row">
<span>{t('累计命令数')}</span>
<b>{redis?.total_commands_processed?.toLocaleString() ?? '—'}</b>
</div>
<div className="adm-status-divider" />
<div className="adm-status-row">
<span>{t('数据截止日期')}</span>
<b>{cutoffDate ?? '—'}</b>
</div>
<div className="adm-status-row">
<span>{t('太阳系行星')}</span>
<b>{systemStats?.solar_system_planets ?? '—'} {t('颗')}</b>
</div>
<div className="adm-status-row">
<span>{t('系外行星')}</span>
<b>{systemStats?.exo_planets ?? '—'} {t('颗')}</b>
</div>
<div className="adm-status-row">
<span>{t('星系总数')}</span>
<b>{systemStats?.total_planets ?? '—'} {t('颗')}</b>
</div>
</div>
</Card>
</Col>
</Row>
</div>
<Card
className="adm-panel"
title={<span className="adm-section-title">{t('即将发生的天象')}</span>}
extra={
<Space size={8}>
<SyncOutlined className="adm-cell-sub" />
<Button type="link" size="small" onClick={() => navigate('/admin/celestial-events')}>
{t('查看全部')}
</Button>
</Space>
}
styles={{ body: { padding: 0 } }}
>
<Table
className="adm-table"
columns={eventColumns}
dataSource={events}
rowKey="id"
size="small"
loading={loading}
pagination={false}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('暂无即将发生的天象事件')} />,
}}
/>
</Card>
</AdminPage>
);
}
}

View File

@ -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<string, unknown>;
}
const BODY_TYPE_LABELS: Record<string, string> = {
star: '恒星',
planet: '行星',
dwarf_planet: '矮行星',
satellite: '卫星',
comet: '彗星',
asteroid: '小行星',
probe: '探测器',
};
const BODY_TYPE_COLORS: Record<string, string> = {
star: 'gold',
planet: 'blue',
dwarf_planet: 'cyan',
satellite: 'geekblue',
comet: 'purple',
asteroid: 'volcano',
probe: 'magenta',
};
const EVENT_TYPE_LABELS: Record<string, string> = {
approach: '接近',
close_approach: '近距离接近',
eclipse: '食',
conjunction: '合',
opposition: '冲',
transit: '凌',
};
const EVENT_TYPE_COLORS: Record<string, string> = {
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<CelestialBody[]>([]);
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
const [bodyEvents, setBodyEvents] = useState<CelestialEvent[]>([]);
const [bodies, setBodies] = useState<FollowedBody[]>([]);
const [selectedBody, setSelectedBody] = useState<FollowedBody | null>(null);
const [events, setEvents] = useState<BodyEvent[]>([]);
const [eventsLoading, setEventsLoading] = useState(false);
const toast = useToast();
useEffect(() => {
loadFollowedBodies();
}, []);
const loadEvents = useCallback(async (body: FollowedBody) => {
setEventsLoading(true);
try {
const { data } = await request.get<BodyEvent[]>('/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<FollowedBody[]>('/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<string, string> = {
'star': '恒星',
'planet': '行星',
'dwarf_planet': '矮行星',
'satellite': '卫星',
'comet': '彗星',
'asteroid': '小行星',
'probe': '探测器',
};
return labelMap[type] || type;
};
const getBodyTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'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<string, string> = {
'approach': '接近',
'close_approach': '近距离接近',
'eclipse': '食',
'conjunction': '合',
'opposition': '冲',
'transit': '凌',
};
return labelMap[type] || type;
};
const getEventTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'approach': 'blue',
'close_approach': 'magenta',
'eclipse': 'purple',
'conjunction': 'cyan',
'opposition': 'orange',
'transit': 'green',
};
return colorMap[type] || 'default';
};
const eventColumns: ColumnsType<CelestialEvent> = [
{
title: '事件',
dataIndex: 'title',
key: 'title',
ellipsis: true,
width: '40%',
},
const eventColumns: ColumnsType<BodyEvent> = [
{ title: '事件', dataIndex: 'title', key: 'title', ellipsis: true, width: '40%' },
{
title: '类型',
dataIndex: 'event_type',
key: 'event_type',
width: 200,
render: (type) => (
<Tag color={getEventTypeColor(type)}>
{getEventTypeLabel(type)}
</Tag>
width: 160,
render: (type: string) => (
<Tag color={EVENT_TYPE_COLORS[type] || 'default'}>{EVENT_TYPE_LABELS[type] || type}</Tag>
),
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 (
<Row gutter={16} style={{ height: 'calc(100vh - 150px)' }}>
{/* 左侧:关注的天体列表 */}
<Col span={8}>
<Card
title={
<Space>
<StarFilled style={{ color: '#faad14' }} />
<span></span>
<Tag color="blue">{followedBodies.length}</Tag>
</Space>
}
extra={
<Button size="small" onClick={loadFollowedBodies} loading={loading}>
</Button>
}
bordered={false}
style={{ height: '100%', overflow: 'hidden' }}
bodyStyle={{ height: 'calc(100% - 57px)', overflowY: 'auto', padding: 0 }}
>
{followedBodies.length === 0 && !loading ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有关注任何天体"
style={{ marginTop: 60 }}
>
<p style={{ color: '#999', margin: '8px 0' }}>
</p>
</Empty>
) : (
<List
dataSource={followedBodies}
loading={loading}
renderItem={(body) => (
<List.Item
key={body.id}
onClick={() => handleSelectBody(body)}
style={{
cursor: 'pointer',
backgroundColor: selectedBody?.id === body.id ? '#f0f5ff' : 'transparent',
padding: '12px 16px',
transition: 'background-color 0.3s',
}}
actions={[
<AdminPage
icon={<StarOutlined />}
title="我的天体" description="查看已关注天体及其相关天象事件">
<Row gutter={[16, 16]}>
<Col xs={24} lg={9} xl={8}>
<Card
className="adm-panel adm-scroll-panel"
title={
<span className="adm-section-title">
<StarFilled style={{ color: '#d4a72c' }} />
<Tag>{bodies.length}</Tag>
</span>
}
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadFollowedBodies()} loading={loading}></Button>}
style={{ height: 520 }}
>
{bodies.length === 0 && !loading ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有关注任何天体"
style={{ marginTop: 72 }}
>
<div className="adm-cell-sub"></div>
</Empty>
) : (
<div className="adm-list">
{bodies.map((body) => (
<div
key={body.id}
className={`adm-list-item ${selectedBody?.id === body.id ? 'is-selected' : ''}`}
onClick={() => void handleSelectBody(body)}
>
<StarFilled style={{ color: '#d4a72c', fontSize: 18 }} />
<div className="adm-list-item-body">
<div className="adm-list-item-title">
<span className="adm-cell-strong">{body.name_zh || body.name}</span>
<Tag color={BODY_TYPE_COLORS[body.type] || 'default'}>
{BODY_TYPE_LABELS[body.type] || body.type}
</Tag>
</div>
<div className="adm-cell-sub">
{body.followed_at
? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}`
: body.name}
</div>
</div>
<Button
key="unfollow"
type="link"
type="text"
danger
size="small"
icon={<StarFilled />}
onClick={(e) => {
e.stopPropagation();
handleUnfollow(body.id);
onClick={(event) => {
event.stopPropagation();
void handleUnfollow(body.id);
}}
>
</Button>,
]}
>
<List.Item.Meta
avatar={<StarFilled style={{ color: '#faad14', fontSize: 20 }} />}
title={
<Space>
<span>{body.name_zh || body.name}</span>
<Tag color={getBodyTypeColor(body.type)} style={{ marginLeft: 4 }}>
{getBodyTypeLabel(body.type)}
</Tag>
</Space>
}
description={
body.followed_at
? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}`
: body.name_zh ? body.name : undefined
}
/>
</List.Item>
)}
/>
)}
</Card>
</Col>
</Button>
</div>
))}
</div>
)}
</Card>
</Col>
{/* 右侧:天体详情和事件 */}
<Col span={16}>
{selectedBody ? (
<Space direction="vertical" size="middle" style={{ width: '100%', height: '100%' }}>
{/* 天体资料 */}
<Col xs={24} lg={15} xl={16}>
<div className="adm-stack">
<Card
className="adm-panel"
title={
<Space>
<RocketOutlined />
<span>{selectedBody.name_zh || selectedBody.name}</span>
<Tag color={getBodyTypeColor(selectedBody.type)}>
{getBodyTypeLabel(selectedBody.type)}
</Tag>
</Space>
selectedBody ? (
<span className="adm-section-title">
<RocketOutlined />
{selectedBody.name_zh || selectedBody.name}
<Tag color={BODY_TYPE_COLORS[selectedBody.type] || 'default'}>
{BODY_TYPE_LABELS[selectedBody.type] || selectedBody.type}
</Tag>
</span>
) : '天体资料'
}
bordered={false}
>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="ID">{selectedBody.id}</Descriptions.Item>
<Descriptions.Item label="类型">
{getBodyTypeLabel(selectedBody.type)}
</Descriptions.Item>
<Descriptions.Item label="中文名">
{selectedBody.name_zh || '-'}
</Descriptions.Item>
<Descriptions.Item label="英文名">
{selectedBody.name}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedBody.is_active ? 'success' : 'default'}>
{selectedBody.is_active ? '活跃' : '已归档'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="关注时间">
{selectedBody.followed_at
? new Date(selectedBody.followed_at).toLocaleString('zh-CN')
: '-'}
</Descriptions.Item>
</Descriptions>
{selectedBody ? (
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="ID">{selectedBody.id}</Descriptions.Item>
<Descriptions.Item label="类型">
{BODY_TYPE_LABELS[selectedBody.type] || selectedBody.type}
</Descriptions.Item>
<Descriptions.Item label="中文名">{selectedBody.name_zh || '-'}</Descriptions.Item>
<Descriptions.Item label="英文名">{selectedBody.name}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedBody.is_active ? 'green' : 'default'}>
{selectedBody.is_active ? '活跃' : '已归档'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="关注时间">
{selectedBody.followed_at ? new Date(selectedBody.followed_at).toLocaleString('zh-CN') : '-'}
</Descriptions.Item>
</Descriptions>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请从左侧选择一个天体" />
)}
</Card>
{/* 天体事件列表 */}
<Card
title="相关天体事件"
bordered={false}
style={{ marginTop: 16 }}
>
<Card className="adm-panel" title="相关天体事件">
<Table
className="adm-table"
columns={eventColumns}
dataSource={bodyEvents}
dataSource={events}
rowKey="id"
loading={eventsLoading}
size="small"
pagination={{
pageSize: 10,
showSizeChanger: false,
showTotal: (total) => `${total}`,
}}
pagination={{ pageSize: 10, showSizeChanger: false, showTotal: (count) => `${count}` }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无相关事件"
/>
),
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无相关事件" />,
}}
expandable={{
expandedRowRender: (record) => (
<div style={{ padding: '8px 16px' }}>
<p style={{ margin: 0 }}>
<strong></strong>
{record.description}
</p>
{record.details && (
<p style={{ margin: '8px 0 0', color: '#666' }}>
<strong></strong>
{JSON.stringify(record.details, null, 2)}
</p>
)}
<div style={{ padding: '4px 8px' }}>
<div><strong></strong>{record.description || '-'}</div>
{record.details ? (
<pre className="adm-detail-pre">{JSON.stringify(record.details, null, 2)}</pre>
) : null}
</div>
),
}}
/>
</Card>
</Space>
) : (
<Card
bordered={false}
style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请从左侧选择一个天体查看详情"
/>
</Card>
)}
</Col>
</Row>
</div>
</Col>
</Row>
</AdminPage>
);
}

View File

@ -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 <Form.Item name={name} label={label} rules={[{ required: true, message: `请输入${label}` }]}><InputNumber min={min} max={max} step={step} style={{ width: '100%' }} addonAfter={suffix} /></Form.Item>;
return <Form.Item name={name} label={label} rules={[{ required: true, message: `请输入${label}` }]}><InputNumber min={min} max={max} step={step} style={{ width: '100%' }} suffix={suffix} /></Form.Item>;
}
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<RocketConfig | null>(null);
@ -97,9 +107,9 @@ export function Rockets() {
const columns: ColumnsType<RocketConfig> = [
{ title: '排序', dataIndex: 'sort_order', width: 70 },
{ title: '火箭', key: 'name', width: 210, render: (_, record) => <div><strong>{record.name_zh || record.name}</strong><div style={{ color: '#8c8c8c', fontSize: 12 }}>{record.name} · {record.code}</div></div> },
{ title: '火箭', key: 'name', width: 210, render: (_, record) => <div><div className="adm-cell-strong">{record.name_zh || record.name}</div><div className="adm-cell-sub">{record.name} · {record.code}</div></div> },
{ title: '制造方', dataIndex: 'manufacturer', width: 180, render: (value) => value || '-' },
{ title: '发射场', key: 'launch_site', width: 180, render: (_, record) => <div><strong>{record.launch_site_name}</strong><div style={{ color: '#8c8c8c', fontSize: 12 }}>{record.launch_latitude_deg.toFixed(4)}, {record.launch_longitude_deg.toFixed(4)}</div></div> },
{ title: '发射场', key: 'launch_site', width: 180, render: (_, record) => <div><div className="adm-cell-strong">{record.launch_site_name}</div><div className="adm-cell-sub">{record.launch_latitude_deg.toFixed(4)}, {record.launch_longitude_deg.toFixed(4)}</div></div> },
{ title: '总体尺寸', key: 'size', width: 130, render: (_, record) => <span>{record.height_m} m × {record.diameter_m} m</span> },
{ title: '一级', key: 'stage1', width: 170, render: (_, record) => <span>{record.stage_1.engine_count} / {(record.stage_1.max_thrust_n / 1_000_000).toFixed(2)} MN</span> },
{ title: '二级', key: 'stage2', width: 170, render: (_, record) => <span>{record.stage_2.engine_count} / {(record.stage_2.max_thrust_n / 1_000_000).toFixed(2)} MN</span> },
@ -108,9 +118,37 @@ export function Rockets() {
];
return (
<>
<DataTable title="火箭数据管理" columns={columns} dataSource={data} loading={loading} rowKey="id" total={total} currentPage={page} pageSize={pageSize} onPageChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} onSearch={(value) => { setSearch(value); setPage(1); }} onAdd={showCreate} onEdit={showEdit} onDelete={remove} />
<Modal title={editing ? '编辑火箭配置' : '新增火箭配置'} open={open} onCancel={() => setOpen(false)} afterOpenChange={syncFormValues} width={820} footer={[<Button key="cancel" onClick={() => setOpen(false)}></Button>, <Button key="save" type="primary" onClick={() => void save()}></Button>]} destroyOnHidden>
<AdminPage
icon={<RocketOutlined />}
title="火箭数据管理" description="发射模拟使用的运载火箭参数(质量、推力、几何尺寸与发射场)">
<DataTable
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
total={total}
currentPage={page}
pageSize={pageSize}
onPageChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }}
onRefresh={() => void load()}
onSearch={(value) => { setSearch(value); setPage(1); }}
searchPlaceholder="搜索火箭名称 / 编码"
onAdd={showCreate}
addText="新增火箭"
onEdit={showEdit}
onDelete={remove}
deleteConfirmTitle="确认删除该火箭?"
deleteConfirmDescription="删除后前台发射模拟将不再显示该运载器"
/>
<Modal
title={editing ? '编辑火箭配置' : '新增火箭配置'}
open={open}
onCancel={() => setOpen(false)}
afterOpenChange={syncFormValues}
width={820}
footer={[<Button key="cancel" onClick={() => setOpen(false)}></Button>, <Button key="save" type="primary" onClick={() => void save()}></Button>]}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Tabs items={[
{ key: 'base', label: '基本信息', children: <Row gutter={16}><Col xs={24} md={12}><Form.Item name="code" label="唯一编码" rules={[{ required: true }, { pattern: /^[a-z0-9][a-z0-9-]*$/, message: '使用小写字母、数字和连字符' }]}><Input placeholder="long-march-5" /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="name" label="英文名称" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="name_zh" label="中文名称"><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="manufacturer" label="制造方"><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="country" label="国家/地区"><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="color" label="箭体颜色" rules={[{ required: true }]}><Input type="color" style={{ height: 32 }} /></Form.Item></Col><Col xs={24} md={8}><NumberField name="height_m" label="箭体高度" suffix="m" /></Col><Col xs={24} md={8}><NumberField name="diameter_m" label="箭体直径" suffix="m" /></Col><Col xs={24} md={8}><NumberField name="payload_mass_kg" label="载荷质量" suffix="kg" /></Col><Col span={24}><Form.Item name="description" label="简介"><Input.TextArea rows={3} /></Form.Item></Col></Row> },
@ -120,6 +158,6 @@ export function Rockets() {
]} />
</Form>
</Modal>
</>
</AdminPage>
);
}

View File

@ -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<ScheduledJob[]>([]);
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) => <div><div style={{ fontWeight: 500 }}>{text}</div>{record.description && <div style={{ fontSize: 12, color: '#888' }}>{record.description}</div>}</div>,
render: (text, record) => (
<div>
<div className="adm-cell-strong">{text}</div>
{record.description ? <div className="adm-cell-sub">{record.description}</div> : null}
</div>
),
},
{
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' ? <Tag color="cyan">{func}</Tag> : <span style={{ color: '#ccc' }}>-</span>,
render: (func, record) => record.job_type === 'predefined' ? <Tag color="cyan">{func}</Tag> : <span className="adm-cell-sub">-</span>,
},
{ title: 'Cron 表达式', dataIndex: 'cron_expression', width: 130, render: (text) => <Tag color="green">{text}</Tag> },
{ title: '状态', dataIndex: 'is_active', width: 80, render: (active) => <Badge status={active ? 'success' : 'default'} text={active ? '启用' : '禁用'} /> },
@ -160,34 +169,35 @@ export function ScheduledJobs() {
title: '上次执行', width: 200,
render: (_, record) => record.last_run_at ? (
<div><div>{new Date(record.last_run_at).toLocaleString()}</div><Tag color={record.last_run_status === 'success' ? 'green' : 'red'}>{record.last_run_status === 'success' ? '成功' : '失败'}</Tag></div>
) : <span style={{ color: '#ccc' }}></span>,
},
{
title: '操作', key: 'action', width: 150,
render: (_, record) => (
<Space size="small">
<Tooltip title="立即执行"><Button type="text" icon={<PlayCircleOutlined />} onClick={() => handleRunNow(record)} style={{ color: '#52c41a' }} /></Tooltip>
<Tooltip title="编辑"><Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} style={{ color: '#1890ff' }} /></Tooltip>
<Popconfirm title="确认删除该任务?" onConfirm={() => handleDelete(record)} okText="删除" cancelText="取消">
<Tooltip title="删除"><Button type="text" danger icon={<DeleteOutlined />} /></Tooltip>
</Popconfirm>
</Space>
),
) : <span className="adm-cell-sub"></span>,
},
];
return (
<>
<AdminPage
icon={<ClockCircleOutlined />}
title="定时任务管理" description="按 Cron 表达式周期性执行内置任务或自定义代码">
<DataTable
title="定时任务管理"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onRefresh={() => void loadData()}
onSearch={setKeyword}
searchPlaceholder="搜索任务名称 / 描述"
onAdd={handleAdd}
addText="新增定时任务"
onEdit={handleEdit}
onDelete={handleDelete}
deleteConfirmTitle="确认删除该定时任务?"
deleteConfirmDescription="删除后该任务不再自动执行"
rowKey="id"
pageSize={10}
pageSize={systemPageSize}
customActions={(record) => (
<Tooltip title="立即执行一次">
<Button type="text" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRunNow(record)} />
</Tooltip>
)}
/>
<ScheduledJobModal
form={form}
@ -202,6 +212,6 @@ export function ScheduledJobs() {
selectedTask={selectedTask}
onClearSelectedTask={() => setSelectedTask(null)}
/>
</>
</AdminPage>
);
}

View File

@ -1,9 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Form, Popconfirm, Space, Tag } from 'antd';
import { DeleteOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons';
import { Button, Form, Tag, Tooltip } from 'antd';
import { EyeOutlined, StarOutlined } 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 { useToast } from '../../contexts/ToastContext';
import { request } from '../../utils/request';
import { StarSystemDetailsModal } from './star-systems/StarSystemDetailsModal';
@ -21,7 +23,14 @@ export function StarSystems() {
const [data, setData] = useState<StarSystem[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const systemPageSize = useListPageSize();
const [pageSize, setPageSize] = useState(systemPageSize);
// 系统参数加载完成后同步分页大小
useEffect(() => {
setPageSize(systemPageSize);
setCurrentPage(1);
}, [systemPageSize]);
const [searchKeyword, setSearchKeyword] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
@ -111,33 +120,37 @@ export function StarSystems() {
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{
title: '系统名称', dataIndex: 'name', key: 'name', width: 200,
render: (text, record) => <div><div className="font-medium">{text}</div>{record.name_zh && <div className="text-gray-500 text-xs">{record.name_zh}</div>}</div>,
render: (text, record) => (
<div>
<div className="adm-cell-strong">{text}</div>
{record.name_zh ? <div className="adm-cell-sub">{record.name_zh}</div> : null}
</div>
),
},
{ title: '主恒星', dataIndex: 'host_star_name', key: 'host_star_name', width: 150 },
{
title: '距离', dataIndex: 'distance_pc', key: 'distance', width: 120,
render: (pc, record) => pc ? <div><div>{pc.toFixed(2)} pc</div><div className="text-gray-500 text-xs">{(record.distance_ly || pc * 3.26).toFixed(2)} ly</div></div> : '-',
render: (pc, record) => pc ? <div><div>{pc.toFixed(2)} pc</div><div className="adm-cell-sub">{(record.distance_ly || pc * 3.26).toFixed(2)} ly</div></div> : '-',
},
{ title: '光谱类型', dataIndex: 'spectral_type', key: 'spectral_type', width: 100, render: (text) => text || '-' },
{
title: '恒星参数', key: 'stellar_params', width: 150,
render: (_, record) => <div className="text-xs">{record.radius_solar && <div>R: {record.radius_solar.toFixed(2)} R</div>}{record.mass_solar && <div>M: {record.mass_solar.toFixed(2)} M</div>}{record.temperature_k && <div>T: {record.temperature_k.toFixed(0)} K</div>}</div>,
render: (_, record) => (
<div className="adm-cell-sub">
{record.radius_solar && <div>R: {record.radius_solar.toFixed(2)} R</div>}
{record.mass_solar && <div>M: {record.mass_solar.toFixed(2)} M</div>}
{record.temperature_k && <div>T: {record.temperature_k.toFixed(0)} K</div>}
</div>
),
},
{ title: '恒星数量', dataIndex: 'star_count', key: 'star_count', width: 100, render: (count) => <Tag color={count > 1 ? 'gold' : 'default'}>{count}</Tag> },
{
title: '操作', key: 'actions', fixed: 'right', width: 180,
render: (_, record) => <Space size="small">
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetails(record)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
{record.id !== 1 && <Popconfirm title="确定要删除这个恒星系统吗?" description="这将同时删除该系统的所有天体!" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消" okButtonProps={{ danger: true }}><Button type="link" size="small" danger icon={<DeleteOutlined />}></Button></Popconfirm>}
</Space>,
},
];
return (
<div className="p-6">
<AdminPage
icon={<StarOutlined />}
title="恒星系统管理" description="管理恒星系统及其包含的天体,太阳系不可删除">
<DataTable
title="恒星系统管理"
columns={columns}
dataSource={data}
loading={loading}
@ -147,8 +160,19 @@ export function StarSystems() {
pageSize={pageSize}
onPageChange={(page, size) => { 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) => (
<Tooltip title="查看详情与天体列表">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetails(record)} />
</Tooltip>
)}
/>
<StarSystemModal
key={`${editingRecord?.id || 'new'}-${isModalOpen}`}
@ -159,6 +183,6 @@ export function StarSystems() {
onCancel={() => setIsModalOpen(false)}
/>
<StarSystemDetailsModal record={detailData} open={isDetailModalOpen} onClose={() => setIsDetailModalOpen(false)} />
</div>
</AdminPage>
);
}

View File

@ -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<StaticDataItem[]>([]);
const [filteredData, setFilteredData] = useState<StaticDataItem[]>([]);
const [keyword, setKeyword] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<StaticDataItem | null>(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) => <span className="adm-cell-mono">{JSON.stringify(text)}</span>,
},
];
return (
<>
<AdminPage
icon={<DatabaseOutlined />}
title="静态数据管理" description="星座、星系、星云、小行星带等静态天文数据JSON">
<DataTable
title="静态数据管理"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onSearch={handleSearch}
onRefresh={() => void loadData()}
onSearch={setKeyword}
searchPlaceholder="搜索名称 / 分类"
onAdd={handleAdd}
addText="新增数据"
onEdit={handleEdit}
onDelete={handleDelete}
rowKey="id"
pageSize={10}
pageSize={systemPageSize}
/>
<Modal
@ -168,6 +176,9 @@ export function StaticData() {
onOk={handleModalOk}
onCancel={() => setIsModalOpen(false)}
width={700}
okText="保存"
cancelText="取消"
forceRender
>
<Form
form={form}
@ -225,6 +236,6 @@ export function StaticData() {
</Form.Item>
</Form>
</Modal>
</>
</AdminPage>
);
}

View File

@ -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<string, string> = {
};
export function SystemSettings() {
// 每页数量由系统参数 page_size 控制
const systemPageSize = useListPageSize();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<SystemSetting[]>([]);
const [filteredData, setFilteredData] = useState<SystemSetting[]>([]);
const [keyword, setKeyword] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<SystemSetting | null>(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(
<>
<div>{data.message}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
<div className="adm-cell-sub" style={{ marginTop: 4 }}>
: {data.redis_cache.positions_keys} | NASA: {data.redis_cache.nasa_keys}
</div>
</>,
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) => (
<div>
<div style={{ fontFamily: 'monospace', fontWeight: 500 }}>{key}</div>
<div className="adm-cell-mono" style={{ fontWeight: 600 }}>{key}</div>
{record.is_public && (
<Badge status="success" text="前端可访问" style={{ fontSize: 11 }} />
)}
@ -208,20 +210,12 @@ export function SystemSettings() {
}
if (record.value_type === 'json' || typeof value === 'object') {
return (
<div style={{
maxWidth: 300,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontFamily: 'monospace',
fontSize: '12px',
color: '#666'
}}>
<div className="adm-cell-mono adm-cell-ellipsis">
{JSON.stringify(value)}
</div>
);
}
return <span style={{ fontWeight: 500 }}>{String(value)}</span>;
return <span className="adm-cell-strong">{String(value)}</span>;
},
},
{
@ -269,34 +263,18 @@ export function SystemSettings() {
];
return (
<>
{/* Cache Management Card */}
<Card
title={
<Space>
<ClearOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 16 }}
styles={{ body: { padding: 16 } }}
>
<Alert
title="系统维护操作说明"
description={
<div>
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
<li><strong></strong> Redis </li>
<li><strong></strong>使</li>
</ul>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Space>
<AdminPage
icon={<SettingOutlined />}
title="系统设置"
description="维护系统参数与缓存,修改后无需重启即可生效"
meta={
<>
<span> Redis </span>
<span></span>
</>
}
actions={
<>
<Popconfirm
title="确认清除所有缓存?"
description="此操作会清空所有缓存数据"
@ -305,42 +283,34 @@ export function SystemSettings() {
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
danger
type="primary"
icon={<ClearOutlined />}
loading={clearingCache}
>
<Button danger icon={<ClearOutlined />} loading={clearingCache}>
</Button>
</Popconfirm>
<Button
type="default"
icon={<SyncOutlined />}
onClick={handleReloadSettings}
loading={reloading}
>
<Button icon={<SyncOutlined />} onClick={handleReloadSettings} loading={reloading}>
</Button>
</Space>
</Card>
</>
}
>
<Divider />
{/* Settings Table */}
{/* 系统参数 */}
<DataTable
title="系统参数"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onSearch={handleSearch}
onRefresh={() => 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
>
<Form
form={form}
@ -468,6 +441,6 @@ export function SystemSettings() {
</Form.Item>
</Form>
</Modal>
</>
</AdminPage>
);
}

View File

@ -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<string, { badge: BadgeProps['status']; label: string }> = {
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<Task[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [currentTask, setCurrentTask] = useState<Task | null>(null);
const [detailsVisible, setDetailsVisible] = useState(false);
// Auto-refresh logic
const timerRef = useRef<ReturnType<typeof setInterval> | 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<Task> = [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{ title: 'ID', dataIndex: 'id', width: 70 },
{
title: '任务类型',
dataIndex: 'task_type',
width: 150,
render: (type: string) => <Tag color="blue">{type}</Tag>
},
{
title: '描述',
dataIndex: 'description',
ellipsis: true,
width: 160,
render: (type: string) => <Tag color="blue">{type}</Tag>,
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
width: 120,
width: 110,
render: (status: string) => {
const colors: Record<string, string> = {
pending: 'default',
running: 'processing',
completed: 'success',
failed: 'error',
cancelled: 'warning'
};
return <Badge status={colors[status] as any} text={status.toUpperCase()} />;
}
const meta = STATUS_META[status] ?? { badge: 'default' as BadgeProps['status'], label: status };
return <Badge status={meta.badge} text={meta.label} />;
},
},
{
title: '进度',
dataIndex: 'progress',
width: 200,
width: 180,
render: (progress: number, record: Task) => (
<Progress
percent={progress}
size="small"
status={record.status === 'failed' ? 'exception' : record.status === 'completed' ? 'success' : 'active'}
<Progress
percent={Math.round(progress ?? 0)}
size="small"
status={record.status === 'failed' ? 'exception' : record.status === 'completed' ? 'success' : 'active'}
/>
)
),
},
{
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) => (
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => {
setCurrentTask(record);
setDetailsVisible(true);
}}
>
</Button>
)
}
];
const isRunning = data.some((task) => task.status === 'running' || task.status === 'pending');
return (
<div className="p-0">
<AdminPage
icon={<ScheduleOutlined />}
title="系统任务"
description={isRunning ? '有任务正在执行,列表每 3 秒自动刷新' : '后台异步任务(数据下载、轨道生成等)执行记录'}
>
<DataTable
title="系统任务列表"
columns={columns}
dataSource={data}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onRefresh={() => void loadData()}
onSearch={setKeyword}
searchPlaceholder="搜索任务类型 / 描述 / 状态"
rowKey="id"
pageSize={systemPageSize}
showAdd={false}
showEdit={false}
customActions={(record) => (
<Tooltip title="查看执行详情与结果">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setCurrentTask(record)} />
</Tooltip>
)}
/>
<Modal
title="任务详情"
open={detailsVisible}
onCancel={() => setDetailsVisible(false)}
footer={null}
width={800}
title={`任务详情 #${currentTask?.id ?? ''}`}
open={Boolean(currentTask)}
onCancel={() => setCurrentTask(null)}
footer={<Button onClick={() => setCurrentTask(null)}></Button>}
width={720}
destroyOnHidden
>
{currentTask && (
<div style={{ maxWidth: '100%', overflowX: 'auto' }}>
<Descriptions bordered column={1}>
<Descriptions.Item label="任务ID">{currentTask.id}</Descriptions.Item>
<Descriptions.Item label="类型">{currentTask.task_type}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={currentTask.status === 'completed' ? 'success' : currentTask.status === 'running' ? 'processing' : 'default'} text={currentTask.status} />
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="任务类型">{currentTask.task_type}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge
status={STATUS_META[currentTask.status]?.badge ?? 'default'}
text={STATUS_META[currentTask.status]?.label ?? currentTask.status}
/>
</Descriptions.Item>
<Descriptions.Item label="进度">{Math.round(currentTask.progress ?? 0)}%</Descriptions.Item>
<Descriptions.Item label="描述">{currentTask.description}</Descriptions.Item>
<Descriptions.Item label="创建时间">{new Date(currentTask.created_at).toLocaleString('zh-CN')}</Descriptions.Item>
{currentTask.started_at && (
<Descriptions.Item label="开始时间">{new Date(currentTask.started_at).toLocaleString('zh-CN')}</Descriptions.Item>
)}
{currentTask.completed_at && (
<Descriptions.Item label="完成时间">{new Date(currentTask.completed_at).toLocaleString('zh-CN')}</Descriptions.Item>
)}
{currentTask.error_message && (
<Descriptions.Item label="错误信息">
<span style={{ color: '#cf222e' }}>{currentTask.error_message}</span>
</Descriptions.Item>
<Descriptions.Item label="描述">{currentTask.description}</Descriptions.Item>
{currentTask.error_message && (
<Descriptions.Item label="错误信息">
<Text type="danger" style={{ wordBreak: 'break-word' }}>{currentTask.error_message}</Text>
</Descriptions.Item>
)}
<Descriptions.Item label="结果">
<div className="bg-gray-100 p-2 rounded max-h-60 overflow-auto text-xs font-mono" style={{ maxWidth: '100%' }}>
{currentTask.result ? (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(currentTask.result, null, 2)}
</pre>
) : (
<span className="text-gray-400"></span>
)}
</div>
</Descriptions.Item>
</Descriptions>
</div>
)}
<Descriptions.Item label="结果">
<div className="adm-result-block">
{currentTask.result
? <pre>{JSON.stringify(currentTask.result, null, 2)}</pre>
: <span className="adm-cell-sub"></span>}
</div>
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
</AdminPage>
);
}

View File

@ -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<PasswordForm>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [uploading, setUploading] = useState(false);
const [userProfile, setUserProfile] = useState<any>(null);
const [profile, setProfile] = useState<ProfileData | null>(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<ProfileData>('/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 (
<Row gutter={24}>
<Col span={8}>
{/* User Avatar and Basic Info Card */}
<Card bordered={false} loading={loading}>
<div style={{ textAlign: 'center' }}>
<div style={{ position: 'relative', display: 'inline-block' }}>
<Avatar
size={100}
src={getAvatarUrl()}
icon={<UserOutlined />}
/>
<AdminPage
icon={<IdcardOutlined />}
title="个人资料" description="维护头像、姓名、邮箱,并在此修改登录密码">
<Row gutter={[16, 16]}>
<Col xs={24} md={9} lg={7}>
<Card className="adm-panel" loading={loading} styles={{ body: { padding: 24 } }}>
<div style={{ textAlign: 'center' }}>
<Avatar size={96} src={avatarUrl} icon={<UserOutlined />} />
<h2 style={{ margin: '16px 0 4px', fontSize: 18 }}>{profile?.full_name || profile?.username || '用户'}</h2>
<div className="adm-cell-sub">@{profile?.username}</div>
<Tag color={roleName === '管理员' ? 'green' : 'default'} style={{ marginTop: 10 }}>
{roleName}
</Tag>
<div style={{ marginTop: 16 }}>
<Upload
name="avatar"
@ -112,76 +144,115 @@ export function UserProfile() {
customRequest={handleAvatarUpload}
accept="image/*"
>
<Button icon={<UploadOutlined />} size="small" loading={uploading}>
<Button icon={<UploadOutlined />} loading={uploading}>
</Button>
</Upload>
</div>
</div>
<h2 style={{ marginTop: 24, marginBottom: 8 }}>
{userProfile?.full_name || userProfile?.username || '用户'}
</h2>
<p style={{ color: '#999', marginBottom: 24 }}>
@{userProfile?.username}
</p>
{userProfile && (
<Descriptions column={1} size="small">
<Descriptions.Item label="角色">
{userProfile.role === 'admin' ? '管理员' : '普通用户'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{new Date(userProfile.created_at).toLocaleString('zh-CN')}
</Descriptions.Item>
</Descriptions>
)}
<Descriptions column={1} size="small" style={{ marginTop: 24 }}>
<Descriptions.Item label="用户名">{profile?.username || '-'}</Descriptions.Item>
<Descriptions.Item label="角色">{roleName}</Descriptions.Item>
<Descriptions.Item label="注册时间">
{profile?.created_at ? new Date(profile.created_at).toLocaleString('zh-CN') : '-'}
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col xs={24} md={15} lg={17}>
<div className="adm-stack">
<Card className="adm-panel" title="基本信息" loading={loading}>
<Form form={form} layout="vertical" onFinish={handleSubmit} autoComplete="off" style={{ maxWidth: 460 }}>
<Form.Item label="姓名" name="full_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input prefix={<IdcardOutlined />} placeholder="请输入您的姓名" />
</Form.Item>
<Form.Item label="邮箱" name="email" rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}>
<Input prefix={<MailOutlined />} placeholder="请输入邮箱地址(可选)" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={saving}>
</Button>
</Form.Item>
</Form>
</Card>
<Card
className="adm-panel"
title="修改密码"
extra={<span className="adm-cell-sub">使</span>}
>
<Form
form={passwordForm}
layout="vertical"
onFinish={handlePasswordSubmit}
autoComplete="off"
style={{ maxWidth: 460 }}
>
<Form.Item
label="当前密码"
name="old_password"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入当前密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
label="新密码"
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少 6 位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入新密码(至少 6 位)"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label="确认新密码"
name="confirm_password"
dependencies={['new_password']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button htmlType="submit" loading={changingPassword}>
</Button>
</Form.Item>
</Form>
</Card>
</div>
</Card>
</Col>
<Col span={16}>
{/* Edit Profile Form */}
<Card title="编辑个人信息" bordered={false}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
>
<Form.Item
label="姓名"
name="full_name"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input
prefix={<IdcardOutlined />}
placeholder="请输入您的姓名"
size="large"
/>
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="请输入邮箱地址(可选)"
size="large"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} size="large" block>
</Button>
</Form.Item>
</Form>
</Card>
</Col>
</Row>
</Col>
</Row>
</AdminPage>
);
}

View File

@ -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<string, string> = {
admin: '管理员',
user: '普通用户',
};
export function Users() {
// 每页数量由系统参数 page_size 控制
const systemPageSize = useListPageSize();
const [data, setData] = useState<UserItem[]>([]);
const [filteredData, setFilteredData] = useState<UserItem[]>([]);
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<UserItem> = [
{ 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) => (
<div>
<div className="adm-cell-strong">{username}</div>
{record.full_name ? <div className="adm-cell-sub">{record.full_name}</div> : null}
</div>
),
},
{
title: '邮箱',
dataIndex: 'email',
render: (email: string | null) => email || <span className="adm-cell-sub"></span>,
},
{
title: '角色',
dataIndex: 'roles',
render: (roles: string[]) => roles.join(', '),
width: 120,
render: (roles: string[]) => (
roles.length > 0
? roles.map((role) => (
<Tag key={role} color={role === 'admin' ? 'green' : 'default'}>
{ROLE_LABELS[role] ?? role}
</Tag>
))
: <span className="adm-cell-sub">-</span>
),
},
{
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') : <span className="adm-cell-sub"></span>),
},
{
title: '注册时间',
dataIndex: 'created_at',
render: (text) => new Date(text).toLocaleDateString(),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
<Popconfirm
title="确认重置密码?"
description="密码将被重置为默认密码"
onConfirm={() => handleResetPassword(record)}
okText="确认"
cancelText="取消"
>
<Button type="link" icon={<ReloadOutlined />} size="small">
</Button>
</Popconfirm>
),
width: 130,
render: (text: string) => new Date(text).toLocaleDateString('zh-CN'),
},
];
return (
<DataTable
title="用户管理"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onSearch={handleSearch}
onStatusChange={handleStatusChange}
statusField="is_active"
rowKey="id"
pageSize={10}
// No onAdd, No onDelete, No onEdit
/>
<AdminPage
icon={<TeamOutlined />}
title="用户管理" description="查看平台注册用户、启用状态与角色,并可重置密码">
<DataTable
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onRefresh={() => void loadData()}
onSearch={setKeyword}
searchPlaceholder="搜索用户名 / 姓名 / 邮箱"
onStatusChange={handleStatusChange}
statusField="is_active"
rowKey="id"
pageSize={systemPageSize}
showAdd={false}
showEdit={false}
customActions={(record) => (
<Popconfirm
title="确认重置密码?"
description="密码将被重置为系统默认密码"
onConfirm={() => handleResetPassword(record)}
okText="确认"
cancelText="取消"
>
<Tooltip title="重置为默认密码">
<Button type="text" size="small" icon={<ReloadOutlined />} />
</Tooltip>
</Popconfirm>
)}
/>
</AdminPage>
);
}

View File

@ -0,0 +1,913 @@
/*
* Cosmo
*
* /
*
*/
:root {
/* 品牌色与登录页保持一致(深空绿) */
--adm-primary: #238636;
--adm-primary-hover: #2ea043;
--adm-primary-soft: rgba(35, 134, 54, 0.12);
/* 中性色 */
--adm-bg: #f5f6f8;
--adm-surface: #ffffff;
--adm-surface-muted: #fafbfc;
--adm-border: #e5e7eb;
--adm-border-strong: #d0d7de;
--adm-text: #1f2328;
--adm-text-secondary: #57606a;
--adm-text-muted: #8b949e;
/* 深色侧边栏 */
--adm-radius: 10px;
--adm-radius-sm: 8px;
--adm-gap: 16px;
/* 框架外壳(顶栏 + 左侧菜单)共用同一套明暗配色 */
--adm-chrome-bg: #ffffff;
--adm-chrome-border: #e5e7eb;
--adm-chrome-title: #1f2328;
--adm-chrome-text: #57606a;
--adm-chrome-text-active: #1a7f37;
--adm-chrome-hover: rgba(31, 35, 40, 0.05);
--adm-pill-bg: rgba(31, 35, 40, 0.04);
--adm-pill-hover: rgba(31, 35, 40, 0.08);
}
/* 深色主题:只切换变量,页面结构与文案不变 */
[data-adm-theme='dark'] {
--adm-bg: #0d1117;
--adm-surface: #161b22;
--adm-surface-muted: #1c2128;
--adm-border: #30363d;
--adm-border-strong: #3d444d;
--adm-text: #e6edf3;
--adm-text-secondary: #9ba7b4;
--adm-text-muted: #7d8590;
--adm-primary-soft: rgba(46, 160, 67, 0.18);
--adm-chrome-bg: #010409;
--adm-chrome-border: #21262d;
--adm-chrome-title: #ffffff;
--adm-chrome-text: #b6bfc9;
--adm-chrome-text-active: #3fb950;
--adm-chrome-hover: rgba(255, 255, 255, 0.06);
--adm-pill-bg: rgba(255, 255, 255, 0.04);
--adm-pill-hover: rgba(255, 255, 255, 0.1);
}
/* ------------------------------------------------------------ 布局骨架 */
.adm-shell {
height: 100vh;
overflow: hidden;
background: var(--adm-bg);
}
.adm-sider {
display: flex;
flex-direction: column;
background: var(--adm-chrome-bg) !important;
border-right: 1px solid var(--adm-chrome-border);
height: 100vh;
position: sticky;
top: 0;
}
/* antd 的 Sider 内容层需要自己撑成纵向 flex用户胶囊才能固定在左下角 */
.adm-sider > .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* 品牌区同时是「收起 / 展开」按钮:收起后悬停会变成展开图标 */
.adm-brand {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 56px;
padding: 0 14px 0 18px;
color: var(--adm-chrome-title);
border-bottom: 1px solid var(--adm-chrome-border);
border-left: none;
border-right: none;
border-top: none;
background: transparent;
text-align: left;
cursor: pointer;
flex: 0 0 auto;
transition: background 0.2s ease;
}
.adm-brand:hover {
background: var(--adm-chrome-hover);
}
.adm-brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
width: 24px;
height: 24px;
}
.adm-brand-text {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.adm-brand-text strong {
font-size: 15px;
letter-spacing: 0.06em;
}
.adm-brand-text span {
font-size: 11px;
color: var(--adm-chrome-text);
}
.adm-sider--collapsed .adm-brand {
justify-content: center;
padding: 0 6px;
}
.adm-brand-toggle {
margin-left: auto;
font-size: 14px;
color: var(--adm-chrome-text);
transition: color 0.2s ease;
}
.adm-brand:hover .adm-brand-toggle {
color: var(--adm-chrome-title);
}
/* 收起状态:默认显示 logo鼠标移上去换成「展开」图标 */
.adm-brand-hover-icon {
display: none;
}
.adm-sider--collapsed .adm-brand:hover .adm-brand-mark {
display: none;
}
.adm-sider--collapsed .adm-brand:hover .adm-brand-hover-icon {
display: inline-flex;
}
.adm-menu {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-inline-end: none !important;
padding: 8px 8px 0;
background: transparent !important;
}
.adm-menu .ant-menu-item,
.adm-menu .ant-menu-submenu-title {
border-radius: var(--adm-radius-sm);
margin-inline: 0;
width: 100%;
}
.adm-menu .ant-menu-item-selected {
background: var(--adm-primary-soft) !important;
color: var(--adm-chrome-text-active) !important;
font-weight: 600;
}
.adm-menu .ant-menu-item-selected::after {
display: none;
}
.adm-menu .ant-menu-submenu-selected > .ant-menu-submenu-title {
color: var(--adm-chrome-text-active);
}
/* 侧边栏底部:用户胶囊(二级菜单入口) */
.adm-user-dock {
flex: 0 0 auto;
padding: 10px;
border-top: 1px solid var(--adm-chrome-border);
}
.adm-user-pill {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: var(--adm-radius);
background: var(--adm-pill-bg);
color: var(--adm-chrome-title);
cursor: pointer;
text-align: left;
transition: background 0.2s ease, border-color 0.2s ease;
}
.adm-user-pill:hover,
.adm-user-pill[aria-expanded='true'] {
background: var(--adm-pill-hover);
border-color: var(--adm-chrome-text-active);
}
.adm-user-pill-meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
line-height: 1.3;
}
.adm-user-pill-meta strong {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adm-user-pill-meta span {
font-size: 11px;
color: var(--adm-chrome-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adm-user-pill-arrow {
font-size: 10px;
color: var(--adm-chrome-text);
transition: transform 0.2s ease;
}
.adm-user-pill[aria-expanded='true'] .adm-user-pill-arrow {
transform: rotate(180deg);
}
.adm-user-dock--collapsed {
display: flex;
justify-content: center;
}
.adm-user-dock--collapsed .adm-user-pill {
justify-content: center;
padding: 6px;
}
/* 用户二级菜单Popover */
.adm-user-menu .ant-popover-inner {
padding: 8px;
}
.adm-user-menu-head {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px 10px;
border-bottom: 1px solid var(--adm-border);
}
.adm-user-menu-head-meta {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.35;
}
.adm-user-menu-head-meta strong {
font-size: 13px;
}
.adm-user-menu-head-meta span {
font-size: 11px;
color: var(--adm-text-muted);
word-break: break-all;
}
.adm-user-menu-group {
padding: 8px 8px 2px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--adm-text-muted);
}
.adm-user-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--adm-radius-sm);
background: transparent;
color: var(--adm-text);
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.adm-user-menu-item:hover {
background: var(--adm-surface-muted);
}
.adm-user-menu-item.is-active {
background: var(--adm-primary-soft);
color: var(--adm-primary);
font-weight: 600;
}
.adm-user-menu-item.is-danger {
color: #cf222e;
}
.adm-user-menu-item.is-danger:hover {
background: rgba(207, 34, 46, 0.08);
}
.adm-user-menu-divider {
height: 1px;
margin: 6px 4px;
background: var(--adm-border);
}
/* ------------------------------------------------------------ 框架 Header */
/*
* /
* Header
*/
.adm-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 56px;
padding: 0 20px;
/* 与左侧菜单共用同一套明暗配色 */
background: var(--adm-chrome-bg);
border-bottom: 1px solid var(--adm-chrome-border);
flex: 0 0 auto;
}
.adm-header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.adm-header-section {
font-size: 14px;
font-weight: 600;
color: var(--adm-chrome-title);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adm-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.adm-switch-group {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px;
border: 1px solid var(--adm-border);
border-radius: 999px;
background: var(--adm-surface);
}
.adm-switch-group > button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--adm-text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease;
}
.adm-switch-group > button:hover {
color: var(--adm-text);
}
.adm-switch-group > button.is-active {
background: var(--adm-primary-soft);
color: var(--adm-primary);
}
.adm-icon-switch {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 32px;
border: 1px solid var(--adm-border);
border-radius: 999px;
background: var(--adm-surface);
color: var(--adm-text-secondary);
font-size: 15px;
cursor: pointer;
transition: all 0.18s ease;
}
.adm-icon-switch:hover {
color: var(--adm-primary);
border-color: var(--adm-primary);
}
.adm-content {
flex: 1 1 auto;
min-height: 0;
padding: 20px;
overflow: auto;
}
/* ------------------------------------------------------------ 页面骨架 */
.adm-page {
display: flex;
flex-direction: column;
/* 页头与内容之间的间距收紧,内容卡片之间保持原有呼吸感 */
gap: 10px;
min-height: 100%;
}
/* 每个页面唯一的 Header 卡片:标题 + 说明(+ 附加信息)+ 右侧操作 */
.adm-page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
padding: 16px 20px;
border: 1px solid var(--adm-border);
border-radius: var(--adm-radius);
background: var(--adm-surface);
}
.adm-page-head-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.adm-page-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex: 0 0 auto;
border-radius: 9px;
background: var(--adm-primary-soft);
color: var(--adm-primary);
font-size: 16px;
}
.adm-page-title {
margin: 0;
font-size: 17px;
font-weight: 600;
color: var(--adm-text);
line-height: 1.3;
}
.adm-page-desc {
margin: 3px 0 0;
font-size: 13px;
color: var(--adm-text-secondary);
}
.adm-page-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
font-size: 12px;
color: var(--adm-text-muted);
}
.adm-page-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.adm-page-body {
display: flex;
flex-direction: column;
gap: var(--adm-gap);
min-width: 0;
}
/* 统一的卡片/面板外观 */
.adm-panel.ant-card {
border-radius: var(--adm-radius);
border: 1px solid var(--adm-border);
box-shadow: 0 1px 2px rgba(31, 35, 40, 0.04);
}
.adm-panel .ant-card-head {
min-height: 52px;
border-bottom: 1px solid var(--adm-border);
}
.adm-panel .ant-card-head-title {
font-weight: 600;
font-size: 14px;
}
/* 数据表格:标题 + 工具栏 + 表格 + 分页 */
.adm-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
width: 100%;
}
.adm-table-toolbar-left,
.adm-table-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.adm-table .ant-table-thead > tr > th {
background: var(--adm-surface-muted) !important;
color: var(--adm-text-secondary);
font-weight: 600;
font-size: 13px;
border-bottom: 1px solid var(--adm-border);
}
.adm-table .ant-table-tbody > tr > td {
font-size: 13px;
}
.adm-table .ant-table-tbody > tr:hover > td {
background: rgba(35, 134, 54, 0.04) !important;
}
.adm-table .ant-table-pagination {
padding: 0 16px;
margin: 14px 0 !important;
}
.adm-table-empty {
padding: 32px 0;
}
/* 行内文案层级 */
.adm-cell-strong {
font-weight: 600;
color: var(--adm-text);
}
.adm-cell-sub {
font-size: 12px;
color: var(--adm-text-muted);
}
.adm-cell-mono {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: var(--adm-text-secondary);
}
.adm-cell-ellipsis {
display: block;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 控制台系统状态面板 */
.adm-status-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.adm-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 2px;
font-size: 13px;
color: var(--adm-text-secondary);
border-bottom: 1px dashed var(--adm-border);
}
.adm-status-row:last-child {
border-bottom: none;
}
.adm-status-row b {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--adm-text);
}
.adm-status-divider {
height: 1px;
margin: 6px 0;
background: var(--adm-border);
}
/* 统计卡片(控制台) */
.adm-stat-card {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
border: 1px solid var(--adm-border);
border-radius: var(--adm-radius);
background: var(--adm-surface);
box-shadow: 0 1px 2px rgba(31, 35, 40, 0.04);
}
.adm-stat-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.adm-stat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--adm-primary-soft);
color: var(--adm-primary);
font-size: 16px;
}
.adm-stat-label {
font-size: 13px;
color: var(--adm-text-secondary);
}
.adm-stat-value {
font-size: 26px;
font-weight: 600;
line-height: 1.1;
color: var(--adm-text);
}
.adm-stat-value em {
font-style: normal;
font-size: 13px;
font-weight: 500;
color: var(--adm-text-muted);
margin-left: 6px;
}
.adm-stat-foot {
margin-top: 8px;
font-size: 12px;
color: var(--adm-text-muted);
}
/* 内容面板内统一间距:卡片、分栏之间都用同一个间距令牌 */
.adm-stack {
display: flex;
flex-direction: column;
gap: var(--adm-gap);
min-width: 0;
}
/* 让并排的两栏卡片等高 */
.adm-col-stretch {
display: flex;
}
.adm-col-stretch > .ant-card {
flex: 1 1 auto;
height: 100%;
}
/* 分栏 / 提示类小面板 */
.adm-hint {
padding: 12px 14px;
border: 1px solid var(--adm-border);
border-radius: var(--adm-radius-sm);
background: var(--adm-surface-muted);
font-size: 13px;
color: var(--adm-text-secondary);
}
.adm-hint strong {
color: var(--adm-text);
}
.adm-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--adm-text);
}
/* 表单/弹窗中的内嵌信息面板 */
.adm-code-editor {
display: flex;
overflow: hidden;
border: 1px solid var(--adm-border);
border-radius: var(--adm-radius-sm);
background: var(--adm-surface-muted);
}
.adm-code-gutter {
padding: 4px 8px;
border-right: 1px solid var(--adm-border);
background: #f0f1f3;
color: var(--adm-text-muted);
text-align: right;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 13px;
line-height: 1.55;
user-select: none;
white-space: pre;
overflow: hidden;
}
.adm-code-editor .adm-code-input,
.adm-code-editor textarea.ant-input {
flex: 1;
border: none !important;
border-radius: 0 !important;
background: var(--adm-surface-muted) !important;
resize: none;
padding: 4px 8px;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 13px;
line-height: 1.55;
box-shadow: none !important;
}
.adm-inline-panel {
margin-top: 16px;
padding: 12px;
border: 1px solid var(--adm-border);
border-radius: var(--adm-radius-sm);
background: var(--adm-primary-soft);
font-size: 13px;
}
.adm-inline-panel-title {
margin-bottom: 8px;
font-weight: 600;
color: var(--adm-primary);
}
.adm-resource-preview {
width: 80px;
height: 80px;
object-fit: contain;
padding: 8px;
border: 1px solid var(--adm-border);
border-radius: var(--adm-radius-sm);
background: var(--adm-surface-muted);
}
/* 任务/事件结果的 JSON 展示 */
.adm-result-block {
max-width: 100%;
max-height: 320px;
overflow: auto;
padding: 10px 12px;
border: 1px solid var(--adm-border);
border-radius: var(--adm-radius-sm);
background: var(--adm-surface-muted);
}
.adm-result-block pre,
.adm-detail-pre {
margin: 0;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.adm-detail-pre {
margin-top: 8px;
color: var(--adm-text-secondary);
}
/* 表单区的辅助说明 */
.adm-form-note {
font-size: 12px;
color: var(--adm-text-muted);
margin-top: 6px;
}
.adm-split {
display: flex;
flex-direction: column;
gap: var(--adm-gap);
height: 100%;
min-height: 0;
}
.adm-scroll-panel.ant-card {
display: flex;
flex-direction: column;
min-height: 0;
}
.adm-scroll-panel .ant-card-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 0;
}
.adm-list-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 12px 16px !important;
border-bottom: 1px solid var(--adm-border);
transition: background 0.2s ease;
}
.adm-list-item:last-child {
border-bottom: none;
}
.adm-list-item-body {
flex: 1 1 auto;
min-width: 0;
}
.adm-list-item-title {
display: flex;
align-items: center;
gap: 8px;
}
.adm-list-item:hover {
background: var(--adm-surface-muted);
}
.adm-list-item.is-selected {
background: var(--adm-primary-soft);
}
/* 窄屏适配 */
@media (max-width: 900px) {
.adm-content {
padding: 14px;
}
.adm-header {
padding: 0 12px;
}
.adm-page-head {
padding: 16px;
}
}

View File

@ -0,0 +1,164 @@
/**
*
*
* key
*
*/
export type AdminLang = 'zh' | 'en';
const EN: Record<string, string> = {
// 框架
'管理后台': 'Admin Console',
'COSMO 管理后台': 'COSMO Admin',
'用户中心': 'Account Center',
'账号': 'Account',
'数据管理': 'Data',
'平台管理': 'Platform',
'概览': 'Overview',
'个人资料': 'Profile',
'返回可视化首页': 'Back to Explorer',
'退出登录': 'Sign out',
'收起菜单': 'Collapse menu',
'展开菜单': 'Expand menu',
'语言切换': 'Language',
'主题切换': 'Appearance',
'浅色': 'Light',
'深色': 'Dark',
'管理员': 'Administrator',
'普通用户': 'User',
// 页面标题与说明
'控制台': 'Dashboard',
'平台数据总览': 'Platform overview',
'天体总数': 'Celestial bodies',
'探测器': 'Probes',
'注册用户': 'Registered users',
'个': 'total',
'人': 'users',
'包含行星、卫星、探测器等全部登记天体': 'All registered bodies, including planets, moons and probes',
'NASA Horizons 实时位置追踪': 'Live positions from NASA Horizons',
'包含管理员与普通用户': 'Includes administrators and regular users',
'恒星系统': 'Star systems',
'含太阳系与系外星系': 'Solar and exoplanetary systems',
// 控制台内容面板
'近期任务': 'Recent tasks',
'系统状态': 'System status',
'即将发生的天象': 'Upcoming events',
'查看全部': 'View all',
'暂无任务记录': 'No task records',
'暂无即将发生的天象事件': 'No upcoming events',
'数据截止日期': 'Data cutoff',
'Redis 缓存': 'Redis cache',
'缓存内存占用': 'Cache memory',
'缓存命中率': 'Cache hit rate',
'累计命令数': 'Total commands',
'太阳系行星': 'Solar system planets',
'系外行星': 'Exoplanets',
'星系总数': 'Total planets',
'已连接': 'Connected',
'未连接': 'Disconnected',
'颗': 'planets',
'任务': 'Task',
'事件': 'Event',
'类型': 'Type',
'时间': 'Time',
'天体数据管理': 'Celestial Bodies',
'按恒星系统维护天体基础信息、显示资源与轨道数据': 'Maintain body data, resources and orbit data per star system',
'恒星系统管理': 'Star Systems',
'管理恒星系统及其包含的天体,太阳系不可删除': 'Manage star systems and their bodies. The Solar System cannot be deleted',
'静态数据管理': 'Static Data',
'星座、星系、星云、小行星带等静态天文数据JSON': 'Constellations, galaxies, nebulae and belts stored as JSON',
'天体事件': 'Celestial Events',
'来自 NASA 的接近、食、合、冲等天象事件': 'Approach, eclipse, conjunction and opposition events from NASA',
'用户管理': 'Users',
'查看平台注册用户、启用状态与角色,并可重置密码': 'Review registered users, status and roles; reset passwords',
'火箭数据管理': 'Launch Vehicles',
'发射模拟使用的运载火箭参数(质量、推力、几何尺寸与发射场)': 'Vehicle parameters used by the launch simulator',
'系统任务': 'Tasks',
'定时任务管理': 'Scheduled Jobs',
'按 Cron 表达式周期性执行内置任务或自定义代码': 'Run built-in tasks or custom code on a Cron schedule',
'系统设置': 'System Settings',
'维护系统参数与缓存,修改后无需重启即可生效': 'Maintain system parameters and caches; changes apply without restart',
'NASA 数据下载': 'NASA Data Download',
'按天补全天体的位置数据00:00 UTC下载任务可在“系统任务”中查看进度': 'Backfill daily positions (00:00 UTC); progress appears under Tasks',
'维护头像、姓名、邮箱,并在此修改登录密码': 'Update avatar, name, email and login password',
'我的天体': 'My Bodies',
'查看已关注天体及其相关天象事件': 'Followed bodies and their events',
// 通用控件
'新增': 'New',
'保存': 'Save',
'取消': 'Cancel',
'删除': 'Delete',
'编辑': 'Edit',
'详情': 'Details',
'刷新': 'Refresh',
'操作': 'Actions',
'状态': 'Status',
'暂无数据': 'No data',
'搜索...': 'Search…',
// 表格列标题
'ID': 'ID',
'用户': 'User',
'邮箱': 'Email',
'角色': 'Role',
'最近登录': 'Last login',
'注册时间': 'Registered',
'事件标题': 'Event',
'目标天体': 'Body',
'事件类型': 'Event type',
'事件时间': 'Event time',
'距离 (AU)': 'Distance (AU)',
'相对速度 (km/s)': 'Relative velocity (km/s)',
'描述': 'Description',
'来源': 'Source',
'英文名': 'English name',
'中文名': 'Chinese name',
'所属系统': 'Star system',
'资源配置': 'Resources',
'系统名称': 'System',
'主恒星': 'Host star',
'距离': 'Distance',
'光谱类型': 'Spectral type',
'恒星参数': 'Stellar params',
'恒星数量': 'Stars',
'分类': 'Category',
'数据 (JSON)': 'Data (JSON)',
'排序': 'Order',
'火箭': 'Vehicle',
'制造方': 'Manufacturer',
'发射场': 'Launch site',
'总体尺寸': 'Size',
'一级': 'Stage 1',
'二级': 'Stage 2',
'目标轨道': 'Target orbit',
'任务类型': 'Task type',
'进度': 'Progress',
'创建时间': 'Created',
'任务名称': 'Job name',
'任务函数': 'Function',
'Cron 表达式': 'Cron expression',
'上次执行': 'Last run',
'参数键': 'Key',
'名称': 'Label',
'当前值': 'Value',
'天体': 'Body',
// 搜索占位
'搜索用户名 / 姓名 / 邮箱': 'Search username, name or email',
'搜索事件标题 / 描述': 'Search event title or description',
'搜索 ID / 英文名 / 中文名': 'Search ID, English or Chinese name',
'搜索恒星系统名称...': 'Search star system name…',
'搜索名称 / 分类': 'Search name or category',
'搜索火箭名称 / 编码': 'Search vehicle name or code',
'搜索任务名称 / 描述': 'Search job name or description',
'搜索任务类型 / 描述 / 状态': 'Search task type, description or status',
'搜索参数键 / 名称 / 分类': 'Search key, label or category',
};
export function translate(text: string, lang: AdminLang): string {
if (lang !== 'en') return text;
return EN[text] ?? text;
}

View File

@ -0,0 +1,171 @@
/**
* antd /
*
* 绿
*
*/
import { theme } from 'antd';
import type { ThemeConfig } from 'antd';
/** 菜单与侧边栏共用同一套明暗配色,深浅两套令牌分别对应。 */
const menuLayoutTokens = {
itemHeight: 40,
itemMarginInline: 0,
itemBorderRadius: 8,
subMenuItemBorderRadius: 8,
activeBarWidth: 0,
} as const;
const menuTokens = {
light: {
...menuLayoutTokens,
itemBg: 'transparent',
subMenuItemBg: 'transparent',
itemColor: '#57606a',
itemHoverBg: 'rgba(31, 35, 40, 0.05)',
itemHoverColor: '#1f2328',
itemSelectedBg: 'rgba(35, 134, 54, 0.12)',
itemSelectedColor: '#1a7f37',
groupTitleColor: '#8b949e',
},
dark: {
...menuLayoutTokens,
darkItemBg: 'transparent',
darkSubMenuItemBg: 'transparent',
darkPopupBg: '#161b22',
darkItemColor: '#b6bfc9',
darkItemHoverBg: 'rgba(255, 255, 255, 0.06)',
darkItemHoverColor: '#ffffff',
darkItemSelectedBg: 'rgba(46, 160, 67, 0.2)',
darkItemSelectedColor: '#3fb950',
},
};
export const adminThemeLight: ThemeConfig = {
token: {
colorPrimary: '#238636',
colorInfo: '#238636',
colorLink: '#238636',
colorLinkHover: '#2ea043',
colorSuccess: '#1a7f37',
colorWarning: '#bc4c00',
colorError: '#cf222e',
colorBgLayout: '#f5f6f8',
colorBorder: '#e5e7eb',
colorBorderSecondary: '#eef0f2',
colorText: '#1f2328',
colorTextSecondary: '#57606a',
colorTextTertiary: '#8b949e',
borderRadius: 8,
borderRadiusLG: 10,
controlHeight: 34,
fontSize: 14,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', sans-serif",
},
components: {
Menu: menuTokens.light,
Layout: {
headerBg: '#ffffff',
headerHeight: 60,
headerPadding: '0 20px',
bodyBg: '#f5f6f8',
siderBg: '#0d1117',
},
Table: {
headerBg: '#fafbfc',
headerColor: '#57606a',
headerSplitColor: 'transparent',
rowHoverBg: 'rgba(35, 134, 54, 0.04)',
cellPaddingBlock: 10,
cellPaddingInline: 14,
borderColor: '#eef0f2',
},
Card: {
headerFontSize: 14,
headerHeight: 52,
paddingLG: 20,
},
Button: {
primaryShadow: 'none',
defaultShadow: 'none',
fontWeight: 500,
},
Modal: {
titleFontSize: 16,
borderRadiusLG: 12,
},
Tabs: {
horizontalItemPadding: '10px 0',
titleFontSize: 14,
},
Descriptions: {
labelBg: '#fafbfc',
},
Form: {
itemMarginBottom: 18,
verticalLabelPadding: '0 0 6px',
},
Segmented: {
itemSelectedBg: '#ffffff',
},
},
};
export const adminThemeDark: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#2ea043',
colorInfo: '#2ea043',
colorLink: '#3fb950',
colorLinkHover: '#56d364',
colorSuccess: '#3fb950',
colorWarning: '#d29922',
colorError: '#f85149',
colorBgLayout: '#0d1117',
colorBgContainer: '#161b22',
colorBgElevated: '#1c2128',
colorBorder: '#30363d',
colorBorderSecondary: '#21262d',
colorText: '#e6edf3',
colorTextSecondary: '#9ba7b4',
colorTextTertiary: '#7d8590',
borderRadius: 8,
borderRadiusLG: 10,
controlHeight: 34,
fontSize: 14,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', sans-serif",
},
components: {
Menu: menuTokens.dark,
Layout: {
headerBg: '#0d1117',
headerHeight: 48,
headerPadding: '0 20px',
bodyBg: '#0d1117',
siderBg: '#010409',
},
Table: {
headerBg: '#1c2128',
headerColor: '#9ba7b4',
headerSplitColor: 'transparent',
rowHoverBg: 'rgba(46, 160, 67, 0.1)',
borderColor: '#21262d',
cellPaddingBlock: 10,
cellPaddingInline: 14,
},
Card: {
headerFontSize: 14,
headerHeight: 52,
paddingLG: 20,
},
Modal: {
titleFontSize: 16,
borderRadiusLG: 12,
},
Descriptions: {
labelBg: '#1c2128',
},
},
};

View File

@ -127,7 +127,7 @@ function PhysicalAndOrbitFields() {
return (
<Alert
message="轨道参数与信息"
title="轨道参数与信息"
description={(
<div>
<Row gutter={16}>
@ -144,8 +144,8 @@ function PhysicalAndOrbitFields() {
</Row>
{orbitInfo?.num_points && (
<div style={{ marginTop: 16, borderTop: '1px solid #d9d9d9', background: '#f0f9ff', padding: 12, borderRadius: 4 }}>
<div style={{ fontWeight: 600, marginBottom: 8, color: '#1890ff' }}></div>
<div className="adm-inline-panel">
<div className="adm-inline-panel-title"></div>
<div style={{ display: 'flex', gap: 24 }}>
<div><strong>:</strong> {orbitInfo.num_points.toLocaleString()} </div>
{orbitInfo.period_days && <div><strong>:</strong> {orbitInfo.period_days.toFixed(2)} </div>}
@ -205,7 +205,7 @@ export function CelestialBodyModal({
<div>
<p>使 <strong>JPL Horizons ID</strong> </p>
<p style={{ marginTop: 4 }}>Hubble ID <code>-48</code>Voyager 1 ID <code>-31</code></p>
<p style={{ marginTop: 4, fontSize: 12, color: '#666' }}> ID ID</p>
<p className="adm-cell-sub" style={{ marginTop: 4 }}> ID ID</p>
</div>
)}
type="info"
@ -228,7 +228,16 @@ export function CelestialBodyModal({
);
return (
<Modal title={record ? '编辑天体' : '新增天体'} open={open} onOk={onOk} onCancel={onCancel} width={1000}>
<Modal
title={record ? '编辑天体' : '新增天体'}
open={open}
onOk={onOk}
onCancel={onCancel}
width={1000}
okText="保存"
cancelText="取消"
forceRender
>
<Form form={form} layout="vertical">
{record ? (
<Tabs

View File

@ -54,7 +54,7 @@ export function ResourceManager({
return (
<Form.Item label="资源配置">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
{resourceTypes.map(({ key, label, type }) => {
const items = currentResources?.[key] ?? [];
const icon = items[0];
@ -68,15 +68,7 @@ export function ResourceManager({
<img
src={`/upload/${icon.file_path}`}
alt="Icon preview"
style={{
width: 80,
height: 80,
objectFit: 'contain',
border: '1px solid #d9d9d9',
borderRadius: 4,
padding: 8,
backgroundColor: '#fafafa',
}}
className="adm-resource-preview"
/>
<div>
<Upload beforeUpload={(file) => onUpload(file, key)} showUploadList={false} disabled={uploading} accept="image/*">
@ -87,7 +79,7 @@ export function ResourceManager({
<Button type="link" danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
<div className="adm-cell-sub" style={{ marginTop: 4 }}>
({(icon.file_size / 1024).toFixed(2)} KB)
</div>
</div>
@ -108,7 +100,7 @@ export function ResourceManager({
<div key={resource.id} style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Tag color="blue">{resource.file_path}</Tag>
<span style={{ fontSize: 12, color: '#888' }}>
<span className="adm-cell-sub">
({(resource.file_size / 1024).toFixed(2)} KB)
</span>
<Popconfirm title="确认删除?" onConfirm={() => onDelete(resource.id)} okText="删除" cancelText="取消">
@ -118,7 +110,7 @@ export function ResourceManager({
{key === 'model' && (
<div style={{ marginLeft: 8 }}>
<Space size="small">
<span style={{ fontSize: 12, color: '#666' }}>:</span>
<span className="adm-cell-sub">:</span>
<InputNumber
size="small"
min={0.1}
@ -134,7 +126,7 @@ export function ResourceManager({
.catch(() => toast.error('更新失败'));
}}
/>
<span style={{ fontSize: 11, color: '#999' }}>(: Webb=0.3, =1.5)</span>
<span className="adm-cell-sub">(: Webb=0.3, =1.5)</span>
</Space>
</div>
)}

View File

@ -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 (
<div>
{cutoffDate && (
<Alert
title={`数据截止日期: ${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`}
description="选择左侧天体右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据00:00 UTC。"
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Row gutter={16}>
<Col span={8}>
<Card title="选择天体" loading={loading} extra={<Text type="secondary">: {selectedBodies.length}</Text>}>
<Collapse
defaultActiveKey={[]}
items={typeOrder.filter((type) => bodies[type]?.length).map((type) => {
const typeBodies = bodies[type];
return {
key: type,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{typeNames[type] || type}</span>
<Checkbox
checked={typeBodies.every((body) => 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); }}
></Checkbox>
</div>
),
children: (
<Space orientation="vertical" style={{ width: '100%' }}>
{typeBodies.map((body) => (
<Checkbox key={body.id} checked={selectedBodies.includes(body.id)} onChange={(event) => onBodySelect(body.id, event.target.checked)}>
{body.name_zh || body.name} ({body.id})
{!body.is_active && <Badge status="default" text="(未激活)" style={{ marginLeft: 8 }} />}
<AdminPage
icon={<DownloadOutlined />}
title="NASA 数据下载"
description="按天补全天体的位置数据00:00 UTC下载任务可在“系统任务”中查看进度"
meta={
cutoffDate ? (
<>
<Tag color="green">
{`${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`}
</Tag>
<span></span>
</>
) : undefined
}
actions={
<>
<RangePicker
value={dateRange}
onChange={onDateRangeChange}
disabledDate={disabledDate}
format="YYYY-MM-DD"
allowClear={false}
/>
<Tooltip title={selectedBodies.length === 0 ? '请先选择天体' : '删除所选天体在该区间的位置数据'}>
<Button danger icon={<DeleteOutlined />} onClick={onDelete} disabled={actionsDisabled} loading={deleting}>
</Button>
</Tooltip>
<Tooltip title={selectedBodies.length === 0 ? '请先选择天体' : '下载所选天体在该区间的位置数据'}>
<Button type="primary" icon={<DownloadOutlined />} onClick={onDownload} disabled={actionsDisabled} loading={downloading}>
</Button>
</Tooltip>
</>
}
>
<Row gutter={[16, 16]}>
<Col xs={24} lg={9} xl={8}>
<Card
className="adm-panel adm-scroll-panel"
title={<span className="adm-section-title"></span>}
extra={<Text type="secondary"> {selectedBodies.length} </Text>}
loading={loading}
style={{ height: 620 }}
>
<div style={{ padding: 16 }}>
<Collapse
defaultActiveKey={['planet']}
items={typeOrder.filter((type) => bodies[type]?.length).map((type) => {
const typeBodies = bodies[type];
const selectedCount = typeBodies.filter((body) => selectedBodies.includes(body.id)).length;
return {
key: type,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<span>
{typeNames[type] || type}
<span className="adm-cell-sub" style={{ marginLeft: 6 }}>
{selectedCount}/{typeBodies.length}
</span>
</span>
<Checkbox
checked={selectedCount === typeBodies.length}
indeterminate={selectedCount > 0 && selectedCount < typeBodies.length}
onChange={(event) => { event.stopPropagation(); onTypeSelectAll(type, event.target.checked); }}
>
</Checkbox>
))}
</Space>
),
};
})}
/>
</div>
),
children: (
<Space orientation="vertical" style={{ width: '100%' }}>
{typeBodies.map((body) => (
<Checkbox
key={body.id}
checked={selectedBodies.includes(body.id)}
onChange={(event) => onBodySelect(body.id, event.target.checked)}
>
{body.name_zh || body.name} ({body.id})
{!body.is_active && <Badge status="default" text="未激活" style={{ marginLeft: 8 }} />}
</Checkbox>
))}
</Space>
),
};
})}
/>
</div>
</Card>
</Col>
<Col span={16}>
<Card
title="选择日期"
extra={(
<Space>
<RangePicker value={dateRange} onChange={onDateRangeChange} disabledDate={disabledDate} format="YYYY-MM-DD" allowClear={false} />
<Button danger icon={<DeleteOutlined />} onClick={onDelete} disabled={selectedBodies.length === 0 || downloading || deleting} loading={deleting}></Button>
<Button type="primary" icon={<DownloadOutlined />} onClick={onDownload} disabled={selectedBodies.length === 0 || downloading || deleting} loading={downloading}></Button>
</Space>
)}
>
<Col xs={24} lg={15} xl={16}>
<Card className="adm-panel" title={<span className="adm-section-title"></span>}>
<Spin spinning={loadingDates} indicator={<LoadingOutlined spin />}>
{selectedBodies.length > 1 && (
{selectedBodies.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<Text strong></Text>
<Space wrap style={{ marginLeft: 8 }}>{selectedBodies.map((bodyId) => <Tag key={bodyId} color={bodyId === activeBodyForCalendar ? 'blue' : 'default'} style={{ cursor: 'pointer' }} onClick={() => onActiveBodyChange(bodyId)}>{getBodyLabel(bodyId)}</Tag>)}</Space>
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}></div>
<Text strong>{selectedBodies.length > 1 ? '天体列表:' : '当前天体:'}</Text>
<Space wrap style={{ marginLeft: 8 }}>
{selectedBodies.map((bodyId) => (
<Tag
key={bodyId}
color={bodyId === activeBodyForCalendar ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => onActiveBodyChange(bodyId)}
>
{getBodyLabel(bodyId)}
</Tag>
))}
</Space>
{selectedBodies.length > 1 ? (
<div className="adm-cell-sub" style={{ marginTop: 8 }}></div>
) : null}
</div>
) : (
<div className="adm-hint" style={{ marginBottom: 16 }}></div>
)}
<div style={{ marginBottom: 16 }}>
<Space>
<Badge status="success" text="已有数据" />
<Badge status="default" text="无数据(点击下载)" />
</Space>
</div>
{downloading && (
<div style={{ marginBottom: 16 }}>
<Progress percent={downloadProgress.total ? Math.round((downloadProgress.current / downloadProgress.total) * 100) : 0} status="active" />
<Text type="secondary">{downloadProgress.current} / {downloadProgress.total}</Text>
</div>
)}
{selectedBodies.length === 1 && activeBodyForCalendar && <div style={{ marginBottom: 16 }}><Text strong></Text><Tag color="blue" style={{ marginLeft: 8 }}>{getBodyLabel(activeBodyForCalendar)}</Tag></div>}
<div style={{ marginBottom: 16 }}><Space><Badge status="success" text="已有数据" /><Badge status="default" text="无数据(点击下载)" /></Space></div>
{downloading && <div style={{ marginBottom: 16 }}><Progress percent={downloadProgress.total ? Math.round((downloadProgress.current / downloadProgress.total) * 100) : 0} status="active" /><Text type="secondary">: {downloadProgress.current} / {downloadProgress.total}</Text></div>}
<Calendar fullscreen={false} value={dateRange[0]} onSelect={onCalendarDateClick} cellRender={onDateCellRender} disabledDate={disabledDate} validRange={[dateRange[0], dateRange[1]]} />
<Calendar
fullscreen={false}
value={dateRange[0]}
onSelect={onCalendarDateClick}
cellRender={onDateCellRender}
disabledDate={disabledDate}
validRange={[dateRange[0], dateRange[1]]}
/>
</Spin>
</Card>
</Col>
</Row>
<Modal title={`位置数据 - ${viewingDateData?.date || ''}`} open={!!viewingDateData} onCancel={onCloseDateData} footer={null} width={900}>
{viewingDateData && (<Spin spinning={loadingDateData}><Table dataSource={viewingDateData.bodies} rowKey="id" pagination={false} size="small" scroll={{ x: true }} columns={[
{ title: '天体', dataIndex: 'name', key: 'name', fixed: 'left', width: 120 },
{ title: 'X (AU)', dataIndex: 'x', key: 'x', render: (value?: number) => 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 && <div style={{ textAlign: 'center', padding: 20, color: '#999' }}></div>}</Spin>)}
<Modal
title={`位置数据 - ${viewingDateData?.date || ''}`}
open={Boolean(viewingDateData)}
onCancel={onCloseDateData}
footer={<Button onClick={onCloseDateData}></Button>}
width={900}
destroyOnHidden
>
{viewingDateData && (
<Spin spinning={loadingDateData}>
<Table
className="adm-table"
dataSource={viewingDateData.bodies}
rowKey="id"
pagination={false}
size="small"
scroll={{ x: true }}
locale={{ emptyText: '该日期暂无数据' }}
columns={[
{ title: '天体', dataIndex: 'name', key: 'name', fixed: 'left', width: 140 },
{ title: 'X (AU)', dataIndex: 'x', key: 'x', render: (value?: number) => 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) || '-' },
]}
/>
</Spin>
)}
</Modal>
</div>
</AdminPage>
);
}

View File

@ -32,15 +32,15 @@ function CodeEditor({
const lineNumbers = Array.from({ length: lineCount }, (_, index) => index + 1).join('\n');
return (
<div style={{ display: 'flex', border: '1px solid #d9d9d9', borderRadius: 6, overflow: 'hidden', backgroundColor: '#fafafa' }}>
<div style={{ padding: '4px 8px', backgroundColor: '#f0f0f0', borderRight: '1px solid #d9d9d9', color: '#999', textAlign: 'right', fontFamily: 'monospace', lineHeight: 1.5, fontSize: 14, userSelect: 'none', whiteSpace: 'pre', overflow: 'hidden' }}>
<div className="adm-code-editor">
<div className="adm-code-gutter">
{lineNumbers}
</div>
<Input.TextArea
value={value}
onChange={onChange}
placeholder={placeholder}
style={{ border: 'none', borderRadius: 0, resize: 'none', fontFamily: 'monospace', lineHeight: 1.5, fontSize: 14, padding: '4px 8px', flex: 1, backgroundColor: '#fafafa' }}
className="adm-code-input"
rows={20}
spellCheck={false}
wrap="off"
@ -88,19 +88,19 @@ export function ScheduledJobModal({
<Row gutter={16}>
<Col span={12}>
<Form.Item
<Form.Item
name="cron_expression"
label={(
<Space>
<span>Cron </span>
<Tooltip title="格式:分 时 日 月 周 (例如: 0 0 * * * 表示每天零点)">
<QuestionCircleOutlined style={{ color: '#888' }} />
<QuestionCircleOutlined style={{ color: '#8b949e' }} />
</Tooltip>
</Space>
)}
rules={[{ required: true, message: '请输入 Cron 表达式' }]}
>
<Input placeholder="0 0 * * *" style={{ fontFamily: 'monospace' }} />
<Input placeholder="0 0 * * *" className="adm-cell-mono" />
</Form.Item>
</Col>
<Col span={12}>
@ -125,7 +125,7 @@ export function ScheduledJobModal({
{selectedTask && (
<Card size="small" title={<Space><InfoCircleOutlined /><span></span></Space>} style={{ marginBottom: 16 }}>
<Alert message={selectedTask.description} type="info" showIcon style={{ marginBottom: 16 }} />
<Alert title={selectedTask.description} type="info" showIcon style={{ marginBottom: 16 }} />
{selectedTask.parameters.map((parameter) => (
<Form.Item
key={parameter.name}
@ -155,7 +155,7 @@ export function ScheduledJobModal({
const codeTab = (
<>
<Alert
message="自定义代码执行环境"
title="自定义代码执行环境"
description={(
<div>
<p></p>
@ -179,7 +179,16 @@ export function ScheduledJobModal({
);
return (
<Modal title={record ? '编辑任务' : '新增任务'} open={open} onOk={onOk} onCancel={onCancel} width={900} destroyOnHidden>
<Modal
title={record ? '编辑定时任务' : '新增定时任务'}
open={open}
onOk={onOk}
onCancel={onCancel}
width={900}
okText="保存"
cancelText="取消"
forceRender
>
<Form form={form} layout="vertical">
<Tabs
activeKey={activeTab}

View File

@ -1,45 +1,104 @@
import { Descriptions, Modal, Tag } from 'antd';
import type { StarSystemWithBodies } from './types';
/**
*
*/
import { Button, Descriptions, Empty, Modal, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
export function StarSystemDetailsModal({ record, open, onClose }: { record: StarSystemWithBodies | null; open: boolean; onClose: () => void }) {
return (
<Modal title={`恒星系统详情 - ${record?.name_zh || record?.name}`} open={open} onCancel={onClose} footer={null} width={900}>
{record && (
import type { StarSystemBody, StarSystemWithBodies } from './types';
const BODY_TYPE_LABELS: Record<string, string> = {
star: '恒星',
planet: '行星',
dwarf_planet: '矮行星',
satellite: '卫星',
comet: '彗星',
probe: '探测器',
};
export function StarSystemDetailsModal({
record,
open,
onClose,
}: {
record: StarSystemWithBodies | null;
open: boolean;
onClose: () => void;
}) {
const columns: ColumnsType<StarSystemBody> = [
{
title: '天体',
key: 'name',
render: (_, body) => (
<div>
<div className="adm-cell-strong">{body.name_zh || body.name}</div>
<div className="adm-cell-sub">{body.id}</div>
</div>
),
},
{
title: '类型',
dataIndex: 'type',
width: 110,
render: (type: string) => <Tag color="blue">{BODY_TYPE_LABELS[type] ?? type}</Tag>,
},
{
title: '轨道参数',
key: 'orbit',
width: 320,
render: (_, body) => (
<div className="adm-cell-sub">
{body.extra_data?.semi_major_axis_au != null ? <div>{body.extra_data.semi_major_axis_au.toFixed(4)} AU</div> : null}
{body.extra_data?.period_days != null ? <div>{body.extra_data.period_days.toFixed(2)} </div> : null}
{body.extra_data?.radius_earth != null ? <div>{body.extra_data.radius_earth.toFixed(2)} R</div> : null}
</div>
),
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
];
return (
<Modal
title={`恒星系统详情 · ${record?.name_zh || record?.name || ''}`}
open={open}
onCancel={onClose}
footer={<Button onClick={onClose}></Button>}
width={900}
destroyOnHidden
>
{record ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="系统ID">{record.id}</Descriptions.Item>
<Descriptions.Item label="系统 ID">{record.id}</Descriptions.Item>
<Descriptions.Item label="主恒星">{record.host_star_name}</Descriptions.Item>
<Descriptions.Item label="距离">{record.distance_pc ? `${record.distance_pc.toFixed(2)} pc (~${(record.distance_ly || record.distance_pc * 3.26).toFixed(2)} ly)` : '-'}</Descriptions.Item>
<Descriptions.Item label="距离">
{record.distance_pc
? `${record.distance_pc.toFixed(2)} pc${(record.distance_ly || record.distance_pc * 3.26).toFixed(2)} ly`
: '-'}
</Descriptions.Item>
<Descriptions.Item label="光谱类型">{record.spectral_type || '-'}</Descriptions.Item>
<Descriptions.Item label="恒星半径">{record.radius_solar ? `${record.radius_solar.toFixed(2)} R☉` : '-'}</Descriptions.Item>
<Descriptions.Item label="恒星质量">{record.mass_solar ? `${record.mass_solar.toFixed(2)} M☉` : '-'}</Descriptions.Item>
<Descriptions.Item label="表面温度">{record.temperature_k ? `${record.temperature_k.toFixed(0)} K` : '-'}</Descriptions.Item>
<Descriptions.Item label="恒星半径">
{record.radius_solar ? `${record.radius_solar.toFixed(2)} R☉` : '-'}
</Descriptions.Item>
<Descriptions.Item label="恒星质量">
{record.mass_solar ? `${record.mass_solar.toFixed(2)} M☉` : '-'}
</Descriptions.Item>
<Descriptions.Item label="表面温度">
{record.temperature_k ? `${record.temperature_k.toFixed(0)} K` : '-'}
</Descriptions.Item>
<Descriptions.Item label="天体数量">{record.body_count}</Descriptions.Item>
</Descriptions>
{record.bodies.length > 0 && (
<div className="mt-4">
<h4 className="text-lg font-semibold mb-2"></h4>
<div className="space-y-2">
{record.bodies.map((body) => (
<div key={body.id} className="border rounded p-3 bg-gray-50">
<div className="flex justify-between items-start">
<div><div className="font-medium">{body.name_zh || body.name}</div><div className="text-xs text-gray-500">{body.id}</div></div>
<Tag color="blue">{body.type}</Tag>
</div>
{body.description && <div className="text-sm text-gray-600 mt-2">{body.description}</div>}
{body.extra_data && <div className="text-xs text-gray-500 mt-2 grid grid-cols-3 gap-2">
{body.extra_data.semi_major_axis_au && <div>: {body.extra_data.semi_major_axis_au.toFixed(4)} AU</div>}
{body.extra_data.period_days && <div>: {body.extra_data.period_days.toFixed(2)} </div>}
{body.extra_data.radius_earth && <div>: {body.extra_data.radius_earth.toFixed(2)} R</div>}
</div>}
</div>
))}
</div>
</div>
)}
<Table
className="adm-table"
columns={columns}
dataSource={record.bodies}
rowKey="id"
size="small"
pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该系统下还没有天体" /> }}
/>
</div>
)}
) : null}
</Modal>
);
}

View File

@ -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) => <Col xs={24} md={span}>{node}</Col>;
return (
<>
<div className="grid grid-cols-3 gap-4">
<Form.Item name="name" label="系统名称" rules={[{ required: true, message: '请输入系统名称' }]}><Input placeholder="例如: Proxima Cen System" /></Form.Item>
<Form.Item name="name_zh" label="中文名称"><Input placeholder="例如: 比邻星系统" /></Form.Item>
<Form.Item name="host_star_name" label="主恒星名称" rules={[{ required: true, message: '请输入主恒星名称' }]}><Input placeholder="例如: Proxima Cen" /></Form.Item>
</div>
<div className="grid grid-cols-3 gap-4">
<Form.Item name="distance_pc" label="距离 (pc)"><InputNumber style={{ width: '100%' }} placeholder="秒差距" step={0.01} /></Form.Item>
<Form.Item name="ra" label="赤经 (度)"><InputNumber style={{ width: '100%' }} min={0} max={360} step={0.001} /></Form.Item>
<Form.Item name="dec" label="赤纬 (度)"><InputNumber style={{ width: '100%' }} min={-90} max={90} step={0.001} /></Form.Item>
</div>
<div className="grid grid-cols-3 gap-4">
<Form.Item name="position_x" label="X坐标 (pc)"><InputNumber style={{ width: '100%' }} step={0.01} /></Form.Item>
<Form.Item name="position_y" label="Y坐标 (pc)"><InputNumber style={{ width: '100%' }} step={0.01} /></Form.Item>
<Form.Item name="position_z" label="Z坐标 (pc)"><InputNumber style={{ width: '100%' }} step={0.01} /></Form.Item>
</div>
<div className="grid grid-cols-3 gap-4">
<Form.Item name="spectral_type" label="光谱类型"><Input placeholder="例如: M5.5 V" /></Form.Item>
<Form.Item name="radius_solar" label="恒星半径 (R☉)"><InputNumber style={{ width: '100%' }} min={0} step={0.01} /></Form.Item>
<Form.Item name="mass_solar" label="恒星质量 (M☉)"><InputNumber style={{ width: '100%' }} min={0} step={0.01} /></Form.Item>
</div>
<div className="grid grid-cols-3 gap-4">
<Form.Item name="temperature_k" label="表面温度 (K)"><InputNumber style={{ width: '100%' }} min={0} step={100} /></Form.Item>
<Form.Item name="magnitude" label="视星等"><InputNumber style={{ width: '100%' }} step={0.1} /></Form.Item>
<Form.Item name="luminosity_solar" label="光度 (L☉)"><InputNumber style={{ width: '100%' }} min={0} step={0.01} /></Form.Item>
</div>
<div className="grid grid-cols-3 gap-4">
<Form.Item name="distance_ly" label="距离 (ly)"><InputNumber style={{ width: '100%' }} placeholder="光年" step={0.01} /></Form.Item>
<Form.Item name="color" label="显示颜色"><Input type="color" /></Form.Item>
</div>
<Row gutter={16}>
{field(8, <Form.Item name="name" label="系统名称" rules={[{ required: true, message: '请输入系统名称' }]}><Input placeholder="例如: Proxima Cen System" /></Form.Item>)}
{field(8, <Form.Item name="name_zh" label="中文名称"><Input placeholder="例如: 比邻星系统" /></Form.Item>)}
{field(8, <Form.Item name="host_star_name" label="主恒星名称" rules={[{ required: true, message: '请输入主恒星名称' }]}><Input placeholder="例如: Proxima Cen" /></Form.Item>)}
</Row>
<Row gutter={16}>
{field(8, <Form.Item name="distance_pc" label="距离 (pc)"><InputNumber style={{ width: '100%' }} placeholder="秒差距" step={0.01} /></Form.Item>)}
{field(8, <Form.Item name="distance_ly" label="距离 (ly)"><InputNumber style={{ width: '100%' }} placeholder="光年" step={0.01} /></Form.Item>)}
{field(8, <Form.Item name="color" label="显示颜色"><Input type="color" /></Form.Item>)}
</Row>
<Row gutter={16}>
{field(8, <Form.Item name="ra" label="赤经 (度)"><InputNumber style={{ width: '100%' }} min={0} max={360} step={0.001} /></Form.Item>)}
{field(8, <Form.Item name="dec" label="赤纬 (度)"><InputNumber style={{ width: '100%' }} min={-90} max={90} step={0.001} /></Form.Item>)}
{field(8, <Form.Item name="spectral_type" label="光谱类型"><Input placeholder="例如: M5.5 V" /></Form.Item>)}
</Row>
<Row gutter={16}>
{field(8, <Form.Item name="position_x" label="X 坐标 (pc)"><InputNumber style={{ width: '100%' }} step={0.01} /></Form.Item>)}
{field(8, <Form.Item name="position_y" label="Y 坐标 (pc)"><InputNumber style={{ width: '100%' }} step={0.01} /></Form.Item>)}
{field(8, <Form.Item name="position_z" label="Z 坐标 (pc)"><InputNumber style={{ width: '100%' }} step={0.01} /></Form.Item>)}
</Row>
<Row gutter={16}>
{field(8, <Form.Item name="radius_solar" label="恒星半径 (R☉)"><InputNumber style={{ width: '100%' }} min={0} step={0.01} /></Form.Item>)}
{field(8, <Form.Item name="mass_solar" label="恒星质量 (M☉)"><InputNumber style={{ width: '100%' }} min={0} step={0.01} /></Form.Item>)}
{field(8, <Form.Item name="temperature_k" label="表面温度 (K)"><InputNumber style={{ width: '100%' }} min={0} step={100} /></Form.Item>)}
</Row>
<Row gutter={16}>
{field(8, <Form.Item name="magnitude" label="视星等"><InputNumber style={{ width: '100%' }} step={0.1} /></Form.Item>)}
{field(8, <Form.Item name="luminosity_solar" label="光度 (L☉)"><InputNumber style={{ width: '100%' }} min={0} step={0.01} /></Form.Item>)}
</Row>
<Form.Item name="description" label="描述"><Input.TextArea rows={3} placeholder="恒星系统简短描述..." /></Form.Item>
</>
);
@ -58,7 +60,16 @@ export function StarSystemModal({ form, record, open, onOk, onCancel }: StarSyst
const [activeTab, setActiveTab] = useState('basic');
return (
<Modal title={record ? '编辑恒星系统' : '创建恒星系统'} open={open} onOk={onOk} onCancel={onCancel} width={1200} okText="保存" cancelText="取消">
<Modal
title={record ? '编辑恒星系统' : '创建恒星系统'}
open={open}
onOk={onOk}
onCancel={onCancel}
width={1000}
okText="保存"
cancelText="取消"
forceRender
>
<Form form={form} layout="vertical">
{record ? (
<Tabs

View File

@ -0,0 +1,17 @@
/**
* page_size
*
* 使
*
*/
import { useSystemSetting } from '../../hooks/useSystemSetting';
const MIN_PAGE_SIZE = 5;
const MAX_PAGE_SIZE = 200;
export function useListPageSize(fallback = 10): number {
const [value] = useSystemSetting<number>('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);
}

View File

@ -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 <project>/scripts; app code and .env live in <project>/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

View File

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

View File

@ -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 <project>/scripts; app code and .env live in <project>/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

244
scripts/run.sh 100755
View File

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

View File

@ -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 <project>/scripts; app code and .env live in <project>/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

View File

@ -3,6 +3,16 @@
set -e # 遇到错误立即退出
# 脚本位于 <project>/scripts后端代码位于 <project>/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