优化了样式
parent
5433892bf6
commit
0d38e1da47
35
.CLAUDE.md
35
.CLAUDE.md
|
|
@ -109,23 +109,24 @@ Cosmo 是一个深空探索可视化平台,使用 Three.js 在浏览器中实
|
|||
- `POST /api/probe-positions` - 优化缓存策略
|
||||
- `GET /health` - 新增 Redis 和数据库健康检查
|
||||
|
||||
#### 2.3 数据迁移脚本
|
||||
创建了完整的初始化和迁移脚本 (`backend/scripts/`):
|
||||
#### 2.3 启动 / 初始化 / 部署脚本
|
||||
脚本统一放在项目根目录 `scripts/`,只保留启动、初始化、部署三类:
|
||||
|
||||
**初始化脚本**:
|
||||
**启动**:
|
||||
- `run.sh` - 一键检查环境并启动前后端开发环境
|
||||
|
||||
**初始化**:
|
||||
- `setup.sh` - 一键初始化(检查环境 + 建库建表 + 初始化管理员)
|
||||
- `create_db.py` - 创建数据库
|
||||
- `init_db.py` - 初始化表结构
|
||||
- `check_config.py` - 验证配置
|
||||
- `seed_admin.py` - 初始化默认管理员、角色与菜单
|
||||
- `init_db.sql` - 完整数据库结构与数据(Docker 首次启动自动导入)
|
||||
|
||||
**数据迁移**:
|
||||
- `migrate_data.py` - 迁移天体基础数据
|
||||
- `update_static_data.py` - 迁移静态数据 (星座、星系)
|
||||
- `populate_resources.py` - 迁移资源文件记录
|
||||
**部署**:
|
||||
- `deploy.sh` - Docker 生产部署
|
||||
|
||||
**辅助脚本**:
|
||||
- `fetch_and_cache.py` - 预取并缓存 NASA 数据
|
||||
- `list_celestial_bodies.py` - 列出所有天体
|
||||
- `add_pluto.py` - 添加冥王星数据示例
|
||||
> 一次性数据填充/迁移脚本(如天体数据、静态数据、资源登记)已清理,
|
||||
> 相关数据已包含在 init_db.sql 中,由应用内定时任务持续更新。
|
||||
|
||||
#### 2.4 依赖管理
|
||||
更新了后端依赖 (`backend/requirements.txt`):
|
||||
|
|
@ -311,13 +312,11 @@ export const getProbePositions = async (probeIds: string[])
|
|||
|
||||
**后端初始化**:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
# 一键初始化(检查环境 + 建库建表):./scripts/setup.sh
|
||||
cd backend && pip install -r requirements.txt
|
||||
cp .env.example .env # 修改配置
|
||||
python scripts/create_db.py
|
||||
python scripts/init_db.py
|
||||
python scripts/migrate_data.py
|
||||
python -m uvicorn app.main:app --reload
|
||||
cd .. && python scripts/create_db.py && python scripts/init_db.py && python scripts/seed_admin.py
|
||||
cd backend && python -m uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**前端初始化**:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
## 🎯 性能优化建议
|
||||
|
||||
|
|
|
|||
225
PROJECT.md
225
PROJECT.md
|
|
@ -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(生产环境)
|
||||
200
QUICKSTART.md
200
QUICKSTART.md
|
|
@ -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
371
README.md
|
|
@ -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 倍的固定比例放大,等整个流程跑通了,再加入动态缩放的逻辑来提升体验。
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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))
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
其余文档按用途归档:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -2,16 +2,180 @@ import { useMemo, useRef } from 'react';
|
|||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import type { RocketConfig, SimulationState } from './types';
|
||||
import { engineState, separationAge, separationTransform, stageGeometry } from './sceneMath';
|
||||
import { engineLayout, engineState, separationAge, separationTransform, stageGeometry } from './sceneMath';
|
||||
|
||||
const DARK = '#20262d';
|
||||
const METAL = '#8b96a1';
|
||||
/*
|
||||
* 运载火箭 3D 模型
|
||||
*
|
||||
* 几何比例、发动机数量与排布、箭体涂装与文字标识全部来自火箭配置
|
||||
* (height_m / diameter_m / length_ratio / engine_count / color / name),
|
||||
* 因此猎鹰 9(9 + 1 台发动机、细长箭体、大直径整流罩)与长征五号
|
||||
* (10 + 2 台发动机、5m 箭体、近乎等径整流罩)在场景里一眼可辨。
|
||||
*/
|
||||
|
||||
const SOOT = '#14171a';
|
||||
const DARK = '#191d22';
|
||||
const METAL = '#9aa4ad';
|
||||
const COMPOSITE = '#e3e7ea';
|
||||
|
||||
/** 由箭体颜色推导深/浅两种涂装色,避免所有火箭长得一样。 */
|
||||
function shade(hex: string, amount: number): string {
|
||||
const color = new THREE.Color(hex);
|
||||
const target = amount >= 0 ? new THREE.Color('#ffffff') : new THREE.Color('#0b0d10');
|
||||
return color.lerp(target, Math.abs(amount)).getStyle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated exhaust plume. The whole plume is anchored at the engine bell (local
|
||||
* y = 0) and grows DOWNWARD; we scale an anchored group (not a center-origin
|
||||
* mesh) so the flame root always stays glued to the nozzle regardless of
|
||||
* throttle/flicker. `nozzleY` is where the engine bell sits in the parent.
|
||||
* 用 canvas 生成箭体文字标识(型号名 / 级次),让模型与火箭资料对应。
|
||||
*/
|
||||
function useDecalTexture(text: string, color: string, background: string) {
|
||||
return useMemo(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold 64px "Helvetica Neue", Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text.slice(0, 16), canvas.width / 2, canvas.height / 2 + 4);
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.anisotropy = 4;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}, [background, color, text]);
|
||||
}
|
||||
|
||||
/** 一圈弧面贴片:用于把文字标识包在箭体表面。 */
|
||||
function BodyDecal({
|
||||
radius,
|
||||
height,
|
||||
y,
|
||||
text,
|
||||
textColor,
|
||||
background,
|
||||
facing = 0,
|
||||
}: {
|
||||
radius: number;
|
||||
height: number;
|
||||
y: number;
|
||||
text: string;
|
||||
textColor: string;
|
||||
background: string;
|
||||
facing?: number;
|
||||
}) {
|
||||
const texture = useDecalTexture(text, textColor, background);
|
||||
if (!texture) return null;
|
||||
return (
|
||||
<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
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 / payload)驱动,CSS 负责分离动画。
|
||||
*/
|
||||
function StackDiagram({ rocket }: { rocket: RocketConfig }) {
|
||||
const geo = useMemo(() => stageGeometry(rocket), [rocket]);
|
||||
const TOP = 16;
|
||||
const BOTTOM = 212;
|
||||
const CX = 58;
|
||||
const scale = (BOTTOM - TOP) / geo.totalHeight;
|
||||
|
||||
const r1 = Math.max(8, geo.radius * scale);
|
||||
const r2 = r1 * 0.97;
|
||||
const rf = Math.max(r1 + 1.5, geo.fairingRadius * scale);
|
||||
const fairingH = geo.fairingHeight * scale;
|
||||
const coneH = geo.fairingConeHeight * scale;
|
||||
const s2H = geo.stage2Height * scale;
|
||||
const interH = Math.max(geo.interstageHeight * scale, 4);
|
||||
const s1H = geo.stage1Height * scale;
|
||||
|
||||
const fairingBottom = TOP + fairingH;
|
||||
const coneTop = TOP;
|
||||
const barrelTop = TOP + coneH;
|
||||
const s2Top = fairingBottom;
|
||||
const interTop = s2Top + s2H;
|
||||
const s1Top = interTop + interH;
|
||||
const engineY = s1Top + s1H;
|
||||
const padY = engineY + 14;
|
||||
const metal = '#aeb7bd';
|
||||
|
||||
/** 侧视图中的发动机:把发动机舱布局投影到水平方向,按位置去重后绘制。 */
|
||||
const engineBells = (count: number, radius: number, baseY: number) => {
|
||||
const drawn: Array<{ x: number; width: number }> = [];
|
||||
engineLayout(count, radius).forEach((placement) => {
|
||||
const x = CX + placement.x * scale;
|
||||
if (drawn.some((item) => Math.abs(item.x - x) < placement.bellRadius * scale * 0.9)) return;
|
||||
drawn.push({ x, width: Math.max(2.6, placement.bellRadius * 2 * scale) });
|
||||
});
|
||||
return drawn.map((bell, index) => (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
const toast = useToast();
|
||||
|
||||
// Auto-refresh logic
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue