初步实现了三阶段
parent
f9f804cb5f
commit
3cdaaa2943
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue