diff --git a/.DS_Store b/.DS_Store index 178d40c..9ef2c6d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/DATABASE_SCHEMA.md b/backend/DATABASE_SCHEMA.md index 34603d4..0358703 100644 --- a/backend/DATABASE_SCHEMA.md +++ b/backend/DATABASE_SCHEMA.md @@ -437,6 +437,7 @@ CREATE TABLE users ( password_hash VARCHAR(255) NOT NULL, -- 密码哈希(bcrypt) email VARCHAR(255) UNIQUE, -- 邮箱地址 full_name VARCHAR(100), -- 全名 + avatar_url VARCHAR(255), -- 头像 is_active BOOLEAN DEFAULT TRUE NOT NULL, -- 账号状态 created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index cfe25b0..c693e61 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -47,6 +47,7 @@ class UserInfo(BaseModel): username: str email: str | None full_name: str | None + avatar_url: str | None roles: list[str] @@ -140,6 +141,7 @@ async def register( "username": new_user.username, "email": new_user.email, "full_name": new_user.full_name, + "avatar_url": new_user.avatar_url, "roles": [user_role.name] } ) @@ -202,6 +204,7 @@ async def login( "username": user.username, "email": user.email, "full_name": user.full_name, + "avatar_url": user.avatar_url, "roles": [role.name for role in user.roles] } ) @@ -219,6 +222,7 @@ async def get_current_user_info( username=current_user.username, email=current_user.email, full_name=current_user.full_name, + avatar_url=current_user.avatar_url, roles=[role.name for role in current_user.roles] ) diff --git a/backend/app/api/user.py b/backend/app/api/user.py index 40f47a0..cac7dc8 100644 --- a/backend/app/api/user.py +++ b/backend/app/api/user.py @@ -1,4 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import os +import aiofiles +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy import select, func @@ -135,6 +138,7 @@ async def get_current_user_profile( "username": current_user.username, "email": current_user.email, "full_name": current_user.full_name, + "avatar_url": current_user.avatar_url, "is_active": current_user.is_active, "roles": [role.name for role in current_user.roles], "created_at": current_user.created_at.isoformat(), @@ -178,7 +182,8 @@ async def update_current_user_profile( "id": current_user.id, "username": current_user.username, "email": current_user.email, - "full_name": current_user.full_name + "full_name": current_user.full_name, + "avatar_url": current_user.avatar_url } } @@ -218,3 +223,61 @@ async def change_current_user_password( return {"message": "Password changed successfully"} + +@router.post("/me/avatar") +async def upload_avatar( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Upload and update user avatar + Saved to: upload/user/{user_id}/avatar/ + """ + # Validate file type + if not file.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image" + ) + + # Create directory structure: upload/user/{user_id}/avatar/ + upload_dir = Path("upload") / "user" / str(current_user.id) / "avatar" + upload_dir.mkdir(parents=True, exist_ok=True) + + # Clean up old avatars if any + for old_file in upload_dir.glob("*"): + if old_file.is_file(): + os.remove(old_file) + + # Save new avatar + file_ext = os.path.splitext(file.filename)[1] + if not file_ext: + # Default to .png if no extension found + file_ext = ".png" + + filename = f"avatar{file_ext}" + file_path = upload_dir / filename + + try: + async with aiofiles.open(file_path, "wb") as f: + content = await file.read() + await f.write(content) + + # Update database with relative path + relative_path = f"user/{current_user.id}/avatar/{filename}" + current_user.avatar_url = relative_path + await db.commit() + await db.refresh(current_user) + + return { + "message": "Avatar uploaded successfully", + "avatar_url": relative_path + } + except Exception as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to upload avatar: {str(e)}" + ) + diff --git a/backend/app/models/db/user.py b/backend/app/models/db/user.py index cc2dc19..fee47da 100644 --- a/backend/app/models/db/user.py +++ b/backend/app/models/db/user.py @@ -31,6 +31,7 @@ class User(Base): created_at = Column(TIMESTAMP, server_default=func.now()) updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) last_login_at = Column(TIMESTAMP, nullable=True, comment="Last login time") + avatar_url = Column(String(255), nullable=True, comment="User avatar file path") # Relationships roles = relationship("Role", secondary=user_roles, back_populates="users") diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py index 6b94027..11f7672 100644 --- a/backend/app/services/token_service.py +++ b/backend/app/services/token_service.py @@ -82,7 +82,7 @@ class TokenService: await redis_cache.set( f"{self.blacklist_prefix}{token}", "1", - expire=ttl_seconds + ttl_seconds=ttl_seconds ) # Delete from active tokens diff --git a/backend/check_db_data.py b/backend/check_db_data.py deleted file mode 100644 index 6e59646..0000000 --- a/backend/check_db_data.py +++ /dev/null @@ -1,33 +0,0 @@ -import asyncio -import logging -import sys -import os - -# Add backend directory to path -sys.path.append(os.path.join(os.getcwd(), "backend")) - -from app.database import AsyncSessionLocal -from sqlalchemy import select -from app.models.db.celestial_body import CelestialBody - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -async def check_data(): - async with AsyncSessionLocal() as session: - # Check Earth and Jupiter - stmt = select(CelestialBody).where(CelestialBody.name.in_(['Earth', 'Jupiter'])) - result = await session.execute(stmt) - bodies = result.scalars().all() - - print(f"Found {len(bodies)} bodies.") - for body in bodies: - print(f"Body: {body.name} (ID: {body.id})") - print(f" Extra Data (Raw): {body.extra_data}") - if body.extra_data: - print(f" Real Radius: {body.extra_data.get('real_radius')}") - else: - print(" Extra Data is None/Empty") - -if __name__ == "__main__": - asyncio.run(check_data()) \ No newline at end of file diff --git a/backend/check_sirius.py b/backend/check_sirius.py deleted file mode 100644 index ea60479..0000000 --- a/backend/check_sirius.py +++ /dev/null @@ -1,31 +0,0 @@ -import asyncio -import sys -import os - -sys.path.append(os.path.join(os.getcwd(), "backend")) - -from app.database import AsyncSessionLocal -from sqlalchemy import select -from app.models.db.star_system import StarSystem -from app.models.db.celestial_body import CelestialBody - -async def check_sirius(): - async with AsyncSessionLocal() as session: - # Check Star Systems - print("Checking Star Systems:") - stmt = select(StarSystem).where(StarSystem.name.ilike('%Sirius%')) - result = await session.execute(stmt) - systems = result.scalars().all() - for s in systems: - print(f"System: {s.name} ({s.name_zh}), ID: {s.id}, Pos: {s.position_x}, {s.position_y}, {s.position_z}") - - # Check Celestial Bodies - print("\nChecking Celestial Bodies:") - stmt = select(CelestialBody).where(CelestialBody.name.ilike('%Sirius%')) - result = await session.execute(stmt) - bodies = result.scalars().all() - for b in bodies: - print(f"Body: {b.name} ({b.name_zh}), ID: {b.id}, Type: {b.type}, SystemID: {b.system_id}") - -if __name__ == "__main__": - asyncio.run(check_sirius()) diff --git a/backend/scripts/add_user_avatar_column.sql b/backend/scripts/add_user_avatar_column.sql new file mode 100644 index 0000000..b54a6ed --- /dev/null +++ b/backend/scripts/add_user_avatar_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url varchar(255) DEFAULT NULL; +COMMENT ON COLUMN users.avatar_url IS 'User avatar file path'; \ No newline at end of file diff --git a/backend/scripts/insert_sirius_system.sql b/backend/scripts/insert_sirius_system.sql new file mode 100644 index 0000000..c3ac1ff --- /dev/null +++ b/backend/scripts/insert_sirius_system.sql @@ -0,0 +1,93 @@ +BEGIN; + +-- 1. Insert or Update Sirius System +WITH new_system AS ( + INSERT INTO "public"."star_systems" ( + "name", + "name_zh", + "host_star_name", + "distance_pc", + "distance_ly", + "ra", + "dec", + "position_x", + "position_y", + "position_z", + "spectral_type", + "magnitude", + "color", + "description", + "planet_count" + ) VALUES ( + 'Sirius', + '天狼星', + 'Sirius A', + 2.64, + 8.6, + 101.287, + -16.716, + -0.495, + 2.479, + -0.759, + 'A1V', + -1.46, + '#FFFFFF', + '天狼星(Sirius,α CMa)是夜空中最亮的恒星,距离太阳系约 8.6 光年。它是一个联星系统,包含一颗蓝矮星(天狼星 A)和一颗白矮星(天狼星 B)。', + 0 + ) + ON CONFLICT (name) DO UPDATE SET + distance_pc = EXCLUDED.distance_pc, + distance_ly = EXCLUDED.distance_ly, + ra = EXCLUDED.ra, + dec = EXCLUDED.dec, + position_x = EXCLUDED.position_x, + position_y = EXCLUDED.position_y, + position_z = EXCLUDED.position_z, + spectral_type = EXCLUDED.spectral_type, + magnitude = EXCLUDED.magnitude, + color = EXCLUDED.color, + description = EXCLUDED.description + RETURNING id +) +-- 2. Insert Celestial Bodies (Sirius A and Sirius B) linked to the system +INSERT INTO "public"."celestial_bodies" ( + "id", + "name", + "name_zh", + "type", + "system_id", + "description", + "extra_data", + "is_active" +) +SELECT + 'sirius_a', + 'Sirius A', + '天狼星 A', + 'star', + id, + '天狼星 A 是天狼星系统的主星,是一颗光谱型 A1V 的蓝矮星,其质量约为太阳的 2 倍,光度约为太阳的 25 倍。', + '{"spectral_type": "A1V", "radius_solar": 1.71, "mass_solar": 2.06, "temperature_k": 9940}'::jsonb, + true +FROM new_system +UNION ALL +SELECT + 'sirius_b', + 'Sirius B', + '天狼星 B', + 'star', + id, + '天狼星 B 是天狼星 A 的伴星,是一颗微弱的白矮星。它是人类发现的第一颗白矮星,质量与太阳相当,但体积仅与地球相当。', + '{"spectral_type": "DA2", "radius_solar": 0.0084, "mass_solar": 1.02, "temperature_k": 25200}'::jsonb, + true +FROM new_system +ON CONFLICT (id) DO UPDATE SET + system_id = EXCLUDED.system_id, + name = EXCLUDED.name, + name_zh = EXCLUDED.name_zh, + type = EXCLUDED.type, + description = EXCLUDED.description, + extra_data = EXCLUDED.extra_data, + is_active = EXCLUDED.is_active; + +COMMIT; diff --git a/backend/test_import.py b/backend/test_import.py deleted file mode 100644 index e385205..0000000 --- a/backend/test_import.py +++ /dev/null @@ -1,12 +0,0 @@ - -import sys -import os -sys.path.append(os.path.join(os.getcwd(), "backend")) - -try: - from app.api import celestial_position - print("Import successful") -except Exception as e: - print(f"Import failed: {e}") - import traceback - traceback.print_exc() diff --git a/backend/upload/user/3/avatar/avatar.png b/backend/upload/user/3/avatar/avatar.png new file mode 100644 index 0000000..8f8b0e1 Binary files /dev/null and b/backend/upload/user/3/avatar/avatar.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e04649..b297dfb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -85,6 +85,18 @@ function App() { const [user, setUser] = useState(auth.getUser()); const [showAuthModal, setShowAuthModal] = useState(false); + // Sync auth state across tabs + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'cosmo_user') { + setUser(e.newValue ? JSON.parse(e.newValue) : null); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, []); + // Get data cutoff date const { cutoffDate } = useDataCutoffDate(); diff --git a/frontend/src/components/UserAuth.tsx b/frontend/src/components/UserAuth.tsx index 9e4ac2e..782170d 100644 --- a/frontend/src/components/UserAuth.tsx +++ b/frontend/src/components/UserAuth.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react'; +import { API_BASE_URL } from '../utils/request'; interface UserAuthProps { user: any; @@ -23,6 +24,13 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User ); } + // Helper to get full avatar URL + const getAvatarUrl = () => { + if (!user?.avatar_url) return null; + const rootUrl = API_BASE_URL.replace('/api', ''); + return `${rootUrl}/upload/${user.avatar_url}`; + }; + return (
{/* Overlay for closing menu */} @@ -37,8 +45,12 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User 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" > -
- +
+ {user.avatar_url ? ( + avatar + ) : ( + + )}
已登录 @@ -49,10 +61,18 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User {/* Dropdown Menu */} {showUserMenu && (
-
-

账号信息

-

{user.full_name || user.username}

-

{user.email}

+
+
+ {user.avatar_url ? ( + avatar + ) : ( + + )} +
+
+

{user.full_name || user.username}

+

{user.email}

+
diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx index 598b9f5..572d0f5 100644 --- a/frontend/src/pages/admin/AdminLayout.tsx +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -22,7 +22,7 @@ import { StarOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; -import { authAPI } from '../../utils/request'; +import { authAPI, API_BASE_URL } from '../../utils/request'; import { auth } from '../../utils/auth'; import { useToast } from '../../contexts/ToastContext'; @@ -46,11 +46,30 @@ export function AdminLayout() { const [collapsed, setCollapsed] = useState(false); const [menus, setMenus] = useState([]); const [loading, setLoading] = useState(true); + const [user, setUser] = useState(auth.getUser()); const navigate = useNavigate(); const location = useLocation(); - const user = auth.getUser(); const toast = useToast(); + // Sync user state + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'cosmo_user') { + setUser(e.newValue ? JSON.parse(e.newValue) : null); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, []); + + // Helper to get full avatar URL + const getAvatarUrl = () => { + if (!user?.avatar_url) return null; + const rootUrl = API_BASE_URL.replace('/api', ''); + return `${rootUrl}/upload/${user.avatar_url}`; + }; + // Load menus from backend useEffect(() => { loadMenus(); @@ -172,7 +191,7 @@ export function AdminLayout() {
- } style={{ marginRight: 8 }} /> + } style={{ marginRight: 8 }} /> {user?.username || 'User'}
diff --git a/frontend/src/pages/admin/UserProfile.tsx b/frontend/src/pages/admin/UserProfile.tsx index d194fe6..6c211c3 100644 --- a/frontend/src/pages/admin/UserProfile.tsx +++ b/frontend/src/pages/admin/UserProfile.tsx @@ -3,15 +3,16 @@ * 个人信息页面 */ import { useState, useEffect } from 'react'; -import { Form, Input, Button, Card, Avatar, Space, Descriptions, Row, Col } from 'antd'; -import { UserOutlined, MailOutlined, IdcardOutlined } from '@ant-design/icons'; -import { request } from '../../utils/request'; +import { Form, Input, Button, Card, Avatar, Space, Descriptions, Row, Col, Upload, message } from 'antd'; +import { UserOutlined, MailOutlined, IdcardOutlined, UploadOutlined } from '@ant-design/icons'; +import { request, API_BASE_URL } from '../../utils/request'; import { auth } from '../../utils/auth'; import { useToast } from '../../contexts/ToastContext'; export function UserProfile() { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); const [userProfile, setUserProfile] = useState(null); const toast = useToast(); const user = auth.getUser(); @@ -58,13 +59,66 @@ export function UserProfile() { } }; + const handleAvatarUpload = async (options: any) => { + const { file } = options; + const formData = new FormData(); + formData.append('file', file); + + setUploading(true); + try { + const { data } = await request.post('/users/me/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + toast.success('头像上传成功'); + + // Update local user info with new avatar URL + auth.setUser({ ...user, avatar_url: data.avatar_url }); + + // Reload profile to get new avatar URL from backend + await loadUserProfile(); + } catch (error: any) { + toast.error(error.response?.data?.detail || '头像上传失败'); + } finally { + setUploading(false); + } + }; + + // Construct full avatar URL + const getAvatarUrl = () => { + if (!userProfile?.avatar_url) return null; + // The backend returns a relative path like "user/1/avatar/avatar.png" + // The upload directory is mounted at /upload + const rootUrl = API_BASE_URL.replace('/api', ''); + return `${rootUrl}/upload/${userProfile.avatar_url}?t=${new Date().getTime()}`; + }; + return ( {/* User Avatar and Basic Info Card */}
- } /> +
+ } + /> +
+ + + +
+

{userProfile?.full_name || userProfile?.username || '用户'}

diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts index cc292a2..797519d 100644 --- a/frontend/src/utils/auth.ts +++ b/frontend/src/utils/auth.ts @@ -34,7 +34,15 @@ export const auth = { // Save user info to localStorage setUser(user: any): void { - localStorage.setItem(USER_KEY, JSON.stringify(user)); + const userStr = JSON.stringify(user); + localStorage.setItem(USER_KEY, userStr); + + // Manually dispatch storage event for the current window + // because window.addEventListener('storage') only fires for other tabs + window.dispatchEvent(new StorageEvent('storage', { + key: USER_KEY, + newValue: userStr + })); }, // Remove user info from localStorage