diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index db02ddb..bd10dab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ * Cosmo - Deep Space Explorer * Main application component */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSpaceData } from './hooks/useSpaceData'; import { useHistoricalData } from './hooks/useHistoricalData'; @@ -15,19 +15,56 @@ import { TimelineController } from './components/TimelineController'; import { Loading } from './components/Loading'; import { InterstellarTicker } from './components/InterstellarTicker'; import { ControlPanel } from './components/ControlPanel'; +import { AuthModal } from './components/AuthModal'; +import { auth } from './utils/auth'; import type { CelestialBody } from './types'; // Timeline configuration - will be fetched from backend later const TIMELINE_DAYS = 30; // Total days in timeline range +const PREFS_KEY = 'cosmo_preferences'; + function App() { const navigate = useNavigate(); - const [selectedDate, setSelectedDate] = useState(null); - const [isTimelineMode, setIsTimelineMode] = useState(false); + + // Load preferences + const [isTimelineMode, setIsTimelineMode] = useState(false); // Usually not persisted const [showOrbits, setShowOrbits] = useState(true); const [isSoundOn, setIsSoundOn] = useState(false); const [showDanmaku, setShowDanmaku] = useState(true); + + // Initialize state from 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); + if (prefs.showDanmaku !== undefined) setShowDanmaku(prefs.showDanmaku); + } catch (e) { + console.error('Failed to parse preferences:', e); + } + } + }, []); + + // Persist preferences + useEffect(() => { + const prefs = { + showOrbits, + isSoundOn, + showDanmaku + }; + localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); + }, [showOrbits, isSoundOn, showDanmaku]); + + const [selectedDate, setSelectedDate] = useState(null); const { takeScreenshot } = useScreenshot(); + const [resetTrigger, setResetTrigger] = useState(0); + + // Auth state + const [user, setUser] = useState(auth.getUser()); + const [showAuthModal, setShowAuthModal] = useState(false); // Use real-time data or historical data based on mode const { bodies: realTimeBodies, loading: realTimeLoading, error: realTimeError } = useSpaceData(); @@ -66,6 +103,17 @@ function App() { setSelectedBody(body); }; + // Auth handlers + const handleLoginSuccess = (userData: any) => { + setUser(userData); + setShowAuthModal(false); + }; + + const handleLogout = () => { + auth.logout(); + setUser(null); + }; + // 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) { @@ -88,10 +136,14 @@ function App() { return (
- {/* Header with simplified branding */} + {/* Header with simplified branding and User Auth */}
setShowAuthModal(true)} + onLogout={handleLogout} + onNavigateToAdmin={() => navigate('/admin')} /> {/* Right Control Panel */} @@ -104,16 +156,23 @@ function App() { onToggleSound={() => setIsSoundOn(!isSoundOn)} showDanmaku={showDanmaku} onToggleDanmaku={() => setShowDanmaku(!showDanmaku)} - onLogin={() => navigate('/login')} onScreenshot={takeScreenshot} /> + {/* Auth Modal */} + setShowAuthModal(false)} + onLoginSuccess={handleLoginSuccess} + /> + {/* Probe List Sidebar */} setResetTrigger(prev => prev + 1)} /> {/* 3D Scene */} @@ -123,6 +182,7 @@ function App() { trajectoryPositions={trajectoryPositions} showOrbits={showOrbits} onBodySelect={handleBodySelect} + resetTrigger={resetTrigger} /> {/* Timeline Controller */} diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 1253da5..1cf9479 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -6,7 +6,10 @@ import { Login } from './pages/Login'; import { AdminLayout } from './pages/admin/AdminLayout'; import { Dashboard } from './pages/admin/Dashboard'; import { CelestialBodies } from './pages/admin/CelestialBodies'; +import { StaticData } from './pages/admin/StaticData'; +import { Users } from './pages/admin/Users'; import { auth } from './utils/auth'; +import { ToastProvider } from './contexts/ToastContext'; import App from './App'; // Protected Route wrapper @@ -19,33 +22,36 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { export function Router() { return ( - - - {/* Public routes */} - } /> + + + + {/* Public routes */} + } /> - {/* Main app (3D visualization) */} - } /> + {/* Main app (3D visualization) */} + } /> - {/* Admin routes (protected) */} - - - - } - > - } /> - } /> - } /> -

静态数据列表

开发中...

} /> -

NASA数据下载管理

开发中...

} /> -
+ {/* Admin routes (protected) */} + + + + } + > + } /> + } /> + } /> + } /> + } /> +

NASA数据下载管理

开发中...

} /> +
- {/* Fallback */} - } /> - - + {/* Fallback */} + } /> + + + ); } diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx new file mode 100644 index 0000000..c72649a --- /dev/null +++ b/frontend/src/components/AuthModal.tsx @@ -0,0 +1,212 @@ +import { useState, useEffect } from 'react'; +import { X, User, Lock, Mail, Eye, EyeOff } from 'lucide-react'; +import { login, register } from '../utils/api'; +import { auth } from '../utils/auth'; +import { useToast } from '../contexts/ToastContext'; + +interface AuthModalProps { + isOpen: boolean; + onClose: () => void; + onLoginSuccess: (user: any) => void; +} + +export function AuthModal({ isOpen, onClose, onLoginSuccess }: AuthModalProps) { + const [isLogin, setIsLogin] = useState(true); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const toast = useToast(); + + const [formData, setFormData] = useState({ + username: '', + password: '', + email: '', + fullName: '' + }); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setFormData({ + username: '', + password: '', + email: '', + fullName: '' + }); + setIsLogin(true); + setShowPassword(false); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + let data; + if (isLogin) { + data = await login(formData.username, formData.password); + } else { + data = await register( + formData.username, + formData.password, + formData.email || undefined, + formData.fullName || undefined + ); + } + + // Store token and user info + auth.setToken(data.access_token); + auth.setUser(data.user); + + toast.success(isLogin ? '登录成功!' : '注册成功!正在为您登录...'); + + onLoginSuccess(data.user); + onClose(); + + } catch (err: any) { + console.error('Auth error:', err); + const msg = err.response?.data?.detail || '操作失败,请重试'; + const errorMsg = typeof msg === 'string' ? msg : JSON.stringify(msg); + toast.error(errorMsg); + } finally { + setLoading(false); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }); + }; + + const toggleMode = () => { + setIsLogin(!isLogin); + }; + + return ( +
+
e.stopPropagation()} // Prevent closing when clicking inside + > + {/* Header */} +
+ +

+ {isLogin ? '欢迎回来' : '创建账号'} +

+

+ Cosmo - Deep Space Explorer +

+
+ + {/* Form */} +
+
+ +
+ + +
+
+ + {!isLogin && ( + <> +
+ +
+ + +
+
+
+ +
+ + +
+
+ + )} + +
+ +
+ + + +
+
+ + +
+ + {/* Footer */} +
+

+ {isLogin ? '还没有账号?' : '已有账号?'} + +

