feat: 完善登录等接口鉴权
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"start": "nest start",
|
"start": "nest start --watch",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
|
|||||||
67
apps/api/src/modules/auth/guards/owner-or-admin.guard.ts
Normal file
67
apps/api/src/modules/auth/guards/owner-or-admin.guard.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { User } from '../../user/user.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源所有者或管理员权限 Guard
|
||||||
|
*
|
||||||
|
* 用途:检查用户是否有权限访问资源
|
||||||
|
* - 管理员(admin、super_admin)可以访问任何资源
|
||||||
|
* - 普通用户只能访问自己的资源(通过比较 userId)
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 查询用户信息:管理员可以查询任何用户,普通用户只能查询自己
|
||||||
|
* - 更新用户信息:管理员可以更新任何用户,普通用户只能更新自己
|
||||||
|
* - 删除用户:管理员可以删除任何用户,普通用户只能删除自己
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* @Get(':id')
|
||||||
|
* @UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
* findOneById(@Param('id') id: string) { ... }
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OwnerOrAdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
user?: User;
|
||||||
|
params: { id?: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('未授权访问');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取请求的资源ID(从路由参数中)
|
||||||
|
const requestedId = request.params?.id;
|
||||||
|
|
||||||
|
if (!requestedId) {
|
||||||
|
// 如果没有提供资源ID,只允许管理员访问
|
||||||
|
const isAdmin =
|
||||||
|
user.role === 'admin' || user.role === 'super_admin';
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new ForbiddenException('权限不足,需要管理员权限');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedUserId = +requestedId;
|
||||||
|
|
||||||
|
// 检查权限:管理员可以访问任何资源,普通用户只能访问自己的资源
|
||||||
|
const isAdmin = user.role === 'admin' || user.role === 'super_admin';
|
||||||
|
const isOwner = user.userId === requestedUserId;
|
||||||
|
|
||||||
|
if (!isAdmin && !isOwner) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'权限不足,只能访问自己的资源或需要管理员权限',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Matches,
|
Matches,
|
||||||
IsIn,
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -95,15 +94,4 @@ export class CreateUserDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
unionId?: string;
|
unionId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '用户角色',
|
|
||||||
example: 'user',
|
|
||||||
enum: ['user', 'admin', 'super_admin'],
|
|
||||||
default: 'user',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(['user', 'admin', 'super_admin'])
|
|
||||||
role?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
Query,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -22,11 +21,11 @@ import { UserService } from './user.service';
|
|||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { QueryUserDto } from './dto/query-user.dto';
|
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard';
|
||||||
|
|
||||||
@ApiTags('user')
|
@ApiTags('user')
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
@@ -60,8 +59,8 @@ export class UserController {
|
|||||||
* 查询所有用户
|
* 查询所有用户
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
// @UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
// @Roles('admin', 'super_admin')
|
@Roles('admin', 'super_admin')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '查询所有用户',
|
summary: '查询所有用户',
|
||||||
@@ -78,35 +77,38 @@ export class UserController {
|
|||||||
return this.userService.findAll();
|
return this.userService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* 根据 username 或 email 查询单个用户
|
// * 根据 username 或 email 查询单个用户
|
||||||
*/
|
// */
|
||||||
@Get('find')
|
// @Get('find')
|
||||||
@ApiOperation({
|
// @ApiOperation({
|
||||||
summary: '查询单个用户',
|
// summary: '查询单个用户',
|
||||||
description: '根据 username 或 email 查询用户',
|
// description: '根据 username 或 email 查询用户',
|
||||||
})
|
// })
|
||||||
@ApiResponse({
|
// @ApiResponse({
|
||||||
status: 200,
|
// status: 200,
|
||||||
description: '查询成功',
|
// description: '查询成功',
|
||||||
type: User,
|
// type: User,
|
||||||
})
|
// })
|
||||||
@ApiResponse({
|
// @ApiResponse({
|
||||||
status: 400,
|
// status: 400,
|
||||||
description: '请求参数错误,必须提供 username 或 email',
|
// description: '请求参数错误,必须提供 username 或 email',
|
||||||
})
|
// })
|
||||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
// @ApiResponse({ status: 404, description: '用户不存在' })
|
||||||
findOne(@Query() queryDto: QueryUserDto): Promise<User> {
|
// findOne(@Query() queryDto: QueryUserDto): Promise<User> {
|
||||||
return this.userService.findOne(queryDto);
|
// return this.userService.findOne(queryDto);
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 ID 查询单个用户
|
* 根据 ID 查询单个用户
|
||||||
|
* 需要管理员权限或者是用户本人
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '根据ID查询用户',
|
summary: '根据ID查询用户',
|
||||||
description: '根据用户ID获取详细信息',
|
description: '根据用户ID获取详细信息,需要管理员权限或者是用户本人',
|
||||||
})
|
})
|
||||||
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -114,6 +116,11 @@ export class UserController {
|
|||||||
description: '查询成功',
|
description: '查询成功',
|
||||||
type: User,
|
type: User,
|
||||||
})
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 403,
|
||||||
|
description: '权限不足,只能查询自己的信息或需要管理员权限',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||||
findOneById(@Param('id') id: string): Promise<User> {
|
findOneById(@Param('id') id: string): Promise<User> {
|
||||||
return this.userService.findOneById(+id);
|
return this.userService.findOneById(+id);
|
||||||
@@ -123,6 +130,8 @@ export class UserController {
|
|||||||
* 更新用户信息
|
* 更新用户信息
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '更新用户信息',
|
summary: '更新用户信息',
|
||||||
description: '更新用户信息,不允许修改 username、openId、unionId',
|
description: '更新用户信息,不允许修改 username、openId、unionId',
|
||||||
@@ -147,6 +156,8 @@ export class UserController {
|
|||||||
*/
|
*/
|
||||||
@Patch(':id/password')
|
@Patch(':id/password')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '修改密码',
|
summary: '修改密码',
|
||||||
description: '修改用户密码,需要先验证旧密码',
|
description: '修改用户密码,需要先验证旧密码',
|
||||||
@@ -167,6 +178,8 @@ export class UserController {
|
|||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '删除用户',
|
summary: '删除用户',
|
||||||
description: '软删除用户,将状态更新为 deleted',
|
description: '软删除用户,将状态更新为 deleted',
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export class UserService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 创建用户
|
// 创建用户
|
||||||
|
// 注意:所有注册用户的 role 固定为 'user',不允许通过注册接口设置其他角色
|
||||||
const user = this.userRepository.create({
|
const user = this.userRepository.create({
|
||||||
username: createUserDto.username,
|
username: createUserDto.username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -101,7 +102,7 @@ export class UserService {
|
|||||||
openId: createUserDto.openId,
|
openId: createUserDto.openId,
|
||||||
unionId: createUserDto.unionId,
|
unionId: createUserDto.unionId,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
role: createUserDto.role || 'user', // 默认为普通用户
|
role: 'user', // 固定为普通用户,不允许通过注册接口修改
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.userRepository.save(user);
|
return this.userRepository.save(user);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ describe('UserService (集成测试)', () => {
|
|||||||
password: 'password123',
|
password: 'password123',
|
||||||
email: 'findall3@example.com',
|
email: 'findall3@example.com',
|
||||||
nickname: '查询测试用户3',
|
nickname: '查询测试用户3',
|
||||||
role: 'admin',
|
// 注意:注册时不允许传入 role,所有注册用户的 role 固定为 'user'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ describe('UserService (集成测试)', () => {
|
|||||||
nickname: '完整信息用户',
|
nickname: '完整信息用户',
|
||||||
avatarUrl: 'https://example.com/avatar.jpg',
|
avatarUrl: 'https://example.com/avatar.jpg',
|
||||||
phone: '13800138000',
|
phone: '13800138000',
|
||||||
role: 'admin',
|
// 注意:注册时不允许传入 role,所有注册用户的 role 固定为 'user'
|
||||||
};
|
};
|
||||||
|
|
||||||
await service.create(createUserDto);
|
await service.create(createUserDto);
|
||||||
@@ -307,7 +307,7 @@ describe('UserService (集成测试)', () => {
|
|||||||
expect(foundUser?.nickname).toBe(createUserDto.nickname);
|
expect(foundUser?.nickname).toBe(createUserDto.nickname);
|
||||||
expect(foundUser?.avatarUrl).toBe(createUserDto.avatarUrl);
|
expect(foundUser?.avatarUrl).toBe(createUserDto.avatarUrl);
|
||||||
expect(foundUser?.phone).toBe(createUserDto.phone);
|
expect(foundUser?.phone).toBe(createUserDto.phone);
|
||||||
expect(foundUser?.role).toBe(createUserDto.role);
|
expect(foundUser?.role).toBe('user'); // 注册用户的 role 固定为 'user'
|
||||||
expect(foundUser?.status).toBe('active');
|
expect(foundUser?.status).toBe('active');
|
||||||
expect(foundUser?.createdAt).toBeDefined();
|
expect(foundUser?.createdAt).toBeDefined();
|
||||||
expect(foundUser?.updatedAt).toBeDefined();
|
expect(foundUser?.updatedAt).toBeDefined();
|
||||||
|
|||||||
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 { useState, useEffect, useMemo } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
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 {
|
import {
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
|
import { authService } from '../services/auth';
|
||||||
import './MainLayout.css';
|
import './MainLayout.css';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
@@ -29,9 +32,16 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [user, setUser] = useState<UserInfo | null>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
useEffect(() => {
|
||||||
|
const currentUser = authService.getUser();
|
||||||
|
setUser(currentUser);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 根据路由获取页面标题
|
// 根据路由获取页面标题
|
||||||
const pageInfo = useMemo(() => {
|
const pageInfo = useMemo(() => {
|
||||||
return pageTitles[location.pathname] || pageTitles['/'];
|
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 selectedKeys = useMemo(() => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
@@ -171,23 +226,32 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
<div className="user-info">
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
<Avatar
|
<div className="user-info" style={{ cursor: 'pointer' }}>
|
||||||
style={{
|
<Avatar
|
||||||
backgroundColor: '#8b5cf6',
|
style={{
|
||||||
verticalAlign: 'middle',
|
backgroundColor: '#8b5cf6',
|
||||||
}}
|
verticalAlign: 'middle',
|
||||||
>
|
}}
|
||||||
U
|
src={user?.avatarUrl}
|
||||||
</Avatar>
|
>
|
||||||
<div className="user-details">
|
{user?.nickname?.[0] || user?.username?.[0] || 'U'}
|
||||||
<div className="user-name">用户名</div>
|
</Avatar>
|
||||||
<Badge
|
<div className="user-details">
|
||||||
status="success"
|
<div className="user-name">
|
||||||
text={<span className="user-role">普通用户</span>}
|
{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>
|
||||||
</div>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</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 { createBrowserRouter } from 'react-router';
|
||||||
import MainLayout from '../layouts/MainLayout';
|
import MainLayout from '../layouts/MainLayout';
|
||||||
|
import ProtectedRoute from '../components/ProtectedRoute';
|
||||||
|
import LoginPage from '../pages/LoginPage';
|
||||||
import AssetsPage from '../pages/AssetsPage';
|
import AssetsPage from '../pages/AssetsPage';
|
||||||
import PlansPage from '../pages/PlansPage';
|
import PlansPage from '../pages/PlansPage';
|
||||||
import ReviewPage from '../pages/ReviewPage';
|
import ReviewPage from '../pages/ReviewPage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <MainLayout />,
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
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';
|
import { envConfig } from '../config/env';
|
||||||
|
|
||||||
|
// Token 存储键名(与 auth service 保持一致)
|
||||||
|
const TOKEN_KEY = 'access_token';
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
const apiClient: AxiosInstance = axios.create({
|
const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: envConfig.apiBaseUrl,
|
baseURL: envConfig.apiBaseUrl,
|
||||||
@@ -13,11 +17,11 @@ const apiClient: AxiosInstance = axios.create({
|
|||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
// 可以在这里添加 token 等认证信息
|
// 自动添加 token 到请求头
|
||||||
// const token = localStorage.getItem('token')
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
// if (token) {
|
if (token) {
|
||||||
// config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
// }
|
}
|
||||||
|
|
||||||
if (envConfig.debug) {
|
if (envConfig.debug) {
|
||||||
console.log('请求:', config.method?.toUpperCase(), config.url);
|
console.log('请求:', config.method?.toUpperCase(), config.url);
|
||||||
@@ -50,8 +54,13 @@ apiClient.interceptors.response.use(
|
|||||||
if (error.response) {
|
if (error.response) {
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
// 未授权,可以跳转到登录页
|
// 未授权,清除 token 并跳转到登录页
|
||||||
// window.location.href = '/login'
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
// 避免在登录页重复跳转
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 403:
|
case 403:
|
||||||
console.error('没有权限访问该资源');
|
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;
|
||||||
|
}
|
||||||
@@ -1,28 +1,32 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true,
|
||||||
},
|
"paths": {
|
||||||
"include": ["src"]
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user