初步实现了三阶段

main
mula.liu 2025-12-06 17:06:39 +08:00
parent f9f804cb5f
commit 3cdaaa2943
13 changed files with 1497 additions and 167 deletions

View File

@ -5,8 +5,10 @@ import { useHistoricalData } from './hooks/useHistoricalData';
import { useTrajectory } from './hooks/useTrajectory';
import { useScreenshot } from './hooks/useScreenshot';
import { useDataCutoffDate } from './hooks/useDataCutoffDate';
import { useSystemSetting } from './hooks/useSystemSetting';
import { Header } from './components/Header';
import { Scene } from './components/Scene';
import { GalaxyScene } from './components/GalaxyScene';
import { ProbeList } from './components/ProbeList';
import { TimelineController } from './components/TimelineController';
import { Loading } from './components/Loading';
@ -14,7 +16,7 @@ import { InterstellarTicker } from './components/InterstellarTicker';
import { ControlPanel } from './components/ControlPanel';
import { AuthModal } from './components/AuthModal';
import { MessageBoard } from './components/MessageBoard';
import { BodyDetailOverlay } from './components/BodyDetailOverlay'; // Import the new overlay component
import { BodyDetailOverlay } from './components/BodyDetailOverlay';
import { auth } from './utils/auth';
import type { CelestialBody } from './types';
import { useToast } from './contexts/ToastContext';
@ -33,30 +35,46 @@ function App() {
const [showOrbits, setShowOrbits] = useState(true);
const [isSoundOn, setIsSoundOn] = useState(false);
const [showMessageBoard, setShowMessageBoard] = useState(false);
const [showDetailOverlayId, setShowDetailOverlayId] = useState<string | null>(null); // State for detail overlay
const [showDetailOverlayId, setShowDetailOverlayId] = useState<string | null>(null);
// Initialize state from localStorage
// Fetch view_mode from system settings (with fallback to 'solar')
const [systemViewMode, systemViewModeLoading] = useSystemSetting<'solar' | 'galaxy'>('view_mode', 'solar');
const [viewMode, setViewMode] = useState<'solar' | 'galaxy'>('solar');
// Initialize state from system settings first, then localStorage
useEffect(() => {
const savedPrefs = localStorage.getItem(PREFS_KEY);
if (savedPrefs) {
try {
const prefs = JSON.parse(savedPrefs);
if (prefs.showOrbits !== undefined) setShowOrbits(prefs.showOrbits);
if (prefs.isSoundOn !== undefined) setIsSoundOn(prefs.isSoundOn);
} catch (e) {
console.error('Failed to parse preferences:', e);
if (!systemViewModeLoading) {
// Priority: system settings > localStorage
const savedPrefs = localStorage.getItem(PREFS_KEY);
let localViewMode: 'solar' | 'galaxy' | undefined;
if (savedPrefs) {
try {
const prefs = JSON.parse(savedPrefs);
if (prefs.showOrbits !== undefined) setShowOrbits(prefs.showOrbits);
if (prefs.isSoundOn !== undefined) setIsSoundOn(prefs.isSoundOn);
localViewMode = prefs.viewMode;
} catch (e) {
console.error('Failed to parse preferences:', e);
}
}
// Use system setting if available, otherwise use localStorage, finally fallback to 'solar'
const initialViewMode = systemViewMode || localViewMode || 'solar';
setViewMode(initialViewMode);
console.log('[App] Initial viewMode:', initialViewMode, '(system:', systemViewMode, ', local:', localViewMode, ')');
}
}, []);
}, [systemViewMode, systemViewModeLoading]);
// Persist preferences
useEffect(() => {
const prefs = {
showOrbits,
isSoundOn,
viewMode,
};
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
}, [showOrbits, isSoundOn]);
}, [showOrbits, isSoundOn, viewMode]);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const { takeScreenshot } = useScreenshot();
@ -150,11 +168,11 @@ function App() {
// Only show full screen loading when we have no data
// This prevents flashing when timeline is playing and fetching new data
if (loading && bodies.length === 0) {
if (loading && bodies.length === 0 && viewMode === 'solar') {
return <Loading />;
}
if (error) {
if (error && viewMode === 'solar') {
return (
<div className="w-full h-full flex items-center justify-center bg-black text-white">
<div className="text-center">
@ -200,6 +218,11 @@ function App() {
}
}}
onScreenshot={handleScreenshot}
viewMode={viewMode}
onToggleViewMode={() => {
setViewMode(prev => prev === 'solar' ? 'galaxy' : 'solar');
setSelectedBody(null); // Reset selection when switching
}}
/>
{/* Auth Modal */}
@ -215,54 +238,61 @@ function App() {
onClose={() => setShowMessageBoard(false)}
/>
{/* Probe List Sidebar */}
<ProbeList
probes={probes}
planets={planets}
onBodySelect={handleBodySelect}
selectedBody={selectedBody}
onResetCamera={() => setResetTrigger(prev => prev + 1)}
/>
{/* View Mode Rendering */}
{viewMode === 'solar' ? (
<>
{/* Probe List Sidebar */}
<ProbeList
probes={probes}
planets={planets}
onBodySelect={handleBodySelect}
selectedBody={selectedBody}
onResetCamera={() => setResetTrigger(prev => prev + 1)}
/>
{/* 3D Scene */}
<Scene
bodies={bodies}
selectedBody={selectedBody}
trajectoryPositions={trajectoryPositions}
showOrbits={showOrbits}
onBodySelect={handleBodySelect}
resetTrigger={resetTrigger}
toast={toast}
onViewDetails={handleViewDetails}
/>
{/* 3D Scene */}
<Scene
bodies={bodies}
selectedBody={selectedBody}
trajectoryPositions={trajectoryPositions}
showOrbits={showOrbits}
onBodySelect={handleBodySelect}
resetTrigger={resetTrigger}
toast={toast}
onViewDetails={handleViewDetails}
/>
{/* Timeline Controller */}
{isTimelineMode && cutoffDate && (
<TimelineController
onTimeChange={handleTimeChange}
maxDate={cutoffDate} // Use cutoff date instead of new Date()
minDate={new Date(cutoffDate.getTime() - TIMELINE_DAYS * 24 * 60 * 60 * 1000)} // End point (past)
/>
{/* Timeline Controller */}
{isTimelineMode && cutoffDate && (
<TimelineController
onTimeChange={handleTimeChange}
maxDate={cutoffDate} // Use cutoff date instead of new Date()
minDate={new Date(cutoffDate.getTime() - TIMELINE_DAYS * 24 * 60 * 60 * 1000)} // End point (past)
/>
)}
{/* Instructions overlay (Only show when exploring freely) */}
{!selectedBody && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 text-white text-xs bg-black/50 backdrop-blur-sm border border-white/10 px-4 py-2 rounded-full flex items-center gap-4 pointer-events-none">
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
</div>
)}
</>
) : (
<GalaxyScene />
)}
{/* Interstellar Ticker Sound (Controlled) */}
<InterstellarTicker isPlaying={isSoundOn} />
{/* Instructions overlay (Only show when exploring freely) */}
{!selectedBody && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 text-white text-xs bg-black/50 backdrop-blur-sm border border-white/10 px-4 py-2 rounded-full flex items-center gap-4 pointer-events-none">
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
</div>
)}
{/* Body Detail Overlay */}
<BodyDetailOverlay
bodyId={showDetailOverlayId}

View File

@ -6,6 +6,7 @@ import { Login } from './pages/Login';
import { AdminLayout } from './pages/admin/AdminLayout';
import { Dashboard } from './pages/admin/Dashboard';
import { CelestialBodies } from './pages/admin/CelestialBodies';
import { StarSystems } from './pages/admin/StarSystems';
import { StaticData } from './pages/admin/StaticData';
import { Users } from './pages/admin/Users';
import { NASADownload } from './pages/admin/NASADownload';
@ -46,6 +47,7 @@ export function Router() {
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="celestial-bodies" element={<CelestialBodies />} />
<Route path="star-systems" element={<StarSystems />} />
<Route path="static-data" element={<StaticData />} />
<Route path="users" element={<Users />} />
<Route path="nasa-data" element={<NASADownload />} />

View File

@ -1,10 +1,11 @@
import { useRef, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { XCircle } from 'lucide-react';
import { Canvas, useFrame } from '@react-three/fiber';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Descriptions, Tag } from 'antd';
import { request } from '../utils/request';
import { useToast } from '../contexts/ToastContext';
@ -13,6 +14,7 @@ import type { CelestialBody as CelestialBodyType } from '../types';
interface BodyDetailOverlayProps {
bodyId: string | null;
preloadedData?: CelestialBodyType; // Optional preloaded data (for interstellar stars)
onClose: () => void;
}
@ -20,12 +22,18 @@ interface BodyDetailOverlayProps {
// Removed AutoRotateCamera to allow user to control the view angle
export function BodyDetailOverlay({ bodyId, onClose }: BodyDetailOverlayProps) {
export function BodyDetailOverlay({ bodyId, preloadedData, onClose }: BodyDetailOverlayProps) {
const [bodyData, setBodyData] = useState<CelestialBodyType | null>(null);
const [loading, setLoading] = useState(false);
const toast = useToast();
useEffect(() => {
// If preloadedData is provided, use it directly
if (preloadedData) {
setBodyData(preloadedData);
return;
}
if (!bodyId) {
setBodyData(null);
return;
@ -44,9 +52,9 @@ export function BodyDetailOverlay({ bodyId, onClose }: BodyDetailOverlayProps) {
.finally(() => {
setLoading(false);
});
}, [bodyId, onClose, toast]);
}, [bodyId, preloadedData, onClose, toast]);
if (!bodyId || !bodyData) {
if ((!bodyId && !preloadedData) || !bodyData) {
return null;
}
@ -57,7 +65,7 @@ export function BodyDetailOverlay({ bodyId, onClose }: BodyDetailOverlayProps) {
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 text-gray-400 hover:text-red-400 transition-colors"
className="absolute top-4 right-4 z-10 text-gray-400 hover:text-green-400 transition-colors"
>
<XCircle size={32} />
</button>
@ -83,7 +91,7 @@ export function BodyDetailOverlay({ bodyId, onClose }: BodyDetailOverlayProps) {
maxPolarAngle={Math.PI}
minPolarAngle={0}
/>
<BodyViewer body={bodyData} />
<BodyViewer body={bodyData} disableGlow={true} />
</Canvas>
)}
</div>
@ -98,7 +106,74 @@ export function BodyDetailOverlay({ bodyId, onClose }: BodyDetailOverlayProps) {
{bodyData.description && <> <span className="mx-2">|</span> {bodyData.description}</>}
</div>
{bodyData.details ? (
{/* Star System Data - Use Descriptions format (like admin panel) */}
{bodyData.starSystemData ? (
<div>
<Descriptions
bordered
column={2}
size="small"
labelStyle={{ backgroundColor: '#374151', color: '#d1d5db', fontWeight: '600' }}
contentStyle={{ backgroundColor: '#1f2937', color: '#f3f4f6' }}
>
<Descriptions.Item label="系统ID">{bodyData.starSystemData.system_id}</Descriptions.Item>
<Descriptions.Item label="主恒星">{bodyData.starSystemData.host_star_name}</Descriptions.Item>
<Descriptions.Item label="距离">
{bodyData.starSystemData.distance_pc
? `${bodyData.starSystemData.distance_pc.toFixed(2)} pc (~${(bodyData.starSystemData.distance_ly || bodyData.starSystemData.distance_pc * 3.26).toFixed(2)} ly)`
: '-'}
</Descriptions.Item>
<Descriptions.Item label="光谱类型">{bodyData.starSystemData.spectral_type || '-'}</Descriptions.Item>
<Descriptions.Item label="恒星半径">
{bodyData.starSystemData.radius_solar ? `${bodyData.starSystemData.radius_solar.toFixed(2)} R☉` : '-'}
</Descriptions.Item>
<Descriptions.Item label="恒星质量">
{bodyData.starSystemData.mass_solar ? `${bodyData.starSystemData.mass_solar.toFixed(2)} M☉` : '-'}
</Descriptions.Item>
<Descriptions.Item label="表面温度">
{bodyData.starSystemData.temperature_k ? `${bodyData.starSystemData.temperature_k.toFixed(0)} K` : '-'}
</Descriptions.Item>
<Descriptions.Item label="天体数量">{bodyData.starSystemData.planet_count || 0}</Descriptions.Item>
</Descriptions>
{/* Planet List */}
{bodyData.starSystemData.planets && bodyData.starSystemData.planets.length > 0 && (
<div className="mt-4">
<h4 className="text-lg font-semibold mb-2 text-white"></h4>
<div className="space-y-2">
{bodyData.starSystemData.planets.map((planet: any) => (
<div key={planet.id} className="border border-gray-600 rounded p-3 bg-gray-700">
<div className="flex justify-between items-start">
<div>
<div className="font-medium text-white">{planet.name_zh || planet.name}</div>
<div className="text-xs text-gray-400">{planet.id}</div>
</div>
<Tag color="blue">{planet.type}</Tag>
</div>
{planet.description && (
<div className="text-sm text-gray-300 mt-2">{planet.description}</div>
)}
{planet.extra_data && (
<div className="text-xs text-gray-400 mt-2 grid grid-cols-3 gap-2">
{planet.extra_data.semi_major_axis_au && (
<div>: {planet.extra_data.semi_major_axis_au.toFixed(4)} AU</div>
)}
{planet.extra_data.period_days && (
<div>: {planet.extra_data.period_days.toFixed(2)} </div>
)}
{planet.extra_data.radius_earth && (
<div>: {planet.extra_data.radius_earth.toFixed(2)} R</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
) : bodyData.details ? (
/* Regular body details - Use Markdown rendering (for solar system bodies) */
<div className="prose prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@ -119,9 +194,9 @@ export function BodyDetailOverlay({ bodyId, onClose }: BodyDetailOverlayProps) {
: <code className="block bg-gray-700 text-blue-300 p-3 rounded text-sm font-mono overflow-x-auto mb-3" {...props} />,
blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-blue-500 pl-4 text-gray-400 italic mb-3" {...props} />,
a: ({node, ...props}) => <a className="text-blue-400 hover:text-blue-300 underline" {...props} />,
table: ({node, ...props}) => <table className="w-full border-collapse mb-3" {...props} />,
th: ({node, ...props}) => <th className="border border-gray-600 bg-gray-700 text-white px-3 py-2 text-left" {...props} />,
td: ({node, ...props}) => <td className="border border-gray-600 text-gray-300 px-3 py-2" {...props} />,
table: ({node, ...props}) => <table className="w-full border-collapse mb-3 text-xs" {...props} />,
th: ({node, ...props}) => <th className="border border-gray-600 bg-gray-700 text-white px-2 py-1 text-left text-xs" {...props} />,
td: ({node, ...props}) => <td className="border border-gray-600 text-gray-300 px-2 py-1 text-xs" {...props} />,
}}
>
{bodyData.details}

View File

@ -1,5 +1,5 @@
import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
import { Mesh, Group } from 'three';
import { Mesh } from 'three';
import * as THREE from 'three';
import { useGLTF, useTexture } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
@ -9,12 +9,12 @@ import { getCelestialSize } from '../config/celestialSizes';
interface BodyViewerProps {
body: CelestialBodyType;
disableGlow?: boolean; // 禁用光晕效果(用于详情视图)
}
// Reusable component to render just the 3D model/mesh of a celestial body
export function BodyViewer({ body }: BodyViewerProps) {
export function BodyViewer({ body, disableGlow = false }: BodyViewerProps) {
const meshRef = useRef<Mesh>(null);
const groupRef = useRef<Group>(null);
const [texturePath, setTexturePath] = useState<string | null | undefined>(undefined);
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
const [modelScale, setModelScale] = useState<number>(1.0);
@ -44,7 +44,9 @@ export function BodyViewer({ body }: BodyViewerProps) {
}
if (body.type === 'star') {
return { size: finalSize, emissive: '#FDB813', emissiveIntensity: 1.5 };
// Use database color if available (for interstellar stars), otherwise default to yellow
const starColor = body.starSystemData?.color || '#FDB813';
return { size: finalSize, emissive: starColor, emissiveIntensity: 1.5 };
}
if (body.type === 'comet') {
return { size: finalSize, emissive: '#000000', emissiveIntensity: 0 };
@ -114,6 +116,7 @@ export function BodyViewer({ body }: BodyViewerProps) {
emissiveIntensity={appearance.emissiveIntensity}
texturePath={texturePath}
meshRef={meshRef}
disableGlow={disableGlow}
/>
);
}
@ -176,13 +179,14 @@ function ProbeModelViewer({ modelPath, modelScale }: { modelPath: string; modelS
}
// Sub-component for Planet models
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, meshRef }: {
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, meshRef, disableGlow = false }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
texturePath: string | null;
meshRef: React.RefObject<Mesh>;
disableGlow?: boolean;
}) {
const texture = texturePath ? useTexture(texturePath) : null;
@ -394,8 +398,8 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
{/* Saturn Rings */}
{body.id === '699' && <SaturnRings />}
{/* Sun glow effect - multi-layer scattered light */}
{body.type === 'star' && (
{/* Sun glow effect - multi-layer scattered light (仅在非禁用光晕模式下显示) */}
{body.type === 'star' && !disableGlow && (
<>
<pointLight intensity={10} distance={400} color="#fff8e7" />

View File

@ -6,7 +6,9 @@ import {
MessageSquare,
Eye,
EyeOff,
Camera
Camera,
Globe,
Sparkles
} from 'lucide-react';
interface ControlPanelProps {
@ -19,6 +21,8 @@ interface ControlPanelProps {
showMessageBoard: boolean;
onToggleMessageBoard: () => void;
onScreenshot: () => void;
viewMode: 'solar' | 'galaxy';
onToggleViewMode: () => void;
}
export function ControlPanel({
@ -31,6 +35,8 @@ export function ControlPanel({
showMessageBoard,
onToggleMessageBoard,
onScreenshot,
viewMode,
onToggleViewMode,
}: ControlPanelProps) {
const buttonClass = (isActive: boolean) => `
p-2 rounded-lg transition-all duration-200 relative group
@ -44,6 +50,17 @@ export function ControlPanel({
return (
<div className="absolute top-24 right-6 z-40 flex flex-col gap-3 items-end">
{/* View Mode Toggle */}
<button
onClick={onToggleViewMode}
className={buttonClass(viewMode === 'galaxy')}
>
{viewMode === 'solar' ? <Globe size={20} /> : <Sparkles size={20} />}
<div className={tooltipClass}>
{viewMode === 'solar' ? '切换至银河视图' : '切换至太阳系视图'}
</div>
</button>
{/* Timeline Toggle */}
<button
onClick={onToggleTimeline}

View File

@ -0,0 +1,289 @@
/**
* GalaxyScene - 3D Scene for Interstellar View (Phase 3)
* Renders nearby stars and exoplanet systems
*/
import { useState, useCallback, useEffect } from 'react';
import { Canvas, useThree } from '@react-three/fiber';
import { OrbitControls, Stars as BackgroundStars } from '@react-three/drei';
import { Select } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { Stars } from './Stars';
import { Galaxies } from './Galaxies';
import { Nebulae } from './Nebulae';
import { BodyDetailOverlay } from './BodyDetailOverlay';
import { request } from '../utils/request';
import type { CelestialBody as CelestialBodyType } from '../types';
interface StarSystem {
id: number;
name: string;
name_zh: string;
position_x: number | null;
position_y: number | null;
position_z: number | null;
distance_pc: number | null;
planet_count: number;
}
// Camera Animation Component
function CameraAnimator({ targetPosition }: { targetPosition: [number, number, number] | null }) {
const { camera } = useThree();
useEffect(() => {
if (!targetPosition) return;
const [x, y, z] = targetPosition;
const distance = 15; // 拉近距离从50改为15
// 计算相机位置(在目标前方一定距离)
const cameraX = x + distance;
const cameraY = y + distance / 2;
const cameraZ = z + distance;
// 平滑动画移动相机
const duration = 2000; // 2秒动画
const startTime = Date.now();
const startPos = camera.position.clone();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// 使用easeInOutCubic缓动函数
const eased = progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
camera.position.x = startPos.x + (cameraX - startPos.x) * eased;
camera.position.y = startPos.y + (cameraY - startPos.y) * eased;
camera.position.z = startPos.z + (cameraZ - startPos.z) * eased;
camera.lookAt(x, y, z);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
}, [targetPosition, camera]);
return null;
}
export function GalaxyScene() {
const [selectedStarData, setSelectedStarData] = useState<CelestialBodyType | null>(null);
const [starSystems, setStarSystems] = useState<StarSystem[]>([]);
const [searchValue, setSearchValue] = useState<number | null>(null);
const [targetPosition, setTargetPosition] = useState<[number, number, number] | null>(null);
// 加载恒星系统列表(包括太阳系)
useEffect(() => {
const loadStarSystems = async () => {
try {
const response = await request.get('/star-systems', {
params: { limit: 1000, exclude_solar: false } // 改为false包含太阳系
});
const systems = response.data.systems || [];
// 只保留有坐标的系统
const validSystems = systems.filter((s: StarSystem) =>
s.position_x !== null && s.position_y !== null && s.position_z !== null
);
setStarSystems(validSystems);
} catch (err) {
console.error('Failed to load star systems:', err);
}
};
loadStarSystems();
}, []);
// 处理搜索选择
const handleSearch = useCallback((systemId: number | null) => {
if (systemId === null || systemId === undefined) {
// 清空时回到初始位置
setSearchValue(null);
setTargetPosition([0, 0, 500]); // 回到初始相机位置
return;
}
const system = starSystems.find(s => s.id === systemId);
if (system && system.position_x !== null && system.position_y !== null && system.position_z !== null) {
// 设置目标位置,触发相机移动
setTargetPosition([system.position_x, system.position_y, system.position_z]);
setSearchValue(systemId);
}
}, [starSystems]);
const handleStarClick = useCallback(async (star: any) => {
console.log('GalaxyScene handleStarClick:', star);
// Fetch planets for this star system from the API
let planets: any[] = [];
if (star.rawData.id) {
try {
const response = await request.get(`/star-systems/${star.rawData.id}/bodies`);
// Filter to only get planets (exclude the star itself)
const bodies = response.data.bodies || [];
planets = bodies.filter((b: any) => b.type === 'planet');
} catch (err) {
console.error('Failed to load planets for star system:', err);
}
}
const mappedBody: CelestialBodyType = {
id: `interstellar-${star.rawData.id}`,
name: star.name,
name_zh: star.name_zh,
type: 'star',
positions: [],
description: `距离地球 ${star.distance_ly?.toFixed(2) ?? 'N/A'} 光年,拥有 ${planets.length} 颗已知行星。`,
is_active: true,
starSystemData: {
system_id: star.rawData.id,
host_star_name: star.rawData.host_star_name || star.name,
distance_pc: star.rawData.distance_pc,
distance_ly: star.distance_ly,
spectral_type: star.rawData.spectral_type,
radius_solar: star.rawData.radius_solar,
mass_solar: star.rawData.mass_solar,
temperature_k: star.rawData.temperature_k,
planet_count: planets.length,
planets: planets,
color: star.rawData.color, // Pass star color from database
},
};
console.log('Setting selectedStarData:', mappedBody);
setSelectedStarData(mappedBody);
}, []);
return (
<div id="cosmo-galaxy-scene-container" className="w-full h-full bg-black">
<Canvas
camera={{
position: [0, 0, 500], // Adjusted camera position for better initial view
fov: 60,
far: 100000, // Very far clipping plane for interstellar distances
}}
onCreated={({ gl, camera }) => {
gl.sortObjects = true;
camera.lookAt(0, 0, 0);
}}
>
{/* Ambient light for general visibility */}
<ambientLight intensity={0.8} />
{/* Background Stars (Procedural, very distant) */}
<BackgroundStars
radius={300} // These are just visual background, not data-driven stars
depth={60}
count={10000}
factor={6}
saturation={0.5}
fade={true}
/>
{/* Data-driven Stars (Our 578 nearby systems) */}
<Stars mode="galaxy" onStarClick={handleStarClick} />
{/* Deep space objects for context */}
{/* <Constellations /> */}
<Nebulae />
<Galaxies />
{/* Camera Animation */}
<CameraAnimator targetPosition={targetPosition} />
{/* Camera Controls */}
<OrbitControls
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={10}
maxDistance={50000}
zoomSpeed={2.0}
panSpeed={2.0}
/>
</Canvas>
{/* 搜索器 - 中心位置,绿色边框 */}
<div className="absolute top-20 left-1/2 -translate-x-1/2 pointer-events-auto" style={{ width: 500 }}>
<Select
showSearch
allowClear
value={searchValue}
onChange={handleSearch}
placeholder="50 PARSECS (~163 LY)"
style={{ width: '100%' }}
size="large"
suffixIcon={<SearchOutlined style={{ color: '#52c41a' }} />}
className="galaxy-search"
optionFilterProp="children"
filterOption={(input, option) => {
const system = starSystems.find(s => s.id === option?.value);
if (!system) return false;
const searchText = input.toLowerCase();
return (
system.name.toLowerCase().includes(searchText) ||
system.name_zh?.toLowerCase().includes(searchText) ||
system.id.toString().includes(searchText)
);
}}
>
{starSystems.map(system => (
<Select.Option key={system.id} value={system.id}>
<div className="flex justify-between items-center">
<span>{system.name_zh || system.name}</span>
<span className="text-xs text-gray-400 ml-2">
{system.distance_pc?.toFixed(1)} pc
</span>
</div>
</Select.Option>
))}
</Select>
</div>
<style>{`
.galaxy-search .ant-select-selector {
border: 2px solid #52c41a !important;
border-radius: 20px !important;
background: rgba(0, 0, 0, 0.7) !important;
color: #52c41a !important;
padding-right: 60px !important;
}
.galaxy-search .ant-select-selector:hover {
border-color: #73d13d !important;
}
.galaxy-search .ant-select-selector input {
color: #52c41a !important;
}
.galaxy-search .ant-select-selection-placeholder {
color: rgba(82, 196, 26, 0.8) !important;
font-weight: 500 !important;
font-family: 'Monaco', 'Courier New', monospace !important;
letter-spacing: 1px !important;
}
.galaxy-search .ant-select-clear {
background: rgba(0, 0, 0, 0.5) !important;
color: #52c41a !important;
right: 35px !important;
width: 20px !important;
height: 20px !important;
margin-top: -10px !important;
}
.galaxy-search .ant-select-clear:hover {
color: #73d13d !important;
}
.galaxy-search .ant-select-arrow {
right: 12px !important;
}
`}</style>
{/* Body Detail Overlay for selected interstellar star */}
<BodyDetailOverlay
bodyId={null} // We are passing preloadedData directly
preloadedData={selectedStarData || undefined}
onClose={() => setSelectedStarData(null)}
/>
</div>
);
}

View File

@ -2,7 +2,7 @@
* Stars component - renders nearby stars in 3D space
*/
import { useEffect, useState, useMemo } from 'react';
import { Billboard } from '@react-three/drei';
import { Billboard, Html } from '@react-three/drei';
import * as THREE from 'three';
import { request } from '../utils/request';
import { createLabelTexture } from '../utils/labelTexture';
@ -17,6 +17,7 @@ interface Star {
color: string;
position: THREE.Vector3;
size: number;
rawData?: any; // Add optional rawData field
}
/**
@ -50,18 +51,55 @@ function magnitudeToSize(magnitude: number): number {
}
// Sub-component for individual star to handle label texture efficiently
function StarObject({ star, geometry }: { star: Star; geometry: THREE.SphereGeometry }) {
function StarObject({ star, geometry, mode, onStarClick }: {
star: Star;
geometry: THREE.SphereGeometry;
mode: 'sky' | 'galaxy';
onStarClick?: (star: Star) => void;
}) {
const [hovered, setHovered] = useState(false);
// Generate label texture
const labelTexture = useMemo(() => {
// For galaxy mode, use a slightly different label style if needed
// Currently reusing same generator
return createLabelTexture(star.name_zh, null, "", "#FFFFFF");
}, [star.name_zh]);
// Adjust visual parameters based on mode
const baseSize = mode === 'galaxy' ? star.size * 8 : star.size; // Make stars larger in galaxy mode
const visualSize = hovered ? baseSize * 1.5 : baseSize; // Scale up on hover
const labelScale: [number, number, number] = mode === 'galaxy' ? [50, 25, 1] : [200, 100, 1]; // Smaller labels in galaxy mode
const labelOffset = mode === 'galaxy' ? 4 : 1.05; // Offset factor
// Handle cursor
useEffect(() => {
document.body.style.cursor = hovered ? 'pointer' : 'auto';
return () => { document.body.style.cursor = 'auto'; };
}, [hovered]);
return (
<group>
<group
onClick={(e) => {
console.log('Star clicked:', star.name);
e.stopPropagation();
onStarClick?.(star);
}}
onPointerOver={(e) => {
console.log('Pointer over:', star.name);
e.stopPropagation();
setHovered(true);
}}
onPointerOut={(e) => {
e.stopPropagation();
setHovered(false);
}}
>
{/* Star sphere */}
<mesh position={star.position} geometry={geometry} scale={[star.size, star.size, star.size]}>
<mesh position={star.position} geometry={geometry} scale={[visualSize, visualSize, visualSize]}>
<meshBasicMaterial
color={star.color}
color={hovered ? '#ffaa00' : star.color} // Highlight color on hover
transparent
opacity={0.9}
blending={THREE.AdditiveBlending}
@ -69,63 +107,164 @@ function StarObject({ star, geometry }: { star: Star; geometry: THREE.SphereGeom
</mesh>
{/* Star glow */}
<mesh position={star.position} geometry={geometry} scale={[star.size * 2, star.size * 2, star.size * 2]}>
<mesh position={star.position} geometry={geometry} scale={[visualSize * 2, visualSize * 2, visualSize * 2]}>
<meshBasicMaterial
color={star.color}
color={hovered ? '#ffaa00' : star.color}
transparent
opacity={0.2}
blending={THREE.AdditiveBlending}
/>
</mesh>
{/* Star name label - positioned radially outward from star */}
{/* Star name label */}
{labelTexture && (
<Billboard position={star.position.clone().multiplyScalar(1.05)}>
<mesh scale={[200, 100, 1]}>
<Billboard position={mode === 'galaxy'
? new THREE.Vector3(star.position.x, star.position.y + visualSize + 2, star.position.z)
: star.position.clone().multiplyScalar(labelOffset)
}>
<mesh scale={labelScale}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
<meshBasicMaterial
map={labelTexture}
transparent
opacity={hovered ? 1.0 : (mode === 'galaxy' ? 0.6 : 1.0)} // Fully opaque on hover
depthWrite={false}
toneMapped={false}
/>
</mesh>
</Billboard>
)}
{/* Hover Tooltip in Galaxy Mode */}
{mode === 'galaxy' && hovered && star.rawData && (
<Html
position={[star.position.x, star.position.y + visualSize + 8, star.position.z]}
center
style={{
pointerEvents: 'none',
userSelect: 'none',
}}
>
<div className="bg-black/90 border border-blue-400/50 rounded-lg px-4 py-3 text-white text-xs shadow-2xl backdrop-blur-sm min-w-[280px]">
<div className="font-bold text-sm mb-2 text-blue-300">{star.name_zh || star.name}</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-gray-400">:</span>
<span>{star.rawData.distance_pc?.toFixed(2) ?? 'N/A'} pc (~{star.distance_ly?.toFixed(2) ?? 'N/A'} ly)</span>
</div>
{star.rawData.spectral_type && (
<div className="flex justify-between">
<span className="text-gray-400">:</span>
<span>{star.rawData.spectral_type}</span>
</div>
)}
{star.rawData.radius_solar && (
<div className="flex justify-between">
<span className="text-gray-400">:</span>
<span>{star.rawData.radius_solar.toFixed(2)} R</span>
</div>
)}
{star.rawData.mass_solar && (
<div className="flex justify-between">
<span className="text-gray-400">:</span>
<span>{star.rawData.mass_solar.toFixed(2)} M</span>
</div>
)}
{star.rawData.temperature_k && (
<div className="flex justify-between">
<span className="text-gray-400">:</span>
<span>{star.rawData.temperature_k.toFixed(0)} K</span>
</div>
)}
{star.rawData.planet_count !== undefined && (
<div className="flex justify-between">
<span className="text-gray-400">:</span>
<span className="text-green-400 font-semibold">{star.rawData.planet_count}</span>
</div>
)}
</div>
<div className="text-gray-500 text-[10px] mt-2 text-center"></div>
</div>
</Html>
)}
</group>
);
}
export function Stars() {
export function Stars({ mode = 'sky', onStarClick }: { mode?: 'sky' | 'galaxy'; onStarClick?: (star: Star) => void }) {
const [stars, setStars] = useState<Star[]>([]);
useEffect(() => {
// Load star data from API
request.get('/celestial/static/star')
.then((res) => {
const data = res.data;
// API returns { category, items: [{ id, name, name_zh, data: {...} }] }
const starData = data.items.map((item: any) => {
// Place all stars on a celestial sphere at fixed distance (5000 units)
const position = raDecToCartesian(item.data.ra, item.data.dec, 5000);
const size = magnitudeToSize(item.data.magnitude);
if (mode === 'galaxy') {
// Galaxy Mode: Load from new star-systems API
request.get('/star-systems', { params: { limit: 1000, exclude_solar: false } })
.then((res) => {
const { systems } = res.data;
// Process star systems data
const starData = systems
.filter((system: any) => system.position_x != null && system.position_y != null && system.position_z != null)
.map((system: any) => {
// Galaxy Mode: Use 3D coordinates from database (Parsecs)
// Scale factor to make the visualization comfortable
const SCALE = 100;
const position = new THREE.Vector3(
system.position_x * SCALE,
system.position_y * SCALE,
system.position_z * SCALE
);
return {
name: item.name,
name_zh: item.name_zh,
distance_ly: item.data.distance_ly,
ra: item.data.ra,
dec: item.data.dec,
magnitude: item.data.magnitude,
color: item.data.color,
position,
size
};
});
setStars(starData);
})
.catch((err) => console.error('Failed to load stars:', err));
}, []);
// Calculate size based on radius_solar if available
const radius = system.radius_solar || 1.0;
const size = Math.max(0.5, Math.log(radius + 1) * 2);
// Calculate distance_ly if not provided
const distance_ly = system.distance_ly || (system.distance_pc ? system.distance_pc * 3.26 : null);
return {
name: system.name,
name_zh: system.name_zh,
distance_ly,
ra: system.ra,
dec: system.dec,
magnitude: system.magnitude,
color: system.color || '#ffffff',
position,
size,
// Pass complete system data for details view and hover tooltip
rawData: system
};
});
setStars(starData);
})
.catch((err) => console.error('Failed to load star systems:', err));
} else {
// Sky Mode: Load from static data API
request.get('/celestial/static/star')
.then((res) => {
const data = res.data;
const starData = data.items.map((item: any) => {
// Sky Mode: Project to Celestial Sphere
const position = raDecToCartesian(item.data.ra, item.data.dec, 5000);
const size = magnitudeToSize(item.data.magnitude);
return {
name: item.name,
name_zh: item.name_zh,
distance_ly: item.data.distance_ly || (item.data.distance_pc ? item.data.distance_pc * 3.26 : null),
ra: item.data.ra,
dec: item.data.dec,
magnitude: item.data.magnitude,
color: item.data.color,
position,
size,
rawData: item.data
};
});
setStars(starData);
})
.catch((err) => console.error('Failed to load stars:', err));
}
}, [mode]);
// Reuse geometry for all stars to improve performance
const sphereGeometry = useMemo(() => new THREE.SphereGeometry(1, 16, 16), []);
@ -137,7 +276,13 @@ export function Stars() {
return (
<group>
{stars.map((star) => (
<StarObject key={star.name} star={star} geometry={sphereGeometry} />
<StarObject
key={star.name}
star={star}
geometry={sphereGeometry}
mode={mode}
onStarClick={onStarClick}
/>
))}
</group>
);

View File

@ -0,0 +1,32 @@
/**
* Custom hook for fetching a specific system setting from the backend.
*/
import { useState, useEffect } from 'react';
import { api } from '../utils/api';
export function useSystemSetting<T>(key: string, defaultValue: T): [T, boolean, Error | null] {
const [settingValue, setSettingValue] = useState<T>(defaultValue);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchSetting = async () => {
try {
setLoading(true);
const response = await api.get<{ value: T }>(`/system/settings/${key}`);
setSettingValue(response.data.value);
setError(null);
} catch (err) {
console.error(`Failed to fetch system setting '${key}':`, err);
setError(err instanceof Error ? err : new Error('Unknown error fetching setting'));
setSettingValue(defaultValue); // Fallback to default on error
} finally {
setLoading(false);
}
};
fetchSetting();
}, [key, defaultValue]);
return [settingValue, loading, error];
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col, Tabs } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col, Tabs, Card } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined, StarOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import type { ColumnsType } from 'antd/es/table';
import MdEditor from 'react-markdown-editor-lite';
@ -18,6 +18,7 @@ interface CelestialBody {
name: string;
name_zh: string;
type: string;
system_id?: number;
description: string;
details?: string; // Added details field
is_active: boolean;
@ -32,10 +33,19 @@ interface CelestialBody {
has_resources?: boolean;
}
interface StarSystem {
id: number;
name: string;
name_zh: string;
planet_count: number;
}
export function CelestialBodies() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<CelestialBody[]>([]);
const [filteredData, setFilteredData] = useState<CelestialBody[]>([]);
const [starSystems, setStarSystems] = useState<StarSystem[]>([]);
const [selectedSystemId, setSelectedSystemId] = useState<number | null>(1); // 默认选择太阳系
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<CelestialBody | null>(null);
const [form] = Form.useForm();
@ -46,14 +56,41 @@ export function CelestialBodies() {
const [activeTabKey, setActiveTabKey] = useState('basic'); // State for active tab
const toast = useToast();
// 加载恒星系统列表
useEffect(() => {
loadData();
loadStarSystems();
}, []);
// 当选择的恒星系改变时,重新加载天体数据
useEffect(() => {
if (selectedSystemId !== null) {
loadData();
}
}, [selectedSystemId]);
const loadStarSystems = async () => {
try {
const { data: result } = await request.get('/star-systems', {
params: { limit: 1000 }
});
setStarSystems(result.systems || []);
} catch (error) {
toast.error('加载恒星系统列表失败');
}
};
const loadData = async () => {
if (selectedSystemId === null) {
setData([]);
setFilteredData([]);
return;
}
setLoading(true);
try {
const { data: result } = await request.get('/celestial/list');
const { data: result } = await request.get('/celestial/list', {
params: { system_id: selectedSystemId }
});
setData(result.bodies || []);
setFilteredData(result.bodies || []);
} catch (error) {
@ -82,7 +119,11 @@ export function CelestialBodies() {
setSearchQuery('');
setActiveTabKey('basic'); // Reset to basic tab
// Default values
form.setFieldsValue({ is_active: true, type: 'probe' });
form.setFieldsValue({
is_active: true,
type: 'probe',
system_id: selectedSystemId // Auto-set to current selected system
});
setIsModalOpen(true);
};
@ -198,8 +239,9 @@ export function CelestialBodies() {
// Form submit
const handleModalOk = async () => {
let values: any;
try {
const values = await form.validateFields();
values = await form.validateFields();
if (editingRecord) {
// Update
@ -219,7 +261,7 @@ export function CelestialBodies() {
if (error.response?.status === 400) {
const detail = error.response?.data?.detail;
if (detail && detail.includes('already exists')) {
toast.error(`天体已存在: ${values.id}`);
toast.error(`天体已存在: ${values?.id || ''}`);
} else {
toast.error(detail || '请检查表单数据是否完整');
}
@ -295,6 +337,20 @@ export function CelestialBodies() {
dataIndex: 'name_zh',
key: 'name_zh',
},
{
title: '所属系统',
dataIndex: 'system_id',
key: 'system_id',
width: 120,
render: (systemId: number) => {
const system = starSystems.find(s => s.id === systemId);
return system ? (
<Tag color="blue" icon={<StarOutlined />}>
{system.name_zh || system.name}
</Tag>
) : '-';
},
},
{
title: '类型',
dataIndex: 'type',
@ -344,6 +400,44 @@ export function CelestialBodies() {
return (
<>
{/* Star System Selector */}
<Card className="mb-4" style={{ marginBottom: 16 }}>
<Space>
<StarOutlined style={{ color: '#1890ff', fontSize: 18 }} />
<span style={{ fontWeight: 500 }}></span>
<Select
showSearch
style={{ width: 400 }}
value={selectedSystemId}
onChange={setSelectedSystemId}
placeholder="请选择恒星系统"
loading={starSystems.length === 0}
optionFilterProp="children"
filterOption={(input, option) => {
const system = starSystems.find(s => s.id === option?.value);
if (!system) return false;
const searchText = input.toLowerCase();
return (
system.name.toLowerCase().includes(searchText) ||
system.name_zh?.toLowerCase().includes(searchText) ||
system.id.toString().includes(searchText)
);
}}
>
{starSystems.map(system => (
<Select.Option key={system.id} value={system.id}>
{system.name_zh || system.name} ({system.planet_count} )
</Select.Option>
))}
</Select>
{selectedSystemId && (
<span style={{ color: '#888', fontSize: 12 }}>
{starSystems.find(s => s.id === selectedSystemId)?.name_zh || starSystems.find(s => s.id === selectedSystemId)?.name}
</span>
)}
</Space>
</Card>
<DataTable
title="天体数据管理"
columns={columns}
@ -371,50 +465,11 @@ export function CelestialBodies() {
form={form}
layout="vertical"
>
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
<Tabs.TabPane tab="基础信息" key="basic">
{!editingRecord && (
<>
<Alert
title="智能搜索提示"
description={
<div>
<p>使 <strong>JPL Horizons ID</strong> </p>
<p style={{ marginTop: 4 }}>
Hubble ID <code>-48</code>Voyager 1 ID <code>-31</code>
</p>
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
ID ID
</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item label="从 NASA 数据库搜索">
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入数字 ID (推荐, 如: -48) 或名称 (如: Hubble)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleNASASearch}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleNASASearch}
loading={searching}
>
</Button>
</Space.Compact>
</Form.Item>
</>
)}
<Row gutter={16}>
{editingRecord ? (
// 编辑模式显示双tab
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
<Tabs.TabPane tab="基础信息" key="basic">
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="id"
@ -442,6 +497,11 @@ export function CelestialBodies() {
</Col>
</Row>
{/* Hidden system_id field - auto-set from selector */}
<Form.Item name="system_id" hidden>
<InputNumber />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
@ -494,6 +554,107 @@ export function CelestialBodies() {
</Form.Item>
</Tabs.TabPane>
</Tabs>
) : (
// 新增模式:只显示基础信息
<>
<Alert
title="智能搜索提示"
description={
<div>
<p>使 <strong>JPL Horizons ID</strong> </p>
<p style={{ marginTop: 4 }}>
Hubble ID <code>-48</code>Voyager 1 ID <code>-31</code>
</p>
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
ID ID
</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item label="从 NASA 数据库搜索">
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入数字 ID (推荐, 如: -48) 或名称 (如: Hubble)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleNASASearch}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleNASASearch}
loading={searching}
>
</Button>
</Space.Compact>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="id"
label="JPL Horizons ID"
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
>
<Input 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>
{/* Hidden system_id field - auto-set from selector */}
<Form.Item name="system_id" hidden>
<InputNumber />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="英文名"
rules={[{ required: true, message: '请输入英文名' }]}
>
<Input placeholder="例如Voyager 1" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="name_zh"
label="中文名"
>
<Input placeholder="例如旅行者1号" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={2} />
</Form.Item>
</>
)}
</Form>
</Modal>
</>

View File

@ -107,7 +107,17 @@ export function NASADownload() {
setLoading(true);
try {
const { data } = await request.get('/celestial/positions/download/bodies');
setBodies(data.bodies || {});
// 只显示激活的天体 (is_active = true)
const activeBodies: GroupedBodies = {};
Object.keys(data.bodies || {}).forEach((type) => {
const bodiesOfType = data.bodies[type].filter((body: CelestialBody) => body.is_active);
if (bodiesOfType.length > 0) {
activeBodies[type] = bodiesOfType;
}
});
setBodies(activeBodies);
} catch (error) {
toast.error('加载天体列表失败');
} finally {

View File

@ -0,0 +1,548 @@
import { useState, useEffect } from 'react';
import { Modal, Form, Input, InputNumber, Select, Button, Space, Popconfirm, Tag, Descriptions, Tabs } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import MdEditor from 'react-markdown-editor-lite';
import MarkdownIt from 'markdown-it';
import 'react-markdown-editor-lite/lib/index.css';
import { DataTable } from '../../components/admin/DataTable';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
const MdEditorParser = new MarkdownIt();
interface StarSystem {
id: number;
name: string;
name_zh: string;
host_star_name: string;
distance_pc: number | null;
distance_ly: number | null;
ra: number | null;
dec: number | null;
position_x: number | null;
position_y: number | null;
position_z: number | null;
spectral_type: string | null;
radius_solar: number | null;
mass_solar: number | null;
temperature_k: number | null;
magnitude: number | null;
luminosity_solar: number | null;
color: string | null;
planet_count: number;
description: string | null;
details: string | null;
created_at: string;
updated_at: string;
}
interface StarSystemWithBodies extends StarSystem {
bodies: any[];
body_count: number;
}
export function StarSystems() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<StarSystem[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [searchKeyword, setSearchKeyword] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<StarSystem | null>(null);
const [detailData, setDetailData] = useState<StarSystemWithBodies | null>(null);
const [activeTabKey, setActiveTabKey] = useState('basic'); // 添加tab状态
const [form] = Form.useForm();
const toast = useToast();
useEffect(() => {
loadData();
}, [currentPage, pageSize, searchKeyword]);
const loadData = async () => {
setLoading(true);
try {
const { data: result } = await request.get('/star-systems', {
params: {
skip: (currentPage - 1) * pageSize,
limit: pageSize,
search: searchKeyword || undefined,
exclude_solar: false,
},
});
setData(result.systems || []);
setTotal(result.total || 0);
} catch (error) {
toast.error('加载恒星系统数据失败');
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setEditingRecord(null);
form.resetFields();
form.setFieldsValue({
color: '#FFFFFF',
planet_count: 0,
});
setActiveTabKey('basic'); // 重置到基础信息tab
setIsModalOpen(true);
};
const handleEdit = (record: StarSystem) => {
setEditingRecord(record);
form.setFieldsValue(record);
setActiveTabKey('basic'); // 重置到基础信息tab
setIsModalOpen(true);
};
const handleViewDetails = async (record: StarSystem) => {
try {
const { data: result } = await request.get(`/star-systems/${record.id}/bodies`);
setDetailData(result);
setIsDetailModalOpen(true);
} catch (error) {
toast.error('加载恒星系统详情失败');
}
};
const handleDelete = async (id: number) => {
if (id === 1) {
toast.error('不能删除太阳系');
return;
}
try {
await request.delete(`/star-systems/${id}`);
toast.success('删除成功');
loadData();
} catch (error: any) {
toast.error(error.response?.data?.detail || '删除失败');
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingRecord) {
// Update
await request.put(`/star-systems/${editingRecord.id}`, values);
toast.success('更新成功');
} else {
// Create
await request.post('/star-systems', values);
toast.success('创建成功');
}
setIsModalOpen(false);
loadData();
} catch (error: any) {
if (error.errorFields) {
toast.error('请填写必填字段');
} else {
toast.error(error.response?.data?.detail || '操作失败');
}
}
};
const handleSearch = (value: string) => {
setSearchKeyword(value);
setCurrentPage(1);
};
const columns: ColumnsType<StarSystem> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 60,
},
{
title: '系统名称',
dataIndex: 'name',
key: 'name',
width: 200,
render: (text, record) => (
<div>
<div className="font-medium">{text}</div>
{record.name_zh && <div className="text-gray-500 text-xs">{record.name_zh}</div>}
</div>
),
},
{
title: '主恒星',
dataIndex: 'host_star_name',
key: 'host_star_name',
width: 150,
},
{
title: '距离',
dataIndex: 'distance_pc',
key: 'distance',
width: 120,
render: (pc, record) => {
if (!pc) return '-';
const ly = record.distance_ly || pc * 3.26;
return (
<div>
<div>{pc.toFixed(2)} pc</div>
<div className="text-gray-500 text-xs">{ly.toFixed(2)} ly</div>
</div>
);
},
},
{
title: '光谱类型',
dataIndex: 'spectral_type',
key: 'spectral_type',
width: 100,
render: (text) => text || '-',
},
{
title: '恒星参数',
key: 'stellar_params',
width: 150,
render: (_, record) => (
<div className="text-xs">
{record.radius_solar && <div>R: {record.radius_solar.toFixed(2)} R</div>}
{record.mass_solar && <div>M: {record.mass_solar.toFixed(2)} M</div>}
{record.temperature_k && <div>T: {record.temperature_k.toFixed(0)} K</div>}
</div>
),
},
{
title: '行星数量',
dataIndex: 'planet_count',
key: 'planet_count',
width: 100,
render: (count) => (
<Tag color={count > 0 ? 'green' : '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">
<DataTable
title="恒星系统管理"
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
total={total}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={(page, size) => {
setCurrentPage(page);
setPageSize(size);
}}
onAdd={handleAdd}
onSearch={handleSearch}
searchPlaceholder="搜索恒星系统名称..."
/>
{/* 创建/编辑 Modal */}
<Modal
title={editingRecord ? '编辑恒星系统' : '创建恒星系统'}
open={isModalOpen}
onOk={handleSubmit}
onCancel={() => setIsModalOpen(false)}
width={800}
okText="保存"
cancelText="取消"
>
<Form form={form} layout="vertical">
{editingRecord ? (
// 编辑模式双tab
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
<Tabs.TabPane tab="基础信息" key="basic">
<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 className="grid grid-cols-2 gap-4">
<Form.Item name="distance_pc" label="距离 (pc)">
<InputNumber style={{ width: '100%' }} placeholder="秒差距" step={0.01} />
</Form.Item>
<Form.Item name="distance_ly" label="距离 (ly)">
<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>
<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>
<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>
<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>
<Form.Item name="color" label="显示颜色">
<Input type="color" />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="恒星系统简短描述..." />
</Form.Item>
</Tabs.TabPane>
<Tabs.TabPane tab="详细信息" key="details">
<Form.Item name="details" style={{ marginBottom: 0 }}>
<MdEditor
value={form.getFieldValue('details') || ''}
style={{ height: '500px' }}
renderHTML={(text) => MdEditorParser.render(text)}
onChange={({ text }) => form.setFieldValue('details', text)}
/>
</Form.Item>
</Tabs.TabPane>
</Tabs>
) : (
// 新增模式:只显示基础信息
<>
<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 className="grid grid-cols-2 gap-4">
<Form.Item name="distance_pc" label="距离 (pc)">
<InputNumber style={{ width: '100%' }} placeholder="秒差距" step={0.01} />
</Form.Item>
<Form.Item name="distance_ly" label="距离 (ly)">
<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>
<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>
<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>
<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>
<Form.Item name="color" label="显示颜色">
<Input type="color" />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="恒星系统简短描述..." />
</Form.Item>
</>
)}
</Form>
</Modal>
{/* 详情 Modal */}
<Modal
title={`恒星系统详情 - ${detailData?.name_zh || detailData?.name}`}
open={isDetailModalOpen}
onCancel={() => setIsDetailModalOpen(false)}
footer={null}
width={900}
>
{detailData && (
<div>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="系统ID">{detailData.id}</Descriptions.Item>
<Descriptions.Item label="主恒星">{detailData.host_star_name}</Descriptions.Item>
<Descriptions.Item label="距离">
{detailData.distance_pc ? `${detailData.distance_pc.toFixed(2)} pc (~${(detailData.distance_ly || detailData.distance_pc * 3.26).toFixed(2)} ly)` : '-'}
</Descriptions.Item>
<Descriptions.Item label="光谱类型">{detailData.spectral_type || '-'}</Descriptions.Item>
<Descriptions.Item label="恒星半径">{detailData.radius_solar ? `${detailData.radius_solar.toFixed(2)} R☉` : '-'}</Descriptions.Item>
<Descriptions.Item label="恒星质量">{detailData.mass_solar ? `${detailData.mass_solar.toFixed(2)} M☉` : '-'}</Descriptions.Item>
<Descriptions.Item label="表面温度">{detailData.temperature_k ? `${detailData.temperature_k.toFixed(0)} K` : '-'}</Descriptions.Item>
<Descriptions.Item label="天体数量">{detailData.body_count}</Descriptions.Item>
</Descriptions>
{detailData.bodies && detailData.bodies.length > 0 && (
<div className="mt-4">
<h4 className="text-lg font-semibold mb-2"></h4>
<div className="space-y-2">
{detailData.bodies.map((body: any) => (
<div key={body.id} className="border rounded p-3 bg-gray-50">
<div className="flex justify-between items-start">
<div>
<div className="font-medium">{body.name_zh || body.name}</div>
<div className="text-xs text-gray-500">{body.id}</div>
</div>
<Tag color="blue">{body.type}</Tag>
</div>
{body.description && (
<div className="text-sm text-gray-600 mt-2">{body.description}</div>
)}
{body.extra_data && (
<div className="text-xs text-gray-500 mt-2 grid grid-cols-3 gap-2">
{body.extra_data.semi_major_axis_au && <div>: {body.extra_data.semi_major_axis_au.toFixed(4)} AU</div>}
{body.extra_data.period_days && <div>: {body.extra_data.period_days.toFixed(2)} </div>}
{body.extra_data.radius_earth && <div>: {body.extra_data.radius_earth.toFixed(2)} R</div>}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
</div>
);
}

View File

@ -125,6 +125,7 @@ export function StaticData() {
{ text: '星云', value: 'nebula' },
{ text: '小行星带', value: 'asteroid_belt' },
{ text: '柯伊伯带', value: 'kuiper_belt' },
{ text: '恒星际', value: 'interstellar' },
],
onFilter: (value, record) => record.category === value,
},
@ -184,6 +185,7 @@ export function StaticData() {
<Select.Option value="nebula"> (Nebula)</Select.Option>
<Select.Option value="asteroid_belt"> (Asteroid Belt)</Select.Option>
<Select.Option value="kuiper_belt"> (Kuiper Belt)</Select.Option>
<Select.Option value="interstellar"> (Interstellar)</Select.Option>
</Select>
</Form.Item>

View File

@ -18,7 +18,22 @@ export interface CelestialBody {
type: CelestialBodyType;
positions: Position[];
description?: string;
details?: string; // Markdown content for detailed view
is_active?: boolean; // Probe status: true = active, false = inactive
// Star system specific data
starSystemData?: {
system_id: number;
host_star_name: string;
distance_pc?: number;
distance_ly?: number;
spectral_type?: string;
radius_solar?: number;
mass_solar?: number;
temperature_k?: number;
planet_count?: number;
planets?: any[]; // Array of planet data
color?: string; // Star color from database
};
}
export interface CelestialDataResponse {