feat: 完善登录等接口鉴权
This commit is contained in:
23
apps/web/src/components/ProtectedRoute.tsx
Normal file
23
apps/web/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由守卫组件
|
||||
* 用于保护需要登录才能访问的页面
|
||||
*/
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const isAuthenticated = authService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// 未登录,重定向到登录页
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// 已登录,渲染子组件
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { Layout, Menu, Avatar, Badge, Drawer } from 'antd';
|
||||
import { Layout, Menu, Avatar, Badge, Drawer, Dropdown, Button, message } from 'antd';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
EditOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { authService } from '../services/auth';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
@@ -29,9 +32,16 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 获取用户信息
|
||||
useEffect(() => {
|
||||
const currentUser = authService.getUser();
|
||||
setUser(currentUser);
|
||||
}, []);
|
||||
|
||||
// 根据路由获取页面标题
|
||||
const pageInfo = useMemo(() => {
|
||||
return pageTitles[location.pathname] || pageTitles['/'];
|
||||
@@ -78,6 +88,51 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
message.success('已退出登录');
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
// 用户下拉菜单
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人资料',
|
||||
disabled: true, // 暂时禁用
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
// 获取角色显示文本
|
||||
const getRoleText = (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
user: '普通用户',
|
||||
admin: '管理员',
|
||||
super_admin: '超级管理员',
|
||||
};
|
||||
return roleMap[role] || '普通用户';
|
||||
};
|
||||
|
||||
// 获取角色状态
|
||||
const getRoleStatus = (role: string): 'success' | 'warning' | 'error' => {
|
||||
if (role === 'admin' || role === 'super_admin') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'success';
|
||||
};
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
@@ -171,23 +226,32 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<div className="user-info">
|
||||
<Avatar
|
||||
style={{
|
||||
backgroundColor: '#8b5cf6',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
U
|
||||
</Avatar>
|
||||
<div className="user-details">
|
||||
<div className="user-name">用户名</div>
|
||||
<Badge
|
||||
status="success"
|
||||
text={<span className="user-role">普通用户</span>}
|
||||
/>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div className="user-info" style={{ cursor: 'pointer' }}>
|
||||
<Avatar
|
||||
style={{
|
||||
backgroundColor: '#8b5cf6',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
src={user?.avatarUrl}
|
||||
>
|
||||
{user?.nickname?.[0] || user?.username?.[0] || 'U'}
|
||||
</Avatar>
|
||||
<div className="user-details">
|
||||
<div className="user-name">
|
||||
{user?.nickname || user?.username || '用户'}
|
||||
</div>
|
||||
<Badge
|
||||
status={user ? getRoleStatus(user.role) : 'success'}
|
||||
text={
|
||||
<span className="user-role">
|
||||
{user ? getRoleText(user.role) : '普通用户'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
|
||||
58
apps/web/src/pages/LoginPage.css
Normal file
58
apps/web/src/pages/LoginPage.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-form .ant-input-affix-wrapper,
|
||||
.login-form .ant-input {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: var(--primary-color, #8b5cf6);
|
||||
border-color: var(--primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: var(--primary-dark, #7c3aed);
|
||||
border-color: var(--primary-dark, #7c3aed);
|
||||
}
|
||||
95
apps/web/src/pages/LoginPage.tsx
Normal file
95
apps/web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Form, Input, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { authService } from '../services/auth';
|
||||
import type { LoginRequest } from '@/types/user';
|
||||
import './LoginPage.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 如果已登录,重定向到首页
|
||||
useEffect(() => {
|
||||
if (authService.isAuthenticated()) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const onFinish = async (values: LoginRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await authService.login(values);
|
||||
message.success('登录成功');
|
||||
navigate('/', { replace: true });
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '登录失败,请检查用户名和密码');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-box">
|
||||
<div className="login-header">
|
||||
<h1>投小记</h1>
|
||||
<p>投资决策与复盘工具</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="login-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="usernameOrEmail"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入用户名或邮箱',
|
||||
},
|
||||
{
|
||||
min: 3,
|
||||
message: '用户名或邮箱至少3个字符',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名或邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: '密码至少6个字符',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="login-button"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
import { createBrowserRouter } from 'react-router';
|
||||
import MainLayout from '../layouts/MainLayout';
|
||||
import ProtectedRoute from '../components/ProtectedRoute';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
import AssetsPage from '../pages/AssetsPage';
|
||||
import PlansPage from '../pages/PlansPage';
|
||||
import ReviewPage from '../pages/ReviewPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { envConfig } from '../config/env';
|
||||
|
||||
// Token 存储键名(与 auth service 保持一致)
|
||||
const TOKEN_KEY = 'access_token';
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
@@ -13,11 +17,11 @@ const apiClient: AxiosInstance = axios.create({
|
||||
// 请求拦截器
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// 可以在这里添加 token 等认证信息
|
||||
// const token = localStorage.getItem('token')
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`
|
||||
// }
|
||||
// 自动添加 token 到请求头
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (envConfig.debug) {
|
||||
console.log('请求:', config.method?.toUpperCase(), config.url);
|
||||
@@ -50,8 +54,13 @@ apiClient.interceptors.response.use(
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// 未授权,可以跳转到登录页
|
||||
// window.location.href = '/login'
|
||||
// 未授权,清除 token 并跳转到登录页
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem('user_info');
|
||||
// 避免在登录页重复跳转
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
break;
|
||||
case 403:
|
||||
console.error('没有权限访问该资源');
|
||||
|
||||
104
apps/web/src/services/auth.ts
Normal file
104
apps/web/src/services/auth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { api } from './api';
|
||||
import type { UserInfo, LoginRequest, LoginResponse } from '@/types/user';
|
||||
import type { ApiResponse } from '@/types/common';
|
||||
|
||||
/**
|
||||
* Token 存储键名
|
||||
*/
|
||||
const TOKEN_KEY = 'access_token';
|
||||
const USER_KEY = 'user_info';
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*/
|
||||
class AuthService {
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const response = await api.post<ApiResponse<LoginResponse>>('/auth/login', credentials);
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
this.setToken(response.data.accessToken);
|
||||
this.setUser(response.data.user);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error(response.message || '登录失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout(): void {
|
||||
this.removeToken();
|
||||
this.removeUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 token
|
||||
*/
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 token
|
||||
*/
|
||||
setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 token
|
||||
*/
|
||||
removeToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
getUser(): UserInfo | null {
|
||||
const userStr = localStorage.getItem(USER_KEY);
|
||||
if (!userStr) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(userStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户信息
|
||||
*/
|
||||
setUser(user: UserInfo): void {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除用户信息
|
||||
*/
|
||||
removeUser(): void {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.getToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为管理员
|
||||
*/
|
||||
isAdmin(): boolean {
|
||||
const user = this.getUser();
|
||||
return user?.role === 'admin' || user?.role === 'super_admin';
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const authService = new AuthService();
|
||||
9
apps/web/src/types/common.ts
Normal file
9
apps/web/src/types/common.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* API 响应包装
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp?: string;
|
||||
}
|
||||
34
apps/web/src/types/user.ts
Normal file
34
apps/web/src/types/user.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 用户信息接口(排除密码哈希)
|
||||
*/
|
||||
export interface UserInfo {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
nickname?: string;
|
||||
phone?: string;
|
||||
avatarUrl?: string;
|
||||
openId?: string;
|
||||
unionId?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录请求参数
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
usernameOrEmail: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录响应
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
user: UserInfo;
|
||||
}
|
||||
Reference in New Issue
Block a user