增加了用户头像
parent
5b8cc1cf0c
commit
ce855a378f
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 |
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue