2025-10-30 11:14:43 +08:00
|
|
|
import { useState, useEffect } from 'react';
|
2026-03-06 14:14:57 +08:00
|
|
|
import { Dropdown, Avatar, Space, Typography, message, Modal, Form, Input, Button, theme } from 'antd';
|
2025-11-13 11:43:45 +08:00
|
|
|
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined, LockOutlined } from '@ant-design/icons';
|
|
|
|
|
import { authApi } from '../services/api';
|
2025-10-30 11:14:43 +08:00
|
|
|
import type { User } from '../types';
|
|
|
|
|
import type { MenuProps } from 'antd';
|
2025-11-13 11:43:45 +08:00
|
|
|
import { useNavigate } from 'react-router-dom';
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
|
|
|
const { Text } = Typography;
|
|
|
|
|
|
2026-01-14 14:33:43 +08:00
|
|
|
interface UserMenuProps {
|
|
|
|
|
/** 是否总是显示完整信息(用于移动端侧边栏) */
|
|
|
|
|
showFullInfo?: boolean;
|
2026-03-06 14:14:57 +08:00
|
|
|
/** 紧凑模式(用于折叠侧边栏,仅展示头像) */
|
|
|
|
|
compact?: boolean;
|
2026-01-14 14:33:43 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 14:14:57 +08:00
|
|
|
export default function UserMenu({ showFullInfo = false, compact = false }: UserMenuProps) {
|
2025-11-13 11:43:45 +08:00
|
|
|
const navigate = useNavigate();
|
2025-10-30 11:14:43 +08:00
|
|
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
2025-11-11 19:50:12 +08:00
|
|
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
|
|
|
|
const [changePasswordForm] = Form.useForm();
|
|
|
|
|
const [changingPassword, setChangingPassword] = useState(false);
|
2026-03-06 14:14:57 +08:00
|
|
|
const { token } = theme.useToken();
|
|
|
|
|
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadCurrentUser();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadCurrentUser = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const user = await authApi.getCurrentUser();
|
|
|
|
|
setCurrentUser(user);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('获取用户信息失败:', error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLogout = async () => {
|
|
|
|
|
try {
|
|
|
|
|
await authApi.logout();
|
|
|
|
|
message.success('已退出登录');
|
|
|
|
|
window.location.href = '/login';
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('退出登录失败:', error);
|
|
|
|
|
message.error('退出登录失败');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-13 11:43:45 +08:00
|
|
|
const handleShowUserManagement = () => {
|
2025-10-30 11:14:43 +08:00
|
|
|
if (!currentUser?.is_admin) {
|
|
|
|
|
message.warning('只有管理员可以访问用户管理');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-13 11:43:45 +08:00
|
|
|
navigate('/user-management');
|
2025-11-12 11:16:32 +08:00
|
|
|
};
|
|
|
|
|
|
2025-11-11 19:50:12 +08:00
|
|
|
const handleChangePassword = async (values: { oldPassword: string; newPassword: string }) => {
|
|
|
|
|
try {
|
|
|
|
|
setChangingPassword(true);
|
|
|
|
|
await authApi.setPassword(values.newPassword);
|
|
|
|
|
message.success('密码修改成功');
|
|
|
|
|
setShowChangePassword(false);
|
|
|
|
|
changePasswordForm.resetFields();
|
2026-01-14 14:33:43 +08:00
|
|
|
} catch (error: unknown) {
|
2025-11-11 19:50:12 +08:00
|
|
|
console.error('修改密码失败:', error);
|
2026-01-14 14:33:43 +08:00
|
|
|
const err = error as { response?: { data?: { detail?: string } } };
|
|
|
|
|
message.error(err.response?.data?.detail || '修改密码失败');
|
2025-11-11 19:50:12 +08:00
|
|
|
} finally {
|
|
|
|
|
setChangingPassword(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
const menuItems: MenuProps['items'] = [
|
|
|
|
|
{
|
|
|
|
|
key: 'user-info',
|
|
|
|
|
label: (
|
|
|
|
|
<div style={{ padding: '8px 0' }}>
|
|
|
|
|
<Text strong>{currentUser?.display_name || currentUser?.username}</Text>
|
|
|
|
|
<br />
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
|
|
|
Trust Level: {currentUser?.trust_level}
|
|
|
|
|
{currentUser?.is_admin && ' · 管理员'}
|
|
|
|
|
</Text>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
disabled: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'divider',
|
|
|
|
|
},
|
2025-11-13 11:43:45 +08:00
|
|
|
...(currentUser?.is_admin ? [
|
|
|
|
|
{
|
|
|
|
|
key: 'user-management',
|
|
|
|
|
icon: <TeamOutlined />,
|
|
|
|
|
label: '用户管理',
|
|
|
|
|
onClick: handleShowUserManagement,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'divider' as const,
|
|
|
|
|
}
|
|
|
|
|
] : []),
|
2025-11-11 19:50:12 +08:00
|
|
|
{
|
|
|
|
|
key: 'change-password',
|
|
|
|
|
icon: <LockOutlined />,
|
|
|
|
|
label: '修改密码',
|
|
|
|
|
onClick: () => setShowChangePassword(true),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'divider',
|
|
|
|
|
},
|
2025-10-30 11:14:43 +08:00
|
|
|
{
|
|
|
|
|
key: 'logout',
|
|
|
|
|
icon: <LogoutOutlined />,
|
|
|
|
|
label: '退出登录',
|
|
|
|
|
onClick: handleLogout,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (!currentUser) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
2026-03-06 14:14:57 +08:00
|
|
|
gap: compact ? 0 : 12,
|
|
|
|
|
padding: compact ? '4px' : '8px 16px',
|
|
|
|
|
background: alphaColor(token.colorBgContainer, 0.65), // 保持半透明以配合 Backdrop
|
2025-10-30 11:14:43 +08:00
|
|
|
backdropFilter: 'blur(10px)',
|
|
|
|
|
WebkitBackdropFilter: 'blur(10px)',
|
2026-03-06 14:14:57 +08:00
|
|
|
borderRadius: compact ? 16 : 24,
|
|
|
|
|
border: `1px solid ${token.colorBorder}`,
|
2025-10-30 11:14:43 +08:00
|
|
|
transition: 'all 0.3s ease',
|
2026-03-06 14:14:57 +08:00
|
|
|
boxShadow: `0 8px 20px ${alphaColor(token.colorText, 0.08)}`,
|
2025-10-30 11:14:43 +08:00
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => {
|
2026-03-06 14:14:57 +08:00
|
|
|
e.currentTarget.style.background = token.colorBgContainer; // 悬浮时变实
|
2025-10-30 11:14:43 +08:00
|
|
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
2026-03-06 14:14:57 +08:00
|
|
|
e.currentTarget.style.boxShadow = `0 12px 28px ${alphaColor(token.colorText, 0.14)}`;
|
2025-10-30 11:14:43 +08:00
|
|
|
}}
|
|
|
|
|
onMouseLeave={(e) => {
|
2026-03-06 14:14:57 +08:00
|
|
|
e.currentTarget.style.background = alphaColor(token.colorBgContainer, 0.65);
|
2025-10-30 11:14:43 +08:00
|
|
|
e.currentTarget.style.transform = 'translateY(0)';
|
2026-03-06 14:14:57 +08:00
|
|
|
e.currentTarget.style.boxShadow = `0 8px 20px ${alphaColor(token.colorText, 0.08)}`;
|
2025-10-30 11:14:43 +08:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
<Avatar
|
|
|
|
|
src={currentUser.avatar_url}
|
|
|
|
|
icon={<UserOutlined />}
|
2026-03-06 14:14:57 +08:00
|
|
|
size={compact ? 32 : 40}
|
2025-10-30 11:14:43 +08:00
|
|
|
style={{
|
2026-03-06 14:14:57 +08:00
|
|
|
backgroundColor: token.colorPrimary,
|
|
|
|
|
border: `3px solid ${token.colorWhite}`,
|
|
|
|
|
boxShadow: `0 8px 20px ${alphaColor(token.colorText, 0.12)}`,
|
2025-10-30 11:14:43 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{currentUser.is_admin && (
|
|
|
|
|
<div style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: -2,
|
|
|
|
|
right: -2,
|
|
|
|
|
width: 18,
|
|
|
|
|
height: 18,
|
2026-03-06 14:14:57 +08:00
|
|
|
background: `linear-gradient(135deg, ${token.colorWarning} 0%, ${token.colorWarningHover} 100%)`,
|
2025-10-30 11:14:43 +08:00
|
|
|
borderRadius: '50%',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
2026-03-06 14:14:57 +08:00
|
|
|
border: `2px solid ${token.colorWhite}`,
|
|
|
|
|
boxShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`,
|
2025-10-30 11:14:43 +08:00
|
|
|
}}>
|
2026-03-06 14:14:57 +08:00
|
|
|
<CrownOutlined style={{ fontSize: 9, color: token.colorWhite }} />
|
2025-10-30 11:14:43 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-06 14:14:57 +08:00
|
|
|
<Space direction="vertical" size={0} style={{ display: compact ? 'none' : ((window.innerWidth <= 768 && !showFullInfo) ? 'none' : 'flex') }}>
|
2025-10-30 11:14:43 +08:00
|
|
|
<Text strong style={{
|
2026-03-06 14:14:57 +08:00
|
|
|
color: token.colorText,
|
2025-10-30 11:14:43 +08:00
|
|
|
fontSize: 14,
|
|
|
|
|
lineHeight: '20px',
|
|
|
|
|
}}>
|
|
|
|
|
{currentUser.display_name || currentUser.username}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text style={{
|
2026-03-06 14:14:57 +08:00
|
|
|
color: token.colorTextSecondary,
|
2025-10-30 11:14:43 +08:00
|
|
|
fontSize: 12,
|
|
|
|
|
lineHeight: '18px',
|
|
|
|
|
}}>
|
|
|
|
|
{currentUser.is_admin ? '👑 管理员' : `🎖️ Trust Level ${currentUser.trust_level}`}
|
|
|
|
|
</Text>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
2025-11-11 19:50:12 +08:00
|
|
|
<Modal
|
|
|
|
|
title="修改密码"
|
|
|
|
|
open={showChangePassword}
|
|
|
|
|
onCancel={() => {
|
|
|
|
|
setShowChangePassword(false);
|
|
|
|
|
changePasswordForm.resetFields();
|
|
|
|
|
}}
|
|
|
|
|
footer={null}
|
|
|
|
|
width={480}
|
|
|
|
|
centered
|
|
|
|
|
>
|
|
|
|
|
<Form
|
|
|
|
|
form={changePasswordForm}
|
|
|
|
|
layout="vertical"
|
|
|
|
|
onFinish={handleChangePassword}
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
>
|
|
|
|
|
<Form.Item
|
|
|
|
|
label="新密码"
|
|
|
|
|
name="newPassword"
|
|
|
|
|
rules={[
|
|
|
|
|
{ required: true, message: '请输入新密码' },
|
|
|
|
|
{ min: 6, message: '密码至少6个字符' },
|
|
|
|
|
]}
|
|
|
|
|
>
|
|
|
|
|
<Input.Password
|
|
|
|
|
prefix={<LockOutlined />}
|
|
|
|
|
placeholder="请输入新密码(至少6个字符)"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
/>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
label="确认密码"
|
|
|
|
|
name="confirmPassword"
|
|
|
|
|
dependencies={['newPassword']}
|
|
|
|
|
rules={[
|
|
|
|
|
{ required: true, message: '请确认新密码' },
|
|
|
|
|
({ getFieldValue }) => ({
|
|
|
|
|
validator(_, value) {
|
|
|
|
|
if (!value || getFieldValue('newPassword') === value) {
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
return Promise.reject(new Error('两次输入的密码不一致'));
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
]}
|
|
|
|
|
>
|
|
|
|
|
<Input.Password
|
|
|
|
|
prefix={<LockOutlined />}
|
|
|
|
|
placeholder="请再次输入新密码"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
/>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item style={{ marginBottom: 0 }}>
|
|
|
|
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
|
|
|
|
<Button onClick={() => {
|
|
|
|
|
setShowChangePassword(false);
|
|
|
|
|
changePasswordForm.resetFields();
|
|
|
|
|
}}>
|
|
|
|
|
取消
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="primary" htmlType="submit" loading={changingPassword}>
|
|
|
|
|
确认修改
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Form>
|
|
|
|
|
</Modal>
|
2025-10-30 11:14:43 +08:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|