增加了用户头像
parent
5b8cc1cf0c
commit
ce855a378f
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="relative pointer-events-auto">
|
||||
{/* 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"
|
||||
>
|
||||
<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.avatar_url ? (
|
||||
<img src={getAvatarUrl()} alt="avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User size={16} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start text-xs">
|
||||
<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 */}
|
||||
{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>
|
||||
<div className="p-4 border-b border-white/10 bg-white/5 flex items-center gap-3">
|
||||
<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">
|
||||
{user.avatar_url ? (
|
||||
<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-xs text-gray-500 truncate">{user.email}</p>
|
||||
<p className="text-[10px] text-gray-500 truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-1">
|
||||
|
|
|
|||
|
|
@ -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<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<any>(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() {
|
|||
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<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>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
|
|||
|
|
@ -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<any>(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 (
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
{/* User Avatar and Basic Info Card */}
|
||||
<Card bordered={false} loading={loading}>
|
||||
<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 }}>
|
||||
{userProfile?.full_name || userProfile?.username || '用户'}
|
||||
</h2>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue