增加了用户头像

main
mula.liu 2026-01-18 10:54:03 +08:00
parent 5b8cc1cf0c
commit ce855a378f
17 changed files with 294 additions and 93 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -437,6 +437,7 @@ CREATE TABLE users (
password_hash VARCHAR(255) NOT NULL, -- 密码哈希bcrypt password_hash VARCHAR(255) NOT NULL, -- 密码哈希bcrypt
email VARCHAR(255) UNIQUE, -- 邮箱地址 email VARCHAR(255) UNIQUE, -- 邮箱地址
full_name VARCHAR(100), -- 全名 full_name VARCHAR(100), -- 全名
avatar_url VARCHAR(255), -- 头像
is_active BOOLEAN DEFAULT TRUE NOT NULL, -- 账号状态 is_active BOOLEAN DEFAULT TRUE NOT NULL, -- 账号状态
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(),

View File

@ -47,6 +47,7 @@ class UserInfo(BaseModel):
username: str username: str
email: str | None email: str | None
full_name: str | None full_name: str | None
avatar_url: str | None
roles: list[str] roles: list[str]
@ -140,6 +141,7 @@ async def register(
"username": new_user.username, "username": new_user.username,
"email": new_user.email, "email": new_user.email,
"full_name": new_user.full_name, "full_name": new_user.full_name,
"avatar_url": new_user.avatar_url,
"roles": [user_role.name] "roles": [user_role.name]
} }
) )
@ -202,6 +204,7 @@ async def login(
"username": user.username, "username": user.username,
"email": user.email, "email": user.email,
"full_name": user.full_name, "full_name": user.full_name,
"avatar_url": user.avatar_url,
"roles": [role.name for role in user.roles] "roles": [role.name for role in user.roles]
} }
) )
@ -219,6 +222,7 @@ async def get_current_user_info(
username=current_user.username, username=current_user.username,
email=current_user.email, email=current_user.email,
full_name=current_user.full_name, full_name=current_user.full_name,
avatar_url=current_user.avatar_url,
roles=[role.name for role in current_user.roles] roles=[role.name for role in current_user.roles]
) )

View File

@ -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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy import select, func from sqlalchemy import select, func
@ -135,6 +138,7 @@ async def get_current_user_profile(
"username": current_user.username, "username": current_user.username,
"email": current_user.email, "email": current_user.email,
"full_name": current_user.full_name, "full_name": current_user.full_name,
"avatar_url": current_user.avatar_url,
"is_active": current_user.is_active, "is_active": current_user.is_active,
"roles": [role.name for role in current_user.roles], "roles": [role.name for role in current_user.roles],
"created_at": current_user.created_at.isoformat(), "created_at": current_user.created_at.isoformat(),
@ -178,7 +182,8 @@ async def update_current_user_profile(
"id": current_user.id, "id": current_user.id,
"username": current_user.username, "username": current_user.username,
"email": current_user.email, "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"} 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)}"
)

View File

@ -31,6 +31,7 @@ class User(Base):
created_at = Column(TIMESTAMP, server_default=func.now()) created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
last_login_at = Column(TIMESTAMP, nullable=True, comment="Last login time") last_login_at = Column(TIMESTAMP, nullable=True, comment="Last login time")
avatar_url = Column(String(255), nullable=True, comment="User avatar file path")
# Relationships # Relationships
roles = relationship("Role", secondary=user_roles, back_populates="users") roles = relationship("Role", secondary=user_roles, back_populates="users")

View File

@ -82,7 +82,7 @@ class TokenService:
await redis_cache.set( await redis_cache.set(
f"{self.blacklist_prefix}{token}", f"{self.blacklist_prefix}{token}",
"1", "1",
expire=ttl_seconds ttl_seconds=ttl_seconds
) )
# Delete from active tokens # Delete from active tokens

View File

@ -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())

View File

@ -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())

View File

@ -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';

View File

@ -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;

View File

@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -85,6 +85,18 @@ function App() {
const [user, setUser] = useState<any>(auth.getUser()); const [user, setUser] = useState<any>(auth.getUser());
const [showAuthModal, setShowAuthModal] = useState(false); 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 // Get data cutoff date
const { cutoffDate } = useDataCutoffDate(); const { cutoffDate } = useDataCutoffDate();

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react'; import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react';
import { API_BASE_URL } from '../utils/request';
interface UserAuthProps { interface UserAuthProps {
user: any; 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 ( return (
<div className="relative pointer-events-auto"> <div className="relative pointer-events-auto">
{/* Overlay for closing menu */} {/* Overlay for closing menu */}
@ -37,8 +45,12 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
onClick={() => setShowUserMenu(!showUserMenu)} 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" 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"> <div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-inner">
<User size={16} /> {user.avatar_url ? (
<img src={getAvatarUrl()} alt="avatar" className="w-full h-full object-cover" />
) : (
<User size={16} />
)}
</div> </div>
<div className="flex flex-col items-start text-xs"> <div className="flex flex-col items-start text-xs">
<span className="text-gray-400 leading-none mb-0.5"></span> <span className="text-gray-400 leading-none mb-0.5"></span>
@ -49,10 +61,18 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
{/* Dropdown Menu */} {/* Dropdown Menu */}
{showUserMenu && ( {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="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"> <div className="p-4 border-b border-white/10 bg-white/5 flex items-center gap-3">
<p className="text-xs text-gray-400 mb-1"></p> <div className="w-10 h-10 rounded-full overflow-hidden bg-gradient-to-br from-blue-500 to-purple-600 flex-shrink-0 flex items-center justify-center">
<p className="text-sm font-bold text-white truncate">{user.full_name || user.username}</p> {user.avatar_url ? (
<p className="text-xs text-gray-500 truncate">{user.email}</p> <img src={getAvatarUrl()} alt="avatar" className="w-full h-full object-cover" />
) : (
<User size={20} className="text-white" />
)}
</div>
<div className="overflow-hidden">
<p className="text-sm font-bold text-white truncate">{user.full_name || user.username}</p>
<p className="text-[10px] text-gray-500 truncate">{user.email}</p>
</div>
</div> </div>
<div className="p-1"> <div className="p-1">

View File

@ -22,7 +22,7 @@ import {
StarOutlined, StarOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { authAPI } from '../../utils/request'; import { authAPI, API_BASE_URL } from '../../utils/request';
import { auth } from '../../utils/auth'; import { auth } from '../../utils/auth';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
@ -46,11 +46,30 @@ export function AdminLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [menus, setMenus] = useState<any[]>([]); const [menus, setMenus] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [user, setUser] = useState<any>(auth.getUser());
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = auth.getUser();
const toast = useToast(); 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 // Load menus from backend
useEffect(() => { useEffect(() => {
loadMenus(); loadMenus();
@ -172,7 +191,7 @@ export function AdminLayout() {
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}> <div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} style={{ marginRight: 8 }} /> <Avatar src={getAvatarUrl()} icon={<UserOutlined />} style={{ marginRight: 8 }} />
<span>{user?.username || 'User'}</span> <span>{user?.username || 'User'}</span>
</div> </div>
</Dropdown> </Dropdown>

View File

@ -3,15 +3,16 @@
* *
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Form, Input, Button, Card, Avatar, Space, Descriptions, Row, Col } from 'antd'; import { Form, Input, Button, Card, Avatar, Space, Descriptions, Row, Col, Upload, message } from 'antd';
import { UserOutlined, MailOutlined, IdcardOutlined } from '@ant-design/icons'; import { UserOutlined, MailOutlined, IdcardOutlined, UploadOutlined } from '@ant-design/icons';
import { request } from '../../utils/request'; import { request, API_BASE_URL } from '../../utils/request';
import { auth } from '../../utils/auth'; import { auth } from '../../utils/auth';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
export function UserProfile() { export function UserProfile() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [userProfile, setUserProfile] = useState<any>(null); const [userProfile, setUserProfile] = useState<any>(null);
const toast = useToast(); const toast = useToast();
const user = auth.getUser(); 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 ( return (
<Row gutter={24}> <Row gutter={24}>
<Col span={8}> <Col span={8}>
{/* User Avatar and Basic Info Card */} {/* User Avatar and Basic Info Card */}
<Card bordered={false} loading={loading}> <Card bordered={false} loading={loading}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Avatar size={100} icon={<UserOutlined />} /> <div style={{ position: 'relative', display: 'inline-block' }}>
<Avatar
size={100}
src={getAvatarUrl()}
icon={<UserOutlined />}
/>
<div style={{ marginTop: 16 }}>
<Upload
name="avatar"
showUploadList={false}
customRequest={handleAvatarUpload}
accept="image/*"
>
<Button icon={<UploadOutlined />} size="small" loading={uploading}>
</Button>
</Upload>
</div>
</div>
<h2 style={{ marginTop: 24, marginBottom: 8 }}> <h2 style={{ marginTop: 24, marginBottom: 8 }}>
{userProfile?.full_name || userProfile?.username || '用户'} {userProfile?.full_name || userProfile?.username || '用户'}
</h2> </h2>

View File

@ -34,7 +34,15 @@ export const auth = {
// Save user info to localStorage // Save user info to localStorage
setUser(user: any): void { 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 // Remove user info from localStorage