0.9.9
parent
a1a7580d63
commit
79a780a4db
|
|
@ -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<Date | null>(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<Date | null>(null);
|
||||
const { takeScreenshot } = useScreenshot();
|
||||
const [resetTrigger, setResetTrigger] = useState(0);
|
||||
|
||||
// Auth state
|
||||
const [user, setUser] = useState<any>(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 (
|
||||
<div className="w-full h-full relative">
|
||||
{/* Header with simplified branding */}
|
||||
{/* Header with simplified branding and User Auth */}
|
||||
<Header
|
||||
bodyCount={bodies.length}
|
||||
selectedBodyName={selectedBody?.name}
|
||||
user={user}
|
||||
onOpenAuth={() => 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 */}
|
||||
<AuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
/>
|
||||
|
||||
{/* Probe List Sidebar */}
|
||||
<ProbeList
|
||||
probes={probes}
|
||||
planets={planets}
|
||||
onBodySelect={handleBodySelect}
|
||||
selectedBody={selectedBody}
|
||||
onResetCamera={() => setResetTrigger(prev => prev + 1)}
|
||||
/>
|
||||
|
||||
{/* 3D Scene */}
|
||||
|
|
@ -123,6 +182,7 @@ function App() {
|
|||
trajectoryPositions={trajectoryPositions}
|
||||
showOrbits={showOrbits}
|
||||
onBodySelect={handleBodySelect}
|
||||
resetTrigger={resetTrigger}
|
||||
/>
|
||||
|
||||
{/* Timeline Controller */}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Main app (3D visualization) */}
|
||||
<Route path="/" element={<App />} />
|
||||
{/* Main app (3D visualization) */}
|
||||
<Route path="/" element={<App />} />
|
||||
|
||||
{/* Admin routes (protected) */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="celestial-bodies" element={<CelestialBodies />} />
|
||||
<Route path="static-data" element={<div><h1>静态数据列表</h1><p>开发中...</p></div>} />
|
||||
<Route path="nasa-data" element={<div><h1>NASA数据下载管理</h1><p>开发中...</p></div>} />
|
||||
</Route>
|
||||
{/* Admin routes (protected) */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="celestial-bodies" element={<CelestialBodies />} />
|
||||
<Route path="static-data" element={<StaticData />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="nasa-data" element={<div><h1>NASA数据下载管理</h1><p>开发中...</p></div>} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
setIsLogin(!isLogin);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/90 backdrop-blur-xl p-4"
|
||||
onClick={onClose} // Close modal when clicking outside
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900/90 border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="relative p-6 pb-4 border-b border-white/10">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<h2 className="text-2xl font-bold text-white text-center">
|
||||
{isLogin ? '欢迎回来' : '创建账号'}
|
||||
</h2>
|
||||
<p className="text-gray-400 text-sm text-center mt-1">
|
||||
Cosmo - Deep Space Explorer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400 ml-1">用户名</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="Username"
|
||||
className="w-full bg-black/50 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-white focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400 ml-1">邮箱 (可选)</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Email"
|
||||
className="w-full bg-black/50 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-white focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400 ml-1">昵称 (可选)</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
name="fullName"
|
||||
type="text"
|
||||
value={formData.fullName}
|
||||
onChange={handleChange}
|
||||
placeholder="Full Name"
|
||||
className="w-full bg-black/50 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-white focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400 ml-1">密码</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="Password"
|
||||
className="w-full bg-black/50 border border-gray-700 rounded-lg pl-10 pr-10 py-2.5 text-white focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-all shadow-lg shadow-blue-600/20 disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||
>
|
||||
{loading ? '处理中...' : (isLogin ? '登录' : '注册并登录')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-white/5 border-t border-white/5 text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
{isLogin ? '还没有账号?' : '已有账号?'}
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className="ml-2 text-blue-400 hover:text-blue-300 font-medium transition-colors"
|
||||
>
|
||||
{isLogin ? '立即注册' : '去登录'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
const lastTargetPos = useRef<Vector3 | null>(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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="absolute top-20 right-6 z-40 flex flex-col gap-3">
|
||||
<div className="absolute top-24 right-6 z-40 flex flex-col gap-3 items-end">
|
||||
{/* Timeline Toggle */}
|
||||
<button
|
||||
onClick={onToggleTimeline}
|
||||
|
|
@ -101,20 +98,6 @@ export function ControlPanel({
|
|||
拍摄宇宙快照
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-white/10 w-full my-1"></div>
|
||||
|
||||
{/* Login Button */}
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="p-2 rounded-lg bg-white/5 text-gray-300 hover:bg-white/10 border border-white/5 transition-all duration-200 relative group"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
<div className={tooltipClass}>
|
||||
管理员登录
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<header className="absolute top-0 left-0 right-0 z-50 pointer-events-none">
|
||||
<div className="px-6 py-4 bg-gradient-to-b from-black/90 via-black/60 to-transparent">
|
||||
<div className="px-6 py-4 bg-gradient-to-b from-black/90 via-black/60 to-transparent flex items-start justify-between">
|
||||
{/* Left: Branding */}
|
||||
<div className="flex items-center gap-4 pointer-events-auto inline-flex">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
|
|
@ -31,6 +47,16 @@ export function Header({ selectedBodyName, bodyCount }: { selectedBodyName?: str
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: User Auth */}
|
||||
{onOpenAuth && (
|
||||
<UserAuth
|
||||
user={user}
|
||||
onOpenAuth={onOpenAuth}
|
||||
onLogout={onLogout!}
|
||||
onNavigateToAdmin={onNavigateToAdmin!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<AudioContext | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const nextNoteTimeRef = useRef<number>(0);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(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;
|
||||
}
|
||||
|
|
@ -25,35 +25,24 @@ function ProbeModel({ body, modelPath, allBodies, onError }: {
|
|||
const groupRef = useRef<Group>(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 */}
|
||||
<Html
|
||||
position={[0, optimalScale * 2, 0]}
|
||||
center
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ interface ProbeListProps {
|
|||
planets: CelestialBody[];
|
||||
onBodySelect: (body: CelestialBody | null) => 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 */}
|
||||
<div className="p-4 border-b border-white/10 space-y-3">
|
||||
<div className="flex items-center justify-between text-white">
|
||||
<h2 className="font-bold text-base tracking-wide">天体导航</h2> {/* text-lg -> text-base */}
|
||||
<h2 className="font-bold text-base tracking-wide">天体导航</h2>
|
||||
<button
|
||||
onClick={() => onBodySelect(null)}
|
||||
onClick={() => {
|
||||
onBodySelect(null);
|
||||
onResetCamera();
|
||||
}}
|
||||
className="text-xs bg-white/10 hover:bg-white/20 px-2 py-1 rounded transition-colors text-gray-300"
|
||||
>
|
||||
重置视角
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ interface SceneProps {
|
|||
trajectoryPositions?: Position[];
|
||||
showOrbits?: boolean;
|
||||
onBodySelect?: (body: CelestialBodyType | null) => void;
|
||||
resetTrigger?: number;
|
||||
}
|
||||
|
||||
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect }: SceneProps) {
|
||||
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0 }: SceneProps) {
|
||||
// Separate natural celestial bodies (planets/dwarf planets/satellites/stars) from probes
|
||||
const celestialBodies = bodies.filter((b) => b.type !== 'probe');
|
||||
const probes = bodies.filter((b) => b.type === 'probe');
|
||||
|
|
@ -76,7 +77,11 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
}}
|
||||
>
|
||||
{/* Camera controller for smooth transitions */}
|
||||
<CameraController focusTarget={selectedBody} allBodies={bodies} />
|
||||
<CameraController
|
||||
focusTarget={selectedBody}
|
||||
allBodies={bodies}
|
||||
resetTrigger={resetTrigger}
|
||||
/>
|
||||
|
||||
{/* Increase ambient light to see textures better */}
|
||||
<ambientLight intensity={0.5} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { useState } from 'react';
|
||||
import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react';
|
||||
|
||||
interface UserAuthProps {
|
||||
user: any;
|
||||
onOpenAuth: () => void;
|
||||
onLogout: () => void;
|
||||
onNavigateToAdmin: () => void;
|
||||
}
|
||||
|
||||
export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: UserAuthProps) {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpenAuth}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-md border border-white/10 text-white font-medium text-sm transition-all duration-200 shadow-lg hover:shadow-blue-500/20 pointer-events-auto"
|
||||
>
|
||||
<LogIn size={16} />
|
||||
<span>登录 / 注册</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative pointer-events-auto">
|
||||
{/* Overlay for closing menu */}
|
||||
{showUserMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 pl-2 pr-4 py-1.5 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 text-white transition-all duration-200 relative z-50"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-inner">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start text-xs">
|
||||
<span className="text-gray-400 leading-none mb-0.5">已登录</span>
|
||||
<span className="font-bold leading-none max-w-[80px] truncate">{user.username}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute top-full right-0 mt-2 w-48 bg-gray-900/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200 z-50">
|
||||
<div className="p-4 border-b border-white/10 bg-white/5">
|
||||
<p className="text-xs text-gray-400 mb-1">账号信息</p>
|
||||
<p className="text-sm font-bold text-white truncate">{user.full_name || user.username}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Open admin in new tab
|
||||
window.open('/admin', '_blank');
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2.5 text-sm text-gray-200 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors rounded-lg"
|
||||
>
|
||||
<LayoutDashboard size={16} className="text-blue-400" />
|
||||
进入后台管理
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-white/10 my-1 mx-2"></div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2.5 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 flex items-center gap-3 transition-colors rounded-lg"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<T> {
|
||||
title?: string;
|
||||
columns: ColumnsType<T>;
|
||||
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<T extends object>({
|
||||
title,
|
||||
columns,
|
||||
dataSource,
|
||||
loading,
|
||||
total,
|
||||
currentPage = 1,
|
||||
pageSize = 10,
|
||||
onPageChange,
|
||||
onSearch,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
statusField = 'is_active' as keyof T,
|
||||
rowKey = 'id',
|
||||
}: DataTableProps<T>) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// Inject action columns if callbacks are provided
|
||||
const tableColumns: ColumnsType<T> = [
|
||||
...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) => (
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(checked) => 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) => (
|
||||
<Space size="middle">
|
||||
{onEdit && (
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
description="此操作无法撤销"
|
||||
onConfirm={() => onDelete(record)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
danger
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
extra={
|
||||
<Space>
|
||||
{onSearch && (
|
||||
<Input.Search
|
||||
placeholder="搜索..."
|
||||
allowClear
|
||||
onSearch={onSearch}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
if (!e.target.value) onSearch(''); // Auto search on clear
|
||||
}}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
)}
|
||||
{onAdd && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
新增
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={dataSource}
|
||||
loading={loading}
|
||||
rowKey={rowKey}
|
||||
pagination={
|
||||
onPageChange
|
||||
? {
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: onPageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}
|
||||
: {
|
||||
defaultPageSize: pageSize,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}
|
||||
}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ToastContextValue | null>(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: <CheckCircle size={20} className="text-green-400" />,
|
||||
error: <AlertCircle size={20} className="text-red-400" />,
|
||||
warning: <AlertTriangle size={20} className="text-amber-400" />,
|
||||
info: <Info size={20} className="text-blue-400" />,
|
||||
};
|
||||
|
||||
// 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<Toast[]>([]);
|
||||
const timersRef = useRef<Map<string, number>>(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 (
|
||||
<ToastContext.Provider value={{ showToast, success, error, warning, info }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container - Top Right */}
|
||||
<div className="fixed top-24 right-6 z-[100] flex flex-col gap-3 pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
pointer-events-auto
|
||||
flex items-start gap-3 px-4 py-3 rounded-xl border shadow-xl backdrop-blur-md
|
||||
min-w-[300px] max-w-sm
|
||||
animate-in slide-in-from-right-8 fade-in duration-300
|
||||
${styles[toast.type]}
|
||||
`}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">{icons[toast.type]}</div>
|
||||
<p className="flex-1 text-sm font-medium leading-tight pt-0.5">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-white/40 hover:text-white transition-colors shrink-0"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {
|
|||
planet: <RocketOutlined />,
|
||||
data: <DatabaseOutlined />,
|
||||
download: <DownloadOutlined />,
|
||||
settings: <SettingOutlined />,
|
||||
users: <TeamOutlined />,
|
||||
sliders: <ControlOutlined />,
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<CelestialBody[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<CelestialBody[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<CelestialBody | null>(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<CelestialBody> = [
|
||||
{
|
||||
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<string, string> = {
|
||||
star: '恒星',
|
||||
planet: '行星',
|
||||
dwarf_planet: '矮行星',
|
||||
satellite: '卫星',
|
||||
probe: '探测器',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
|
|
@ -74,20 +170,86 @@ export function CelestialBodies() {
|
|||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h1>天体数据列表</h1>
|
||||
<Button type="primary" onClick={loadData}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
<>
|
||||
<DataTable
|
||||
title="天体数据管理"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
total={filteredData.length}
|
||||
onSearch={handleSearch}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onStatusChange={handleStatusChange}
|
||||
statusField="is_active"
|
||||
rowKey="id"
|
||||
pageSize={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑天体' : '新增天体'}
|
||||
open={isModalOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="id"
|
||||
label="JPL ID"
|
||||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
||||
>
|
||||
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="英文名"
|
||||
rules={[{ required: true, message: '请输入英文名' }]}
|
||||
>
|
||||
<Input placeholder="例如:Voyager 1" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name_zh"
|
||||
label="中文名"
|
||||
>
|
||||
<Input placeholder="例如:旅行者1号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="planet">行星</Select.Option>
|
||||
<Select.Option value="dwarf_planet">矮行星</Select.Option>
|
||||
<Select.Option value="satellite">卫星</Select.Option>
|
||||
<Select.Option value="probe">探测器</Select.Option>
|
||||
<Select.Option value="star">恒星</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="is_active"
|
||||
label="状态"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<StaticDataItem[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<StaticDataItem[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<StaticDataItem | null>(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<StaticDataItem> = [
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<DataTable
|
||||
title="静态数据管理"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
total={filteredData.length}
|
||||
onSearch={handleSearch}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
rowKey="id"
|
||||
pageSize={10}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑静态数据' : '新增静态数据'}
|
||||
open={isModalOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={700}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="star">恒星 (Star)</Select.Option>
|
||||
<Select.Option value="constellation">星座 (Constellation)</Select.Option>
|
||||
<Select.Option value="galaxy">星系 (Galaxy)</Select.Option>
|
||||
<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>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="英文名"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name_zh"
|
||||
label="中文名"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="data"
|
||||
label="JSON数据"
|
||||
rules={[
|
||||
{ required: true, message: '请输入JSON数据' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return Promise.resolve();
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error('无效的JSON格式'));
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={15} className="font-mono" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<UserItem[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<UserItem[]>([]);
|
||||
|
||||
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<UserItem> = [
|
||||
{
|
||||
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) => (
|
||||
<Popconfirm
|
||||
title="确认重置密码?"
|
||||
description="密码将被重置为默认密码"
|
||||
onConfirm={() => handleResetPassword(record)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" icon={<ReloadOutlined />} size="small">
|
||||
重置密码
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
title="用户管理"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
total={filteredData.length}
|
||||
onSearch={handleSearch}
|
||||
onStatusChange={handleStatusChange}
|
||||
statusField="is_active"
|
||||
rowKey="id"
|
||||
pageSize={10}
|
||||
// No onAdd, No onDelete, No onEdit
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<any> {
|
||||
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<any> {
|
||||
const response = await api.post('/auth/register', { username, password, email, full_name });
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue