main
mula.liu 2025-11-30 23:04:04 +08:00
parent e796eceaf8
commit 7037628831
11 changed files with 452 additions and 152 deletions

View File

@ -96,7 +96,7 @@ function App() {
// Filter probes and planets from all bodies
const probes = bodies.filter((b) => b.type === 'probe');
const planets = bodies.filter((b) =>
b.type === 'planet' || b.type === 'dwarf_planet' || b.type === 'satellite'
b.type === 'planet' || b.type === 'dwarf_planet' || b.type === 'satellite' || b.type === 'comet'
);
const handleBodySelect = (body: CelestialBody | null) => {
@ -138,7 +138,7 @@ function App() {
<div className="w-full h-full relative">
{/* Header with simplified branding and User Auth */}
<Header
bodyCount={bodies.length}
bodyCount={bodies.filter(b => b.is_active !== false).length}
selectedBodyName={selectedBody?.name}
user={user}
onOpenAuth={() => setShowAuthModal(true)}

View File

@ -8,10 +8,12 @@ import { useTexture, Html } from '@react-three/drei';
import type { CelestialBody as CelestialBodyType } from '../types';
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
import { fetchBodyResources } from '../utils/api';
import { PLANET_SIZES, SATELLITE_SIZES, getCelestialSize } from '../config/celestialSizes';
interface CelestialBodyProps {
body: CelestialBodyType;
allBodies: CelestialBodyType[];
isSelected?: boolean;
}
// Saturn Rings component - multiple rings for band effect
@ -73,12 +75,13 @@ function SaturnRings() {
}
// Planet component with texture
function Planet({ body, size, emissive, emissiveIntensity, allBodies }: {
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
allBodies: CelestialBodyType[];
isSelected?: boolean;
}) {
const meshRef = useRef<Mesh>(null);
const position = body.positions[0];
@ -132,11 +135,12 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies }: {
meshRef={meshRef}
hasOffset={renderPosition.hasOffset}
allBodies={allBodies}
isSelected={isSelected}
/>;
}
// Separate component to handle texture loading
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies }: {
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false }: {
body: CelestialBodyType;
size: number;
emissive: string;
@ -147,6 +151,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
meshRef: React.RefObject<Mesh>;
hasOffset: boolean;
allBodies: CelestialBodyType[];
isSelected?: boolean;
}) {
// Load texture if path is provided
const texture = texturePath ? useTexture(texturePath) : null;
@ -212,25 +217,27 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
distanceFactor={10}
style={{
color: body.type === 'star' ? '#FDB813' : '#ffffff',
fontSize: '11px',
fontSize: '9px', // 从 11px 减小到 9px
fontWeight: 'bold',
textShadow: '0 0 4px rgba(0,0,0,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
transition: 'opacity 0.3s ease',
}}
>
{body.name_zh || body.name}
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '9px', color: '#ffaa00', opacity: 0.9 }}>
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 9px 减小到 7px */}
{offsetDesc}
</span>
</>
)}
<br />
<span style={{ fontSize: '8px', opacity: 0.7 }}>
<span style={{ fontSize: '7px', opacity: isSelected ? 0.7 : 0.3 }}> {/* 从 8px 减小到 7px */}
{distance.toFixed(2)} AU
</span>
</Html>
@ -238,7 +245,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
);
}
export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
export function CelestialBody({ body, allBodies, isSelected = false }: CelestialBodyProps) {
// Get the current position (use the first position for now)
const position = body.positions[0];
if (!position) return null;
@ -252,7 +259,7 @@ export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
const appearance = useMemo(() => {
if (body.type === 'star') {
return {
size: 0.4, // Revert to original size
size: 0.4, // Sun size
emissive: '#FDB813',
emissiveIntensity: 1.5,
};
@ -260,32 +267,16 @@ export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
// Satellite (natural moons) - small size with slight glow for visibility
if (body.type === 'satellite') {
const satelliteSizes: Record<string, number> = {
Moon: 0.15, // Small but visible
// Add other satellites here as needed
};
return {
size: satelliteSizes[body.name] || 0.12,
size: getCelestialSize(body.name, body.type),
emissive: '#888888', // Slight glow to make it visible
emissiveIntensity: 0.4,
};
}
// Planet sizes - balanced for visibility with smaller probes
const planetSizes: Record<string, number> = {
Mercury: 0.35, // Slightly larger for visibility
Venus: 0.55, // Slightly larger for visibility
Earth: 0.6, // Slightly larger for visibility
Mars: 0.45, // Slightly larger for visibility
Jupiter: 1.4, // Larger gas giant
Saturn: 1.2, // Larger gas giant
Uranus: 0.8, // Medium outer planet
Neptune: 0.8, // Medium outer planet
Pluto: 0.2, // Dwarf planet, smaller than Moon
};
// Planet and dwarf planet sizes
return {
size: planetSizes[body.name] || 0.5,
size: getCelestialSize(body.name, body.type),
emissive: '#000000',
emissiveIntensity: 0,
};
@ -298,6 +289,7 @@ export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
emissive={appearance.emissive}
emissiveIntensity={appearance.emissiveIntensity}
allBodies={allBodies}
isSelected={isSelected}
/>
);
}

View File

@ -94,7 +94,7 @@ export function Constellations() {
{constellation.starPositions.map((pos, idx) => (
<mesh key={`${constellation.name}-star-${idx}`} position={pos}>
<sphereGeometry args={[15, 8, 8]} />
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.9} />
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.6} />
</mesh>
))}
@ -104,9 +104,9 @@ export function Constellations() {
key={`${constellation.name}-line-${idx}`}
points={[segment.start, segment.end]}
color="#4488FF"
lineWidth={1.5}
lineWidth={1}
transparent
opacity={0.5}
opacity={0.3}
/>
))}
@ -115,7 +115,7 @@ export function Constellations() {
<Text
fontSize={120}
color="#6699FF"
fillOpacity={0.8}
fillOpacity={0.6}
anchorX="center"
anchorY="middle"
outlineWidth={0}

View File

@ -13,14 +13,17 @@ import { fetchBodyResources } from '../utils/api';
interface ProbeProps {
body: CelestialBody;
allBodies: CelestialBody[];
isSelected?: boolean;
}
// Separate component for each probe type to properly use hooks
function ProbeModel({ body, modelPath, allBodies, onError }: {
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0 }: {
body: CelestialBody;
modelPath: string;
allBodies: CelestialBody[];
isSelected?: boolean;
onError: () => void;
resourceScale?: number;
}) {
const groupRef = useRef<Group>(null);
const position = body.positions[0];
@ -52,16 +55,17 @@ function ProbeModel({ body, modelPath, allBodies, onError }: {
const maxDimension = Math.max(size.x, size.y, size.z);
// Target size for display (consistent visual size)
const targetSize = 0.5; // Target visual size in scene units
const targetSize = 0.35; // Balanced size for all probes
// Calculate scale factor
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.2;
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.3;
// Clamp scale to reasonable range
const finalScale = Math.max(0.1, Math.min(2.0, calculatedScale));
// Clamp scale to reasonable range - tighter range for consistency
const finalScale = Math.max(0.2, Math.min(1.0, calculatedScale));
return finalScale;
}, [scene, body.name]);
// Apply custom scale from resource metadata
return finalScale * resourceScale;
}, [scene, body.name, resourceScale]);
// 4. Hook: Configured Scene
const configuredScene = useMemo(() => {
@ -128,25 +132,27 @@ function ProbeModel({ body, modelPath, allBodies, onError }: {
distanceFactor={15}
style={{
color: '#00ffff',
fontSize: '12px',
fontSize: '9px', // 从 12px 减小到 9px
fontWeight: 'bold',
textShadow: '0 0 6px rgba(0,255,255,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
transition: 'opacity 0.3s ease',
}}
>
🛰 {body.name_zh || body.name}
{body.name_zh || body.name}
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '10px', color: '#ffaa00', opacity: 0.9 }}>
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 10px 减小到 7px */}
{offsetDesc}
</span>
</>
)}
<br />
<span style={{ fontSize: '10px', opacity: 0.8 }}>
<span style={{ fontSize: '7px', opacity: isSelected ? 0.8 : 0.3 }}> {/* 从 10px 减小到 7px */}
{distance.toFixed(2)} AU
</span>
</Html>
@ -155,7 +161,7 @@ function ProbeModel({ body, modelPath, allBodies, onError }: {
}
// Fallback component when model is not available
function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: CelestialBody[] }) {
function ProbeFallback({ body, allBodies, isSelected = false }: { body: CelestialBody; allBodies: CelestialBody[]; isSelected?: boolean }) {
const position = body.positions[0];
// Use smart render position calculation
@ -185,25 +191,27 @@ function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: Ce
distanceFactor={15}
style={{
color: '#ff6666',
fontSize: '12px',
fontSize: '9px', // 从 12px 减小到 9px
fontWeight: 'bold',
textShadow: '0 0 6px rgba(255,0,0,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
transition: 'opacity 0.3s ease',
}}
>
🛰 {body.name_zh || body.name}
{body.name_zh || body.name}
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '10px', color: '#ffaa00', opacity: 0.9 }}>
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 10px 减小到 7px */}
{offsetDesc}
</span>
</>
)}
<br />
<span style={{ fontSize: '10px', opacity: 0.8 }}>
<span style={{ fontSize: '7px', opacity: isSelected ? 0.8 : 0.3 }}> {/* 从 10px 减小到 7px */}
{distance.toFixed(2)} AU
</span>
</Html>
@ -211,10 +219,11 @@ function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: Ce
);
}
export function Probe({ body, allBodies }: ProbeProps) {
export function Probe({ body, allBodies, isSelected = false }: ProbeProps) {
const position = body.positions[0];
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
const [loadError, setLoadError] = useState<boolean>(false);
const [resourceScale, setResourceScale] = useState<number>(1.0);
// Fetch model from backend API
useEffect(() => {
@ -227,6 +236,12 @@ export function Probe({ body, allBodies }: ProbeProps) {
if (response.resources.length > 0) {
// Get the first model resource
const modelResource = response.resources[0];
// Get scale from extra_data
const scale = modelResource.extra_data?.scale || 1.0;
setResourceScale(scale);
console.log(`[Probe ${body.name}] Scale from resource:`, scale);
// Construct full URL from file_path
const protocol = window.location.protocol;
const hostname = window.location.hostname;
@ -265,11 +280,11 @@ export function Probe({ body, allBodies }: ProbeProps) {
// Use model if available and no load error, otherwise use fallback
if (modelPath && !loadError) {
return <ProbeModel body={body} modelPath={modelPath} allBodies={allBodies} onError={() => {
return <ProbeModel body={body} modelPath={modelPath} allBodies={allBodies} isSelected={isSelected} resourceScale={resourceScale} onError={() => {
console.error(`[Probe ${body.name}] ProbeModel rendering failed, switching to fallback`);
setLoadError(true);
}} />;
}
return <ProbeFallback body={body} allBodies={allBodies} />;
return <ProbeFallback body={body} allBodies={allBodies} isSelected={isSelected} />;
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Search, Globe, Rocket, Moon, Asterisk } from 'lucide-react';
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Search, Globe, Rocket, Moon, Asterisk, Sparkles } from 'lucide-react';
import type { CelestialBody } from '../types';
interface ProbeListProps {
@ -11,7 +11,7 @@ interface ProbeListProps {
}
export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true); // 默认关闭
const [searchTerm, setSearchTerm] = useState('');
const [expandedGroup, setExpandedGroup] = useState<string | null>(null); // 只允许一个分组展开
@ -42,6 +42,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
const dwarfPlanetList = processedBodies.filter(({ body }) => body.type === 'dwarf_planet');
const satelliteList = processedBodies.filter(({ body }) => body.type === 'satellite');
const probeList = processedBodies.filter(({ body }) => body.type === 'probe');
const cometList = processedBodies.filter(({ body }) => body.type === 'comet');
const toggleGroup = (groupName: string) => {
// 如果点击的是当前展开的分组,则收起;否则切换到新分组
@ -147,6 +148,20 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
/>
)}
{/* Comets Group */}
{cometList.length > 0 && (
<BodyGroup
title="彗星"
icon={<Sparkles size={12} />}
count={cometList.length}
bodies={cometList}
isExpanded={expandedGroup === 'comet'}
onToggle={() => toggleGroup('comet')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
/>
)}
{/* No results message */}
{processedBodies.length === 0 && (
<div className="text-center py-8 text-gray-500 text-xs">

View File

@ -3,7 +3,7 @@
*/
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stars as BackgroundStars, Html } from '@react-three/drei';
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { CelestialBody } from './CelestialBody';
import { Probe } from './Probe';
import { CameraController } from './CameraController';
@ -29,6 +29,16 @@ interface SceneProps {
}
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0 }: SceneProps) {
// State to control info panel visibility (independent of selection)
const [showInfoPanel, setShowInfoPanel] = useState(true);
// Reset info panel visibility when selected body changes
useEffect(() => {
if (selectedBody) {
setShowInfoPanel(true);
}
}, [selectedBody]);
// Separate natural celestial bodies (planets/dwarf planets/satellites/stars) from probes
const celestialBodies = bodies.filter((b) => b.type !== 'probe');
const probes = bodies.filter((b) => b.type === 'probe');
@ -116,7 +126,12 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
{/* Render all celestial bodies: planets, dwarf planets, satellites, and stars */}
{celestialBodies.map((body) => (
<CelestialBody key={body.id} body={body} allBodies={bodies} />
<CelestialBody
key={body.id}
body={body}
allBodies={bodies}
isSelected={selectedBody?.id === body.id}
/>
))}
{/* Unified orbit renderer for all celestial bodies (planets and dwarf planets) */}
@ -124,7 +139,12 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
{/* Render visible probes with 3D models */}
{visibleProbes.map((body) => (
<Probe key={body.id} body={body} allBodies={bodies} />
<Probe
key={body.id}
body={body}
allBodies={bodies}
isSelected={selectedBody?.id === body.id}
/>
))}
{/* Render trajectory for selected probe */}
@ -148,9 +168,9 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
/>
{/* Dynamic Focus Info Label */}
{selectedBody && onBodySelect && (
{selectedBody && showInfoPanel && (
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
<FocusInfo body={selectedBody} onClose={() => onBodySelect(null)} />
<FocusInfo body={selectedBody} onClose={() => setShowInfoPanel(false)} />
</Html>
)}
</Canvas>

View File

@ -0,0 +1,69 @@
/**
* Celestial body rendering sizes configuration
* Shared across components for consistent sizing
*/
/**
* Planet rendering sizes (radius in scene units)
*/
export const PLANET_SIZES: Record<string, number> = {
Mercury: 0.35,
Venus: 0.55,
Earth: 0.6,
Mars: 0.45,
Jupiter: 1.4,
Saturn: 1.2,
Uranus: 0.8,
Neptune: 0.8,
Pluto: 0.2,
// Default for unknown planets
default: 0.5,
};
/**
* Satellite rendering sizes (radius in scene units)
*/
export const SATELLITE_SIZES: Record<string, number> = {
Moon: 0.15,
// Default for unknown satellites
default: 0.12,
};
/**
* Star rendering sizes (radius in scene units)
*/
export const STAR_SIZES: Record<string, number> = {
Sun: 0.4,
// Default for unknown stars
default: 0.4,
};
/**
* Comet rendering sizes (radius in scene units)
* Comets are typically small with a bright nucleus
*/
export const COMET_SIZES: Record<string, number> = {
// Famous comets
Halley: 0.15,
// Default for unknown comets
default: 0.12,
};
/**
* Get the rendering size for a celestial body by name and type
*/
export function getCelestialSize(name: string, type: string): number {
switch (type) {
case 'planet':
case 'dwarf_planet':
return PLANET_SIZES[name] || PLANET_SIZES.default;
case 'satellite':
return SATELLITE_SIZES[name] || SATELLITE_SIZES.default;
case 'star':
return STAR_SIZES[name] || STAR_SIZES.default;
case 'comet':
return COMET_SIZES[name] || COMET_SIZES.default;
default:
return 0.5;
}
}

View File

@ -2,9 +2,10 @@
* Celestial Bodies Management Page
*/
import { useState, useEffect } from 'react';
import { message, Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined } from '@ant-design/icons';
import { message, Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { UploadFile } from 'antd/es/upload/interface';
import { DataTable } from '../../components/admin/DataTable';
import { request } from '../../utils/request';
@ -35,6 +36,8 @@ export function CelestialBodies() {
const [form] = Form.useForm();
const [searching, setSearching] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [uploading, setUploading] = useState(false);
const [refreshResources, setRefreshResources] = useState(0);
useEffect(() => {
loadData();
@ -166,7 +169,7 @@ export function CelestialBodies() {
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingRecord) {
// Update
await request.put(`/celestial/${editingRecord.id}`, values);
@ -176,7 +179,7 @@ export function CelestialBodies() {
await request.post('/celestial/', values);
message.success('创建成功');
}
setIsModalOpen(false);
loadData();
} catch (error) {
@ -185,6 +188,50 @@ export function CelestialBodies() {
}
};
// Handle resource upload
const handleResourceUpload = async (file: File, resourceType: string) => {
if (!editingRecord) {
message.error('请先选择要编辑的天体');
return false;
}
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const response = await request.post(
`/celestial/resources/upload?body_id=${editingRecord.id}&resource_type=${resourceType}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
message.success(`${response.data.message} (上传到 ${response.data.upload_directory} 目录)`);
setRefreshResources(prev => prev + 1); // Trigger reload
return false; // Prevent default upload behavior
} catch (error: any) {
message.error(error.response?.data?.detail || '上传失败');
return false;
} finally {
setUploading(false);
}
};
// Handle resource delete
const handleResourceDelete = async (resourceId: number) => {
try {
await request.delete(`/celestial/resources/${resourceId}`);
message.success('删除成功');
setRefreshResources(prev => prev + 1); // Trigger reload
} catch (error: any) {
message.error(error.response?.data?.detail || '删除失败');
}
};
const columns: ColumnsType<CelestialBody> = [
{
title: 'ID',
@ -214,6 +261,7 @@ export function CelestialBodies() {
{ text: '卫星', value: 'satellite' },
{ text: '探测器', value: 'probe' },
{ text: '矮行星', value: 'dwarf_planet' },
{ text: '彗星', value: 'comet' },
],
onFilter: (value, record) => record.type === value,
render: (type: string) => {
@ -223,6 +271,7 @@ export function CelestialBodies() {
dwarf_planet: '矮行星',
satellite: '卫星',
probe: '探测器',
comet: '彗星',
};
return typeMap[type] || type;
},
@ -319,90 +368,204 @@ export function CelestialBodies() {
</>
)}
<Form.Item
name="id"
label="JPL Horizons ID"
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
>
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="id"
label="JPL Horizons ID"
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
>
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="type"
label="类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select>
<Select.Option value="planet"></Select.Option>
<Select.Option value="dwarf_planet"></Select.Option>
<Select.Option value="satellite"></Select.Option>
<Select.Option value="probe"></Select.Option>
<Select.Option value="star"></Select.Option>
<Select.Option value="comet"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="name"
label="英文名"
rules={[{ required: true, message: '请输入英文名' }]}
>
<Input placeholder="例如Voyager 1" />
</Form.Item>
<Form.Item
name="name_zh"
label="中文名"
>
<Input placeholder="例如旅行者1号" />
</Form.Item>
<Form.Item
name="type"
label="类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select>
<Select.Option value="planet"></Select.Option>
<Select.Option value="dwarf_planet"></Select.Option>
<Select.Option value="satellite"></Select.Option>
<Select.Option value="probe"></Select.Option>
<Select.Option value="star"></Select.Option>
</Select>
</Form.Item>
<Form.Item
name="is_active"
label="状态"
valuePropName="checked"
>
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="英文名"
rules={[{ required: true, message: '请输入英文名' }]}
>
<Input placeholder="例如Voyager 1" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="name_zh"
label="中文名"
>
<Input placeholder="例如旅行者1号" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={4} />
<Input.TextArea rows={2} />
</Form.Item>
{editingRecord && editingRecord.has_resources && (
<Form.Item label="已配置资源">
<Descriptions bordered size="small" column={1}>
{Object.entries(editingRecord.resources || {}).map(([type, resources]) => (
<Descriptions.Item
key={type}
label={
type === 'texture' ? '纹理' :
type === 'model' ? '模型' :
type === 'icon' ? '图标' :
type === 'thumbnail' ? '缩略图' :
type === 'data' ? '数据' : type
}
>
{resources.map((res: any, idx: number) => (
<div key={res.id} style={{ marginBottom: idx < resources.length - 1 ? 8 : 0 }}>
<Tag color="blue">{res.file_path}</Tag>
<span style={{ fontSize: 12, color: '#888' }}>
({(res.file_size / 1024).toFixed(2)} KB)
</span>
</div>
))}
</Descriptions.Item>
))}
</Descriptions>
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
,
</div>
</Form.Item>
{editingRecord && (
<ResourceManager
bodyId={editingRecord.id}
bodyType={editingRecord.type}
resources={editingRecord.resources}
hasResources={editingRecord.has_resources}
onUpload={handleResourceUpload}
onDelete={handleResourceDelete}
uploading={uploading}
refreshTrigger={refreshResources}
/>
)}
</Form>
</Modal>
</>
);
}
// Resource Manager Component
function ResourceManager({
bodyId,
bodyType,
resources,
hasResources,
onUpload,
onDelete,
uploading,
refreshTrigger,
}: {
bodyId: string;
bodyType: string;
resources?: any;
hasResources?: boolean;
onUpload: (file: File, resourceType: string) => Promise<boolean>;
onDelete: (resourceId: number) => Promise<void>;
uploading: boolean;
refreshTrigger: number;
}) {
const [currentResources, setCurrentResources] = useState(resources);
// Load resources when modal opens
useEffect(() => {
request.get(`/celestial/resources/${bodyId}`)
.then(({ data }) => {
const grouped: any = {};
data.resources.forEach((res: any) => {
if (!grouped[res.resource_type]) {
grouped[res.resource_type] = [];
}
grouped[res.resource_type].push(res);
});
setCurrentResources(grouped);
})
.catch(() => {
message.error('加载资源列表失败');
});
}, [refreshTrigger, bodyId]);
const resourceTypes = [
{ key: 'texture', label: bodyType === 'probe' ? '纹理 (上传到 model 目录)' : '纹理 (上传到 texture 目录)' },
{ key: 'model', label: bodyType === 'probe' ? '模型 (上传到 model 目录)' : '模型 (上传到 texture 目录)' },
];
return (
<Form.Item label="资源配置">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{resourceTypes.map(({ key, label }) => (
<div key={key}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{label}</div>
<Upload
beforeUpload={(file) => onUpload(file, key)}
showUploadList={false}
disabled={uploading}
>
<Button icon={<UploadOutlined />} loading={uploading} size="small">
{label.split(' ')[0]}
</Button>
</Upload>
{currentResources?.[key] && currentResources[key].length > 0 && (
<div style={{ marginTop: 8 }}>
{currentResources[key].map((res: any) => (
<div key={res.id} style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Tag color="blue">{res.file_path}</Tag>
<span style={{ fontSize: 12, color: '#888' }}>
({(res.file_size / 1024).toFixed(2)} KB)
</span>
<Popconfirm
title="确认删除?"
onConfirm={() => onDelete(res.id)}
okText="删除"
cancelText="取消"
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
</div>
{key === 'model' && (
<div style={{ marginLeft: 8 }}>
<Space size="small">
<span style={{ fontSize: 12, color: '#666' }}>:</span>
<InputNumber
size="small"
min={0.1}
max={5}
step={0.1}
defaultValue={res.extra_data?.scale || 1.0}
style={{ width: 80 }}
placeholder="1.0"
onChange={(value) => {
// Update scale in resource
const newScale = value || 1.0;
request.put(`/celestial/resources/${res.id}`, {
extra_data: { ...res.extra_data, scale: newScale }
}).then(() => {
message.success('缩放参数已更新');
}).catch(() => {
message.error('更新失败');
});
}}
/>
<span style={{ fontSize: 11, color: '#999' }}>
(: Webb=0.3, =1.5)
</span>
</Space>
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</Space>
</Form.Item>
);
}

View File

@ -2,7 +2,7 @@
* TypeScript type definitions for Cosmo application
*/
export type CelestialBodyType = 'planet' | 'probe' | 'star' | 'dwarf_planet' | 'satellite';
export type CelestialBodyType = 'planet' | 'probe' | 'star' | 'dwarf_planet' | 'satellite' | 'comet';
export interface Position {
time: string;

View File

@ -138,6 +138,7 @@ export async function fetchBodyResources(bodyId: string, resourceType?: string):
file_size: number;
mime_type: string;
created_at: string;
extra_data?: Record<string, any>;
}>;
}> {
const params: Record<string, string> = {};

View File

@ -4,11 +4,12 @@
*/
import { scalePosition } from './scaleDistance';
import { getCelestialSize } from '../config/celestialSizes';
import type { CelestialBody } from '../types';
/**
* Calculate rendering position using true scaled coordinates
* For satellites, add a radial offset relative to PARENT PLANET
* For satellites and probes near planets, add a radial offset relative to PARENT PLANET
*/
export function calculateRenderPosition(
body: CelestialBody,
@ -22,33 +23,33 @@ export function calculateRenderPosition(
// 1. Calculate base scaled position (fallback)
const scaled = scalePosition(pos.x, pos.y, pos.z);
// 2. Special handling for satellites (like Moon)
if (body.type === 'satellite') {
// 2. Special handling for satellites (like Moon) and probes near planets
if (body.type === 'satellite' || body.type === 'probe') {
const parent = findParentPlanet(body, allBodies);
if (parent) {
const parentPos = parent.positions[0];
// Calculate parent's scaled position (where it is rendered)
// Note: Recursion is possible if parent is also a satellite, but unlikely for planets
const parentScaled = scalePosition(parentPos.x, parentPos.y, parentPos.z);
// Calculate vector from Parent to Satellite (in True AU coordinates)
// Calculate vector from Parent to body (in True AU coordinates)
const dx = pos.x - parentPos.x;
const dy = pos.y - parentPos.y;
const dz = pos.z - parentPos.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist > 0) {
// Normalize the direction vector
const nx = dx / dist;
const ny = dy / dist;
const nz = dz / dist;
// Set a fixed visual offset distance
// Earth radius is 0.6, Moon is 0.15.
// A distance of 1.5 units ensures they are close but distinct and don't overlap.
// This simulates a "mini-system" view at the planet's location.
const visualOffset = 1.5;
// Calculate dynamic offset based on parent planet's rendering size
// Formula: planetRadius × 1.5 + 0.3 (fixed gap)
// This ensures larger planets (Jupiter, Saturn) have larger offsets
// while smaller planets (Earth, Mars) have smaller offsets
const parentSize = getCelestialSize(parent.name, parent.type);
const visualOffset = parentSize * 1.5 + 0.3;
return {
x: parentScaled.x + nx * visualOffset,
@ -106,7 +107,31 @@ export function findParentPlanet(
* Get description for bodies that are close to planets
*/
export function getOffsetDescription(body: CelestialBody, allBodies: CelestialBody[]): string | null {
// This function is kept for compatibility but no longer calculates offsets
// Could be used to show proximity information in the future
return null;
if (body.type !== 'satellite' && body.type !== 'probe') {
return null;
}
const parent = findParentPlanet(body, allBodies);
if (!parent) {
return null;
}
const pos = body.positions[0];
const parentPos = parent.positions[0];
if (!pos || !parentPos) {
return null;
}
// Calculate actual distance
const dx = pos.x - parentPos.x;
const dy = pos.y - parentPos.y;
const dz = pos.z - parentPos.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
// Return proximity description
const distanceStr = dist < 0.01
? `${(dist * 149597870.7).toFixed(0)} km` // Convert AU to km for very close objects
: `${dist.toFixed(4)} AU`;
return `${parent.name_zh || parent.name} (${distanceStr})`;
}