+
+
+
+ ); +} diff --git a/frontend/src/components/CameraController.tsx b/frontend/src/components/CameraController.tsx index 8227531..32b7927 100644 --- a/frontend/src/components/CameraController.tsx +++ b/frontend/src/components/CameraController.tsx @@ -11,72 +11,64 @@ interface CameraControllerProps { focusTarget: CelestialBody | null; allBodies: CelestialBody[]; onAnimationComplete?: () => void; + resetTrigger?: number; } -export function CameraController({ focusTarget, allBodies, onAnimationComplete }: CameraControllerProps) { +export function CameraController({ focusTarget, allBodies, onAnimationComplete, resetTrigger = 0 }: CameraControllerProps) { const { camera } = useThree(); const targetPosition = useRef(new Vector3()); const isAnimating = useRef(false); const animationProgress = useRef(0); const startPosition = useRef(new Vector3()); - - // Track previous focus to detect changes vs updates - const lastFocusId = useRef(null); - const lastTargetPos = useRef(null); + const lastResetTrigger = useRef(0); + // Handle manual reset trigger + useEffect(() => { + if (resetTrigger !== lastResetTrigger.current) { + lastResetTrigger.current = resetTrigger; + // Force reset + targetPosition.current.set(25, 20, 25); + startPosition.current.copy(camera.position); + isAnimating.current = true; + animationProgress.current = 0; + } + }, [resetTrigger, camera]); // Only run when resetTrigger changes + + // Handle focus target changes useEffect(() => { if (focusTarget) { // Focus on target - use smart rendered position const renderPos = calculateRenderPosition(focusTarget, allBodies); - const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y); // Note: Y/Z swap in rendering + const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y); - // Check if we are just updating positions for the same body - if (lastFocusId.current === focusTarget.id && lastTargetPos.current) { - // Same body, position changed (timeline playing) - // Move camera by the same delta to maintain relative view - const delta = new Vector3().subVectors(currentTargetPos, lastTargetPos.current); - camera.position.add(delta); - - // Update tracking ref - lastTargetPos.current.copy(currentTargetPos); - return; // Skip animation and reset logic - } - - // New target or first load: Calculate ideal camera position + // Calculate ideal camera position based on target const pos = focusTarget.positions[0]; const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2); const parentInfo = findParentPlanet(focusTarget, allBodies); - // Calculate camera position based on target type and context let offset: number; - let heightMultiplier = 1; // For adjusting vertical position - let sideMultiplier = 1; // For adjusting horizontal offset + let heightMultiplier = 1; + let sideMultiplier = 1; if (focusTarget.type === 'planet') { - // For planets, use closer view from above offset = 4; heightMultiplier = 1.5; sideMultiplier = 1; } else if (focusTarget.type === 'probe') { - // For probes, determine view based on context if (parentInfo) { - // Probe near a planet - use closer view to see both probe and planet - offset = 3; // Closer view (was 5) + offset = 3; heightMultiplier = 0.8; sideMultiplier = 1.2; } else if (distance < 10) { - // Inner solar system probe (not near a planet) - offset = 5; // Closer view (was 8) + offset = 5; heightMultiplier = 0.6; sideMultiplier = 1.5; } else if (distance > 50) { - // Far probes (Voyagers, New Horizons) - need even closer view since they're so far - offset = 4; // Much closer (was 12) + offset = 4; heightMultiplier = 0.8; sideMultiplier = 1; } else { - // Medium distance probes - offset = 6; // Closer view (was 10) + offset = 6; heightMultiplier = 0.8; sideMultiplier = 1.2; } @@ -86,65 +78,27 @@ export function CameraController({ focusTarget, allBodies, onAnimationComplete } sideMultiplier = 1; } - targetPosition.current.set( - currentTargetPos.x + (offset * sideMultiplier), - currentTargetPos.y + (offset * heightMultiplier), // already swapped in currentTargetPos? No, currentTargetPos is X, Z, Y world coords. - // Wait, let's look at the original code logic carefully. - // Original: scaledPos.x + offset, scaledPos.z + offset, scaledPos.y + offset - // currentTargetPos is created as (renderPos.x, renderPos.z, renderPos.y). - // In Three.js scene: x=x, y=z(up), z=y(depth) usually? - // Let's verify Scene coordinate system. - // Scene.tsx: controlsTarget = [scaledPos.x, scaledPos.z, scaledPos.y] - // So Y is up in Threejs, but Z is Up in data? - // renderPos (from calculateRenderPosition) usually returns data coords. - // let's stick to the exact mapping used in the original code. - // Original: - // targetPosition.current.set( - // scaledPos.x + (offset * sideMultiplier), - // scaledPos.z + (offset * heightMultiplier), - // scaledPos.y + offset - // ); - // So: X_cam = X_data + off - // Y_cam = Z_data + off <-- Y is up in ThreeJS, so Z_data is mapped to Y - // Z_cam = Y_data + off - currentTargetPos.y + (offset * heightMultiplier), // Z_data is in .y of currentTargetPos because we did new Vector3(x, z, y) - currentTargetPos.z + offset // Y_data is in .z of currentTargetPos - ); - // Wait, my `currentTargetPos` construction `new Vector3(renderPos.x, renderPos.z, renderPos.y)` - // maps Data X->X, Data Z->Y, Data Y->Z. - // So currentTargetPos.x = Data X - // currentTargetPos.y = Data Z (Up) - // currentTargetPos.z = Data Y (Depth) - // This matches the Scene.tsx controlsTarget `[scaledPos.x, scaledPos.z, scaledPos.y]`. - - // So the calculation should be relative to currentTargetPos: targetPosition.current.set( currentTargetPos.x + (offset * sideMultiplier), currentTargetPos.y + (offset * heightMultiplier), currentTargetPos.z + offset ); - // Update tracking refs - lastFocusId.current = focusTarget.id; - if (!lastTargetPos.current) { - lastTargetPos.current = new Vector3(); - } - lastTargetPos.current.copy(currentTargetPos); - - // Start animation + // Start animation to target startPosition.current.copy(camera.position); isAnimating.current = true; animationProgress.current = 0; + } else { - // No target - stop any ongoing animation and reset tracking refs. - // Do NOT force camera to return to solar system overview. - // OrbitControls will maintain the current camera position. - if (isAnimating.current) { // If an animation was in progress, stop it. + // Target became null (e.g. info window closed) + // DO NOTHING here to preserve camera position + // Reset is handled by the other useEffect + + // Just stop any ongoing animation + if (isAnimating.current) { isAnimating.current = false; animationProgress.current = 0; } - lastFocusId.current = null; - lastTargetPos.current = null; } }, [focusTarget, allBodies, camera]); diff --git a/frontend/src/components/ControlPanel.tsx b/frontend/src/components/ControlPanel.tsx index 7aa5c2d..0b30c11 100644 --- a/frontend/src/components/ControlPanel.tsx +++ b/frontend/src/components/ControlPanel.tsx @@ -4,7 +4,6 @@ import { Volume2, VolumeX, MessageSquare, - LogIn, Eye, EyeOff, Camera @@ -19,7 +18,6 @@ interface ControlPanelProps { onToggleSound: () => void; showDanmaku: boolean; onToggleDanmaku: () => void; - onLogin: () => void; onScreenshot: () => void; } @@ -32,8 +30,7 @@ export function ControlPanel({ onToggleSound, showDanmaku, onToggleDanmaku, - onLogin, - onScreenshot + onScreenshot, }: ControlPanelProps) { const buttonClass = (isActive: boolean) => ` p-2 rounded-lg transition-all duration-200 relative group @@ -46,7 +43,7 @@ export function ControlPanel({ const tooltipClass = "absolute right-full mr-2 top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50"; return ( -
+
{/* Timeline Toggle */} - - {/* Divider */} -
- - {/* Login Button */} -
); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index a756557..1fdc491 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,10 +1,26 @@ -/** - * Header component - simple branding - */ -export function Header({ selectedBodyName, bodyCount }: { selectedBodyName?: string, bodyCount: number }) { +import { UserAuth } from './UserAuth'; + +interface HeaderProps { + selectedBodyName?: string; + bodyCount: number; + user?: any; + onOpenAuth?: () => void; + onLogout?: () => void; + onNavigateToAdmin?: () => void; +} + +export function Header({ + selectedBodyName, + bodyCount, + user, + onOpenAuth, + onLogout, + onNavigateToAdmin +}: HeaderProps) { return (
-
+
+ {/* Left: Branding */}
@@ -31,6 +47,16 @@ export function Header({ selectedBodyName, bodyCount }: { selectedBodyName?: str

+ + {/* Right: User Auth */} + {onOpenAuth && ( + + )}
); diff --git a/frontend/src/components/InterstellarTicker.tsx b/frontend/src/components/InterstellarTicker.tsx index 09d5da6..baa9ea5 100644 --- a/frontend/src/components/InterstellarTicker.tsx +++ b/frontend/src/components/InterstellarTicker.tsx @@ -1,106 +1,41 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; interface InterstellarTickerProps { isPlaying: boolean; } export function InterstellarTicker({ isPlaying }: InterstellarTickerProps) { - const audioContextRef = useRef(null); - const timerRef = useRef(null); - const nextNoteTimeRef = useRef(0); + const audioRef = useRef(null); - // 初始化 AudioContext (必须在用户交互后触发) - const initAudio = useCallback(() => { - if (!audioContextRef.current) { - const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; - audioContextRef.current = new AudioContextClass(); - } - if (audioContextRef.current.state === 'suspended') { - audioContextRef.current.resume(); - } - }, []); - - // 播放单次滴答声 - const playTick = useCallback((time: number) => { - const ctx = audioContextRef.current; - if (!ctx) return; - - // 1. 创建振荡器 (声音源) - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - - // 2. 声音设计 - 模仿星际穿越的木鱼/秒表声 - // 使用正弦波,频率较高 - osc.type = 'sine'; - osc.frequency.setValueAtTime(880, time); // A5 音高,清脆 - - // 3. 包络 (Envolope) - 极短的冲击感 - // 0s: 静音 - gain.gain.setValueAtTime(0, time); - // 0.005s: 快速达到峰值 (Attack) - gain.gain.linearRampToValueAtTime(0.8, time + 0.005); - // 0.05s: 快速衰减 (Decay) - gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); - - // 4. 连接并播放 - osc.connect(gain); - gain.connect(ctx.destination); - - osc.start(time); - osc.stop(time + 0.1); - }, []); - - // 调度器 (Lookahead Scheduler) - const scheduler = useCallback(() => { - const ctx = audioContextRef.current; - if (!ctx) return; - - // 预读 0.1 秒 - const lookahead = 0.1; - const interval = 1.2; // 1.2 秒间隔 - - // 如果下一个音符的时间在当前时间 + 预读范围内,则调度它 - while (nextNoteTimeRef.current < ctx.currentTime + lookahead) { - playTick(nextNoteTimeRef.current); - nextNoteTimeRef.current += interval; - } - - timerRef.current = window.setTimeout(scheduler, 25); - }, [playTick]); - - // 监听 isPlaying 变化 useEffect(() => { - if (isPlaying) { - // 开启声音 - initAudio(); + if (!audioRef.current) { + // Initialize audio element + const protocol = window.location.protocol; + const hostname = window.location.hostname; + const port = import.meta.env.VITE_API_BASE_URL ? '' : ':8000'; + const audioUrl = `${protocol}//${hostname}${port}/upload/assets/tick_sample.m4a`; - // 重置调度时间 - if (audioContextRef.current) { - // 稍微延迟一点开始,避免切歌时的爆音 - nextNoteTimeRef.current = audioContextRef.current.currentTime + 0.1; - scheduler(); - } - } else { - // 关闭声音 - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } + const audio = new Audio(audioUrl); + audio.loop = true; // Enable looping + audio.volume = 0.5; // Set default volume + audioRef.current = audio; + } + + if (isPlaying) { + audioRef.current.play().catch(e => console.error("Audio play failed:", e)); + } else { + audioRef.current.pause(); + // Optional: reset time to 0 if you want it to start from beginning each time + // audioRef.current.currentTime = 0; } - }, [isPlaying, initAudio, scheduler]); - // 组件卸载时清理 - useEffect(() => { return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - if (audioContextRef.current) { - audioContextRef.current.close(); + // Cleanup not strictly necessary for singleton audio, but good practice if component unmounts + if (audioRef.current) { + audioRef.current.pause(); } }; - }, []); + }, [isPlaying]); - // 这是一个纯逻辑组件,不渲染任何 UI return null; } \ No newline at end of file diff --git a/frontend/src/components/Probe.tsx b/frontend/src/components/Probe.tsx index 989d8ce..dbcfb48 100644 --- a/frontend/src/components/Probe.tsx +++ b/frontend/src/components/Probe.tsx @@ -25,35 +25,24 @@ function ProbeModel({ body, modelPath, allBodies, onError }: { const groupRef = useRef(null); const position = body.positions[0]; - // Use smart render position calculation + // 1. Hook: Render Position const renderPosition = useMemo(() => { return calculateRenderPosition(body, allBodies); }, [position.x, position.y, position.z, body, allBodies]); const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z }; - // Load 3D model - must be at top level - // Add error handling and logging - let scene; - try { - const gltf = useGLTF(modelPath); - scene = gltf.scene; - console.log(`[ProbeModel ${body.name}] GLTF loaded successfully:`, { children: scene.children.length, modelPath }); - } catch (error) { - console.error(`[ProbeModel ${body.name}] Error loading GLTF:`, error); - // Call error callback and return null to trigger fallback - onError(); - return null; - } + // 2. Hook: Load GLTF + // We removed the try-catch block because calling hooks conditionally or inside try-catch is forbidden. + // If useGLTF fails, it will throw an error which should be caught by an ErrorBoundary or handled by Suspense. + // Since we preload in the parent, this should generally be safe. + const gltf = useGLTF(modelPath); + const scene = gltf.scene; - if (!scene || !scene.children || scene.children.length === 0) { - console.error(`[ProbeModel ${body.name}] GLTF scene is empty or invalid`); - onError(); - return null; - } - - // Calculate optimal scale based on model bounding box + // 3. Hook: Optimal Scale const optimalScale = useMemo(() => { + if (!scene) return 1; + // Calculate bounding box to determine model size const box = new THREE.Box3().setFromObject(scene); const size = new THREE.Vector3(); @@ -66,33 +55,23 @@ function ProbeModel({ body, modelPath, allBodies, onError }: { const targetSize = 0.5; // Target visual size in scene units // Calculate scale factor - // If model is very small, scale it up; if very large, scale it down const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.2; // Clamp scale to reasonable range const finalScale = Math.max(0.1, Math.min(2.0, calculatedScale)); - console.log(`[ProbeModel ${body.name}] Model dimensions:`, { - x: size.x.toFixed(3), - y: size.y.toFixed(3), - z: size.z.toFixed(3), - maxDimension: maxDimension.toFixed(3), - calculatedScale: calculatedScale.toFixed(3), - finalScale: finalScale.toFixed(3) - }); - return finalScale; }, [scene, body.name]); - // Configure model materials for proper rendering + // 4. Hook: Configured Scene const configuredScene = useMemo(() => { + if (!scene) return null; + const clonedScene = scene.clone(); clonedScene.traverse((child: any) => { if (child.isMesh) { - // Force proper depth testing and high render order child.renderOrder = 10000; if (child.material) { - // Clone material to avoid modifying shared materials if (Array.isArray(child.material)) { child.material = child.material.map((mat: any) => { const clonedMat = mat.clone(); @@ -119,13 +98,16 @@ function ProbeModel({ body, modelPath, allBodies, onError }: { return clonedScene; }, [scene]); - // Slow rotation for visual effect + // 5. Hook: Animation useFrame((_, delta) => { if (groupRef.current) { groupRef.current.rotation.y += delta * 0.2; } }); + // Render Logic + if (!scene || !configuredScene) return null; + // Calculate ACTUAL distance from Sun (not scaled) const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2); @@ -138,9 +120,8 @@ function ProbeModel({ body, modelPath, allBodies, onError }: { object={configuredScene} scale={optimalScale} /> - {/* Removed the semi-transparent sphere to avoid rendering conflicts */} - - {/* Name label - position based on model scale */} + + {/* Name label */} void; selectedBody: CelestialBody | null; + onResetCamera: () => void; } -export function ProbeList({ probes, planets, onBodySelect, selectedBody }: ProbeListProps) { +export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) { const [isCollapsed, setIsCollapsed] = useState(false); const [searchTerm, setSearchTerm] = useState(''); @@ -52,9 +53,12 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody }: Probe {/* Header & Search */}
-

天体导航

{/* text-lg -> text-base */} +

天体导航

+ ); + } + + return ( +
+ {/* Overlay for closing menu */} + {showUserMenu && ( +
setShowUserMenu(false)} + /> + )} + + + + {/* Dropdown Menu */} + {showUserMenu && ( +
+
+

账号信息

+

{user.full_name || user.username}

+

{user.email}

+
+ +
+ + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/DataTable.tsx b/frontend/src/components/admin/DataTable.tsx new file mode 100644 index 0000000..2e630e7 --- /dev/null +++ b/frontend/src/components/admin/DataTable.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { Table, Input, Button, Space, Popconfirm, Switch, Card, Tooltip } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, QuestionCircleOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; + +interface DataTableProps { + title?: string; + columns: ColumnsType; + dataSource: T[]; + loading?: boolean; + total?: number; + currentPage?: number; + pageSize?: number; + onPageChange?: (page: number, pageSize: number) => void; + onSearch?: (keyword: string) => void; + onAdd?: () => void; + onEdit?: (record: T) => void; + onDelete?: (record: T) => void; + onStatusChange?: (record: T, checked: boolean) => void; + statusField?: keyof T; // Field name for the status switch (e.g., 'is_active') + rowKey?: string; +} + +export function DataTable({ + title, + columns, + dataSource, + loading, + total, + currentPage = 1, + pageSize = 10, + onPageChange, + onSearch, + onAdd, + onEdit, + onDelete, + onStatusChange, + statusField = 'is_active' as keyof T, + rowKey = 'id', +}: DataTableProps) { + const [keyword, setKeyword] = useState(''); + + // Inject action columns if callbacks are provided + const tableColumns: ColumnsType = [ + ...columns, + ]; + + // Add status column if onStatusChange is provided + if (onStatusChange) { + tableColumns.push({ + title: '状态', + dataIndex: statusField as string, + key: 'status', + width: 100, + render: (value: boolean, record: T) => ( + onStatusChange(record, checked)} + size="small" + /> + ), + }); + } + + // Add operations column if onEdit or onDelete is provided + if (onEdit || onDelete) { + tableColumns.push({ + title: '操作', + key: 'action', + width: 150, + fixed: 'right', + render: (_, record) => ( + + {onEdit && ( + + + )} + + } + bodyStyle={{ padding: 0 }} + bordered={false} + > + `共 ${total} 条`, + } + : { + defaultPageSize: pageSize, + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, + } + } + scroll={{ x: 'max-content' }} + /> + + ); +} diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..229b9e9 --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,113 @@ +import { createContext, useContext, useState, useCallback, useRef } from 'react'; +import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'; + +// Types +type ToastType = 'success' | 'error' | 'warning' | 'info'; + +interface Toast { + id: string; + type: ToastType; + message: string; + duration?: number; +} + +interface ToastContextValue { + showToast: (message: string, type?: ToastType, duration?: number) => void; + success: (message: string, duration?: number) => void; + error: (message: string, duration?: number) => void; + warning: (message: string, duration?: number) => void; + info: (message: string, duration?: number) => void; +} + +// Context +const ToastContext = createContext(null); + +// Hook +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} + +// Icons map +const icons = { + success: , + error: , + warning: , + info: , +}; + +// Styles map +const styles = { + success: 'border-green-500/20 bg-green-900/90 text-green-100', + error: 'border-red-500/20 bg-red-900/90 text-red-100', + warning: 'border-amber-500/20 bg-amber-900/90 text-amber-100', + info: 'border-blue-500/20 bg-blue-900/90 text-blue-100', +}; + +// Provider Component +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef>(new Map()); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + if (timersRef.current.has(id)) { + clearTimeout(timersRef.current.get(id)); + timersRef.current.delete(id); + } + }, []); + + const showToast = useCallback((message: string, type: ToastType = 'info', duration = 3000) => { + const id = Math.random().toString(36).substring(2, 9); + const newToast: Toast = { id, type, message, duration }; + + setToasts((prev) => [...prev, newToast]); + + if (duration > 0) { + const timer = window.setTimeout(() => { + removeToast(id); + }, duration); + timersRef.current.set(id, timer); + } + }, [removeToast]); + + // Convenience methods + const success = useCallback((msg: string, d?: number) => showToast(msg, 'success', d), [showToast]); + const error = useCallback((msg: string, d?: number) => showToast(msg, 'error', d), [showToast]); + const warning = useCallback((msg: string, d?: number) => showToast(msg, 'warning', d), [showToast]); + const info = useCallback((msg: string, d?: number) => showToast(msg, 'info', d), [showToast]); + + return ( + + {children} + + {/* Toast Container - Top Right */} +
+ {toasts.map((toast) => ( +
+
{icons[toast.type]}
+

{toast.message}

+ +
+ ))} +
+
+ ); +} diff --git a/frontend/src/hooks/useSpaceData.ts b/frontend/src/hooks/useSpaceData.ts index 724adb2..ed1fffe 100644 --- a/frontend/src/hooks/useSpaceData.ts +++ b/frontend/src/hooks/useSpaceData.ts @@ -24,7 +24,7 @@ export function useSpaceData() { const data = await fetchCelestialPositions( now.toISOString(), now.toISOString(), // Same as start - single point in time - '1h' // 1 hour step (though doesn't matter for single point) + '1d' // Use 1d step for consistency ); setBodies(data.bodies); diff --git a/frontend/src/hooks/useTrajectory.ts b/frontend/src/hooks/useTrajectory.ts index 746212a..c290de6 100644 --- a/frontend/src/hooks/useTrajectory.ts +++ b/frontend/src/hooks/useTrajectory.ts @@ -22,8 +22,12 @@ export function useTrajectory(body: CelestialBody | null) { setLoading(true); // Fetch positions for the last 30 days - const endTime = new Date().toISOString(); - const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + // Align to UTC midnight to leverage caching + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + + const endTime = now.toISOString(); + const startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); const data = await fetchCelestialPositions(startTime, endTime, '1d'); diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx index 285a36b..658fae0 100644 --- a/frontend/src/pages/admin/AdminLayout.tsx +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -14,6 +14,9 @@ import { LogoutOutlined, RocketOutlined, AppstoreOutlined, + SettingOutlined, + TeamOutlined, + ControlOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; import { authAPI } from '../../utils/request'; @@ -28,6 +31,9 @@ const iconMap: Record = { planet: , data: , download: , + settings: , + users: , + sliders: , }; export function AdminLayout() { @@ -55,16 +61,16 @@ export function AdminLayout() { }; // Convert backend menu to Ant Design menu format - const convertMenus = (menus: any[]): MenuProps['items'] => { + const convertMenus = (menus: any[], isChild = false): MenuProps['items'] => { return menus.map((menu) => { const item: any = { key: menu.path || menu.name, - icon: iconMap[menu.icon || ''] || null, + icon: isChild ? null : (iconMap[menu.icon || ''] || null), label: menu.title, }; if (menu.children && menu.children.length > 0) { - item.children = convertMenus(menu.children); + item.children = convertMenus(menu.children, true); } return item; diff --git a/frontend/src/pages/admin/CelestialBodies.tsx b/frontend/src/pages/admin/CelestialBodies.tsx index 56272f9..4de540d 100644 --- a/frontend/src/pages/admin/CelestialBodies.tsx +++ b/frontend/src/pages/admin/CelestialBodies.tsx @@ -2,8 +2,9 @@ * Celestial Bodies Management Page */ import { useState, useEffect } from 'react'; -import { Table, Button, message } from 'antd'; +import { message, Modal, Form, Input, Select, Switch, InputNumber } from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import { DataTable } from '../../components/admin/DataTable'; import { request } from '../../utils/request'; interface CelestialBody { @@ -12,11 +13,16 @@ interface CelestialBody { name_zh: string; type: string; description: string; + is_active: boolean; } export function CelestialBodies() { const [loading, setLoading] = useState(false); const [data, setData] = useState([]); + const [filteredData, setFilteredData] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [form] = Form.useForm(); useEffect(() => { loadData(); @@ -27,6 +33,7 @@ export function CelestialBodies() { try { const { data: result } = await request.get('/celestial/list'); setData(result.bodies || []); + setFilteredData(result.bodies || []); } catch (error) { message.error('加载数据失败'); } finally { @@ -34,17 +41,97 @@ export function CelestialBodies() { } }; + // Search handler + const handleSearch = (keyword: string) => { + const lowerKeyword = keyword.toLowerCase(); + const filtered = data.filter( + (item) => + item.name.toLowerCase().includes(lowerKeyword) || + item.name_zh?.toLowerCase().includes(lowerKeyword) || + item.id.includes(lowerKeyword) + ); + setFilteredData(filtered); + }; + + // Add handler + const handleAdd = () => { + setEditingRecord(null); + form.resetFields(); + // Default values + form.setFieldsValue({ is_active: true, type: 'probe' }); + setIsModalOpen(true); + }; + + // Edit handler + const handleEdit = (record: CelestialBody) => { + setEditingRecord(record); + form.setFieldsValue(record); + setIsModalOpen(true); + }; + + // Delete handler + const handleDelete = async (record: CelestialBody) => { + try { + await request.delete(`/celestial/${record.id}`); + message.success('删除成功'); + loadData(); + } catch (error) { + message.error('删除失败'); + } + }; + + // Status change handler + const handleStatusChange = async (record: CelestialBody, checked: boolean) => { + try { + await request.put(`/celestial/${record.id}`, { is_active: checked }); + message.success(`状态更新成功`); + // Update local state to avoid full reload + const newData = data.map(item => + item.id === record.id ? { ...item, is_active: checked } : item + ); + setData(newData); + setFilteredData(newData); // Should re-filter if needed, but simplistic here + } catch (error) { + message.error('状态更新失败'); + } + }; + + // Form submit + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + + if (editingRecord) { + // Update + await request.put(`/celestial/${editingRecord.id}`, values); + message.success('更新成功'); + } else { + // Create + await request.post('/celestial/', values); + message.success('创建成功'); + } + + setIsModalOpen(false); + loadData(); + } catch (error) { + console.error(error); + // message.error('操作失败'); // request interceptor might already handle this + } + }; + const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', key: 'id', width: 100, + sorter: (a, b) => a.id.localeCompare(b.id), }, { title: '英文名', dataIndex: 'name', key: 'name', + sorter: (a, b) => a.name.localeCompare(b.name), }, { title: '中文名', @@ -55,11 +142,20 @@ export function CelestialBodies() { title: '类型', dataIndex: 'type', key: 'type', + filters: [ + { text: '行星', value: 'planet' }, + { text: '恒星', value: 'star' }, + { text: '卫星', value: 'satellite' }, + { text: '探测器', value: 'probe' }, + { text: '矮行星', value: 'dwarf_planet' }, + ], + onFilter: (value, record) => record.type === value, render: (type: string) => { const typeMap: Record = { star: '恒星', planet: '行星', dwarf_planet: '矮行星', + satellite: '卫星', probe: '探测器', }; return typeMap[type] || type; @@ -74,20 +170,86 @@ export function CelestialBodies() { ]; return ( -
-
-

天体数据列表

- -
-
+ - + + setIsModalOpen(false)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/admin/StaticData.tsx b/frontend/src/pages/admin/StaticData.tsx new file mode 100644 index 0000000..5d4dae9 --- /dev/null +++ b/frontend/src/pages/admin/StaticData.tsx @@ -0,0 +1,226 @@ +/** + * Static Data Management Page + */ +import { useState, useEffect } from 'react'; +import { message, Modal, Form, Input, Select } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { DataTable } from '../../components/admin/DataTable'; +import { request } from '../../utils/request'; + +interface StaticDataItem { + id: number; + category: string; + name: string; + name_zh: string; + data: any; +} + +export function StaticData() { + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [filteredData, setFilteredData] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [form] = Form.useForm(); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const { data: result } = await request.get('/celestial/static/list'); + setData(result.items || []); + setFilteredData(result.items || []); + } catch (error) { + message.error('加载数据失败'); + } finally { + setLoading(false); + } + }; + + const handleSearch = (keyword: string) => { + const lowerKeyword = keyword.toLowerCase(); + const filtered = data.filter( + (item) => + item.name.toLowerCase().includes(lowerKeyword) || + item.name_zh?.toLowerCase().includes(lowerKeyword) || + item.category.toLowerCase().includes(lowerKeyword) + ); + setFilteredData(filtered); + }; + + const handleAdd = () => { + setEditingRecord(null); + form.resetFields(); + form.setFieldsValue({ category: 'star' }); + setIsModalOpen(true); + }; + + const handleEdit = (record: StaticDataItem) => { + setEditingRecord(record); + // Convert JSON data to string for editing + form.setFieldsValue({ + ...record, + data: JSON.stringify(record.data, null, 2) + }); + setIsModalOpen(true); + }; + + const handleDelete = async (record: StaticDataItem) => { + try { + await request.delete(`/celestial/static/${record.id}`); + message.success('删除成功'); + loadData(); + } catch (error) { + message.error('删除失败'); + } + }; + + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + + // Parse JSON data + try { + values.data = JSON.parse(values.data); + } catch (e) { + message.error('JSON格式错误'); + return; + } + + if (editingRecord) { + await request.put(`/celestial/static/${editingRecord.id}`, values); + message.success('更新成功'); + } else { + await request.post('/celestial/static', values); + message.success('创建成功'); + } + + setIsModalOpen(false); + loadData(); + } catch (error) { + console.error(error); + } + }; + + const columns: ColumnsType = [ + { + title: 'ID', + dataIndex: 'id', + width: 80, + sorter: (a, b) => a.id - b.id, + }, + { + title: '分类', + dataIndex: 'category', + width: 120, + filters: [ + { text: '恒星', value: 'star' }, + { text: '星座', value: 'constellation' }, + { text: '星系', value: 'galaxy' }, + { text: '星云', value: 'nebula' }, + { text: '小行星带', value: 'asteroid_belt' }, + { text: '柯伊伯带', value: 'kuiper_belt' }, + ], + onFilter: (value, record) => record.category === value, + }, + { + title: '英文名', + dataIndex: 'name', + sorter: (a, b) => a.name.localeCompare(b.name), + }, + { + title: '中文名', + dataIndex: 'name_zh', + }, + { + title: '数据 (JSON)', + dataIndex: 'data', + ellipsis: true, + render: (text) => JSON.stringify(text), + }, + ]; + + return ( + <> + + + setIsModalOpen(false)} + width={700} + > +
+ + + + + + + + + + + + + { + try { + JSON.parse(value); + return Promise.resolve(); + } catch (err) { + return Promise.reject(new Error('无效的JSON格式')); + } + } + } + ]} + > + + + +
+ + ); +} diff --git a/frontend/src/pages/admin/Users.tsx b/frontend/src/pages/admin/Users.tsx new file mode 100644 index 0000000..e11fe74 --- /dev/null +++ b/frontend/src/pages/admin/Users.tsx @@ -0,0 +1,149 @@ +/** + * User Management Page + */ +import { useState, useEffect } from 'react'; +import { message, Modal, Button, Popconfirm } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { DataTable } from '../../components/admin/DataTable'; +import { request } from '../../utils/request'; +import { ReloadOutlined } from '@ant-design/icons'; + +interface UserItem { + id: number; + username: string; + full_name: string; + email: string; + is_active: boolean; + roles: string[]; + last_login_at: string; + created_at: string; +} + +export function Users() { + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [filteredData, setFilteredData] = useState([]); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const { data: result } = await request.get('/users/list'); + setData(result.users || []); + setFilteredData(result.users || []); + } catch (error) { + message.error('加载用户数据失败'); + } finally { + setLoading(false); + } + }; + + const handleSearch = (keyword: string) => { + const lowerKeyword = keyword.toLowerCase(); + const filtered = data.filter( + (item) => + item.username.toLowerCase().includes(lowerKeyword) || + item.full_name?.toLowerCase().includes(lowerKeyword) || + item.email?.toLowerCase().includes(lowerKeyword) + ); + setFilteredData(filtered); + }; + + const handleStatusChange = async (record: UserItem, checked: boolean) => { + try { + await request.put(`/users/${record.id}/status`, { is_active: checked }); + message.success(`用户 ${record.username} 状态更新成功`); + + const newData = data.map(item => + item.id === record.id ? { ...item, is_active: checked } : item + ); + setData(newData); + setFilteredData(newData); + } catch (error) { + message.error('状态更新失败'); + } + }; + + const handleResetPassword = async (record: UserItem) => { + try { + await request.post(`/users/${record.id}/reset-password`); + message.success(`用户 ${record.username} 密码已重置`); + } catch (error) { + message.error('密码重置失败'); + } + }; + + const columns: ColumnsType = [ + { + title: 'ID', + dataIndex: 'id', + width: 80, + sorter: (a, b) => a.id - b.id, + }, + { + title: '用户名', + dataIndex: 'username', + sorter: (a, b) => a.username.localeCompare(b.username), + }, + { + title: '姓名', + dataIndex: 'full_name', + }, + { + title: '邮箱', + dataIndex: 'email', + }, + { + title: '角色', + dataIndex: 'roles', + render: (roles: string[]) => roles.join(', '), + }, + { + title: '最近登录', + dataIndex: 'last_login_at', + render: (text) => text ? new Date(text).toLocaleString() : '从未', + }, + { + title: '注册时间', + dataIndex: 'created_at', + render: (text) => new Date(text).toLocaleDateString(), + }, + { + title: '操作', + key: 'action', + width: 120, + render: (_, record) => ( + handleResetPassword(record)} + okText="确认" + cancelText="取消" + > + + + ), + }, + ]; + + return ( + + ); +} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index dbd7b93..808cc45 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -3,6 +3,7 @@ */ import axios from 'axios'; import type { CelestialDataResponse, BodyInfo } from '../types'; +import { auth } from './auth'; // Dynamically determine the API base URL // If VITE_API_BASE_URL is set, use it; otherwise use the current host with port 8000 @@ -29,10 +30,17 @@ export const api = axios.create({ timeout: 120000, // Increase timeout to 120 seconds for historical data queries }); -// Add request interceptor for debugging +// Add request interceptor for debugging and auth api.interceptors.request.use( (config) => { console.log('[API Request]', config.method?.toUpperCase(), config.url, config.params); + + // Add token if available + const token = auth.getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; }, (error) => { @@ -138,3 +146,16 @@ export async function fetchBodyResources(bodyId: string, resourceType?: string): const response = await api.get(`/celestial/resources/${bodyId}`, { params }); return response.data; } + +/** + * Auth API methods + */ +export async function login(username: string, password: string): Promise { + const response = await api.post('/auth/login', { username, password }); + return response.data; +} + +export async function register(username: string, password: string, email?: string, full_name?: string): Promise { + const response = await api.post('/auth/register', { username, password, email, full_name }); + return response.data; +}