From 457ba6d765579620d5a214b34947e53d0144764a Mon Sep 17 00:00:00 2001 From: R524809 Date: Wed, 7 Jan 2026 16:21:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=88=B8=E5=95=86?= =?UTF-8?q?=E5=92=8C=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/common/dto/pagination.dto.ts | 29 ++ .../src/modules/broker/broker.controller.ts | 33 +- apps/api/src/modules/broker/broker.service.ts | 58 ++- .../broker/dto/paginated-response.dto.ts | 14 + .../user/dto/paginated-response.dto.ts | 14 + .../src/modules/user/dto/query-user.dto.ts | 92 +++- apps/api/src/modules/user/mock-users.data.ts | 102 +++++ apps/api/src/modules/user/user.controller.ts | 15 +- apps/api/src/modules/user/user.seeder.ts | 226 +++++++-- apps/api/src/modules/user/user.service.ts | 85 +++- apps/api/tsconfig.json | 49 +- apps/web/src/App.tsx | 6 +- apps/web/src/components/ErrorBoundary.tsx | 73 +++ apps/web/src/components/ErrorPage.css | 130 ++++++ apps/web/src/components/ErrorPage.tsx | 184 ++++++++ apps/web/src/layouts/MainLayout.css | 23 +- apps/web/src/layouts/MainLayout.tsx | 83 +--- apps/web/src/layouts/SidebarMenu.css | 155 +++++++ apps/web/src/layouts/SidebarMenu.tsx | 99 ++++ apps/web/src/layouts/menuConfig.tsx | 155 +++++++ apps/web/src/pages/broker/BrokerFormModal.tsx | 172 +++++++ apps/web/src/pages/broker/BrokerPage.css | 38 ++ apps/web/src/pages/broker/BrokerPage.tsx | 328 +++++++++++++ apps/web/src/pages/broker/index.ts | 1 + apps/web/src/pages/user/UserDetailModal.tsx | 137 ++++++ apps/web/src/pages/user/UserPage.css | 7 + apps/web/src/pages/user/UserPage.tsx | 431 ++++++++++++++++++ apps/web/src/pages/user/index.ts | 1 + apps/web/src/router/index.tsx | 24 +- apps/web/src/services/broker.ts | 59 +++ apps/web/src/services/user.ts | 46 ++ apps/web/src/types/broker.ts | 90 ++++ apps/web/src/types/user.ts | 69 +++ 33 files changed, 2851 insertions(+), 177 deletions(-) create mode 100644 apps/api/src/common/dto/pagination.dto.ts create mode 100644 apps/api/src/modules/broker/dto/paginated-response.dto.ts create mode 100644 apps/api/src/modules/user/dto/paginated-response.dto.ts create mode 100644 apps/api/src/modules/user/mock-users.data.ts create mode 100644 apps/web/src/components/ErrorBoundary.tsx create mode 100644 apps/web/src/components/ErrorPage.css create mode 100644 apps/web/src/components/ErrorPage.tsx create mode 100644 apps/web/src/layouts/SidebarMenu.css create mode 100644 apps/web/src/layouts/SidebarMenu.tsx create mode 100644 apps/web/src/layouts/menuConfig.tsx create mode 100644 apps/web/src/pages/broker/BrokerFormModal.tsx create mode 100644 apps/web/src/pages/broker/BrokerPage.css create mode 100644 apps/web/src/pages/broker/BrokerPage.tsx create mode 100644 apps/web/src/pages/broker/index.ts create mode 100644 apps/web/src/pages/user/UserDetailModal.tsx create mode 100644 apps/web/src/pages/user/UserPage.css create mode 100644 apps/web/src/pages/user/UserPage.tsx create mode 100644 apps/web/src/pages/user/index.ts create mode 100644 apps/web/src/services/broker.ts create mode 100644 apps/web/src/services/user.ts create mode 100644 apps/web/src/types/broker.ts diff --git a/apps/api/src/common/dto/pagination.dto.ts b/apps/api/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..70b071f --- /dev/null +++ b/apps/api/src/common/dto/pagination.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 分页信息 + */ +export class PaginationInfo { + @ApiProperty({ description: '总记录数', example: 46 }) + total: number; + + @ApiProperty({ description: '总页数', example: 5 }) + total_page: number; + + @ApiProperty({ description: '每页数量', example: 10 }) + page_size: number; + + @ApiProperty({ description: '当前页码', example: 1 }) + current_page: number; +} + +/** + * 通用分页响应数据 + */ +export class PaginatedData { + @ApiProperty({ description: '数据列表' }) + list: T[]; + + @ApiProperty({ description: '分页信息', type: PaginationInfo }) + pagination: PaginationInfo; +} diff --git a/apps/api/src/modules/broker/broker.controller.ts b/apps/api/src/modules/broker/broker.controller.ts index 15a41a2..599bbe3 100644 --- a/apps/api/src/modules/broker/broker.controller.ts +++ b/apps/api/src/modules/broker/broker.controller.ts @@ -9,14 +9,25 @@ import { Query, HttpCode, HttpStatus, + UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; import { BrokerService } from './broker.service'; import { CreateBrokerDto } from './dto/create-broker.dto'; import { UpdateBrokerDto } from './dto/update-broker.dto'; import { QueryBrokerDto } from './dto/query-broker.dto'; import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto'; +import { PaginatedBrokerData } from './dto/paginated-response.dto'; import { Broker } from './broker.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('broker') @Controller('broker') @@ -27,6 +38,9 @@ export class BrokerController { * 单独创建 broker */ @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: '创建券商', description: '创建单个券商信息' }) @ApiResponse({ @@ -44,6 +58,9 @@ export class BrokerController { * 批量创建 broker */ @Post('batch') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: '批量创建券商', @@ -63,9 +80,9 @@ export class BrokerController { } /** - * 查询 broker(支持多种查询条件) + * 查询 broker(支持多种查询条件和分页) * 支持按 broker_id、broker_code、broker_name、region 查询 - * 返回一个或多个 broker + * 返回分页数据 */ @Get() @ApiOperation({ @@ -75,9 +92,9 @@ export class BrokerController { @ApiResponse({ status: 200, description: '查询成功', - type: [Broker], + type: PaginatedBrokerData, }) - findAll(@Query() queryDto: QueryBrokerDto): Promise { + findAll(@Query() queryDto: QueryBrokerDto): Promise { return this.brokerService.findAll(queryDto); } @@ -104,6 +121,9 @@ export class BrokerController { * 更新 broker */ @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() @ApiOperation({ summary: '更新券商', description: '更新券商的部分或全部信息', @@ -127,6 +147,9 @@ export class BrokerController { * 删除 broker */ @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: '删除券商', diff --git a/apps/api/src/modules/broker/broker.service.ts b/apps/api/src/modules/broker/broker.service.ts index 7c3ef10..9b2ba28 100644 --- a/apps/api/src/modules/broker/broker.service.ts +++ b/apps/api/src/modules/broker/broker.service.ts @@ -10,6 +10,7 @@ import { CreateBrokerDto } from './dto/create-broker.dto'; import { UpdateBrokerDto } from './dto/update-broker.dto'; import { QueryBrokerDto } from './dto/query-broker.dto'; import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; @Injectable() export class BrokerService { @@ -109,9 +110,12 @@ export class BrokerService { } /** - * 查询 broker(支持多种查询条件) + * 查询 broker(支持多种查询条件和分页) */ - async findAll(queryDto: QueryBrokerDto): Promise { + async findAll(queryDto: QueryBrokerDto): Promise<{ + list: Broker[]; + pagination: PaginationInfo; + }> { const where: FindOptionsWhere = {}; if (queryDto.brokerId) { @@ -134,13 +138,53 @@ export class BrokerService { where.isActive = queryDto.isActive; } - return this.brokerRepository.find({ + // 分页参数 + const page = queryDto.page || 1; + const limit = queryDto.limit || 10; + const skip = (page - 1) * limit; + + // 排序字段映射 + const sortBy = queryDto.sortBy || 'createdAt'; + const sortOrder = queryDto.sortOrder || 'DESC'; + + // 构建排序对象 + const order: Record = {}; + if (sortBy === 'createdAt') { + order.createdAt = sortOrder; + } else if (sortBy === 'sortOrder') { + order.sortOrder = sortOrder; + } else { + order.createdAt = 'DESC'; + } + // 添加默认排序 + if (sortBy !== 'sortOrder') { + order.sortOrder = 'ASC'; + } + order.brokerId = 'ASC'; + + // 查询总数 + const total = await this.brokerRepository.count({ where }); + + // 查询分页数据 + const list = await this.brokerRepository.find({ where, - order: { - sortOrder: 'ASC', - brokerId: 'ASC', - }, + order, + skip, + take: limit, }); + + // 计算总页数 + const total_page = Math.ceil(total / limit); + + return { + list, + pagination: { + total, + total_page, + page_size: limit, + current_page: page, + }, + }; } /** diff --git a/apps/api/src/modules/broker/dto/paginated-response.dto.ts b/apps/api/src/modules/broker/dto/paginated-response.dto.ts new file mode 100644 index 0000000..a43c913 --- /dev/null +++ b/apps/api/src/modules/broker/dto/paginated-response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Broker } from '../broker.entity'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; + +/** + * 券商分页响应数据 + */ +export class PaginatedBrokerData { + @ApiProperty({ description: '券商列表', type: [Broker] }) + list: Broker[]; + + @ApiProperty({ description: '分页信息', type: PaginationInfo }) + pagination: PaginationInfo; +} diff --git a/apps/api/src/modules/user/dto/paginated-response.dto.ts b/apps/api/src/modules/user/dto/paginated-response.dto.ts new file mode 100644 index 0000000..49fdbd0 --- /dev/null +++ b/apps/api/src/modules/user/dto/paginated-response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '../user.entity'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; + +/** + * 用户分页响应数据 + */ +export class PaginatedUserData { + @ApiProperty({ description: '用户列表', type: [User] }) + list: User[]; + + @ApiProperty({ description: '分页信息', type: PaginationInfo }) + pagination: PaginationInfo; +} diff --git a/apps/api/src/modules/user/dto/query-user.dto.ts b/apps/api/src/modules/user/dto/query-user.dto.ts index 90d6f57..9113b34 100644 --- a/apps/api/src/modules/user/dto/query-user.dto.ts +++ b/apps/api/src/modules/user/dto/query-user.dto.ts @@ -1,4 +1,12 @@ -import { IsOptional, IsString, IsEmail } from 'class-validator'; +import { + IsOptional, + IsString, + IsEmail, + IsNumber, + Min, + IsIn, +} from 'class-validator'; +import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; export class QueryUserDto { @@ -10,11 +18,91 @@ export class QueryUserDto { @IsString() username?: string; + @ApiPropertyOptional({ + description: '昵称', + example: 'John Doe', + }) + @IsOptional() + @IsString() + nickname?: string; + @ApiPropertyOptional({ description: '邮箱', example: 'user@example.com', }) @IsOptional() - @IsEmail() + @IsString() email?: string; + + @ApiPropertyOptional({ + description: '电话', + example: '13800138000', + }) + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ + description: '角色', + example: 'user', + enum: ['user', 'admin', 'super_admin'], + }) + @IsOptional() + @IsString() + @IsIn(['user', 'admin', 'super_admin']) + role?: string; + + @ApiPropertyOptional({ + description: '状态', + example: 'active', + enum: ['active', 'inactive', 'deleted'], + }) + @IsOptional() + @IsString() + @IsIn(['active', 'inactive', 'deleted']) + status?: string; + + @ApiPropertyOptional({ + description: '页码', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量', + example: 10, + minimum: 1, + default: 10, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number = 10; + + @ApiPropertyOptional({ + description: '排序字段', + example: 'createdAt', + default: 'createdAt', + }) + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @ApiPropertyOptional({ + description: '排序方向', + example: 'DESC', + enum: ['ASC', 'DESC'], + default: 'DESC', + }) + @IsOptional() + @IsString() + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC' = 'DESC'; } diff --git a/apps/api/src/modules/user/mock-users.data.ts b/apps/api/src/modules/user/mock-users.data.ts new file mode 100644 index 0000000..2bcd869 --- /dev/null +++ b/apps/api/src/modules/user/mock-users.data.ts @@ -0,0 +1,102 @@ +/** + * 普通用户 Mock 数据 + * 用于种子数据初始化 + */ + +export interface MockUserData { + username: string; + email: string; + nickname: string; + phone?: string; + password: string; // 明文密码,会自动加密 +} + +/** + * 12个普通用户的Mock数据 + */ +export const MOCK_USERS: MockUserData[] = [ + { + username: 'user001', + email: 'user001@vestmind.com', + nickname: '用户001', + phone: '13800010001', + password: 'user123456', + }, + { + username: 'user002', + email: 'user002@vestmind.com', + nickname: '用户002', + phone: '13800010002', + password: 'user123456', + }, + { + username: 'user003', + email: 'user003@vestmind.com', + nickname: '用户003', + phone: '13800010003', + password: 'user123456', + }, + { + username: 'user004', + email: 'user004@vestmind.com', + nickname: '用户004', + phone: '13800010004', + password: 'user123456', + }, + { + username: 'user005', + email: 'user005@vestmind.com', + nickname: '用户005', + phone: '13800010005', + password: 'user123456', + }, + { + username: 'user006', + email: 'user006@vestmind.com', + nickname: '用户006', + phone: '13800010006', + password: 'user123456', + }, + { + username: 'user007', + email: 'user007@vestmind.com', + nickname: '用户007', + phone: '13800010007', + password: 'user123456', + }, + { + username: 'user008', + email: 'user008@vestmind.com', + nickname: '用户008', + phone: '13800010008', + password: 'user123456', + }, + { + username: 'user009', + email: 'user009@vestmind.com', + nickname: '用户009', + phone: '13800010009', + password: 'user123456', + }, + { + username: 'user010', + email: 'user010@vestmind.com', + nickname: '用户010', + phone: '13800010010', + password: 'user123456', + }, + { + username: 'user011', + email: 'user011@vestmind.com', + nickname: '用户011', + phone: '13800010011', + password: 'user123456', + }, + { + username: 'user012', + email: 'user012@vestmind.com', + nickname: '用户012', + phone: '13800010012', + password: 'user123456', + }, +]; diff --git a/apps/api/src/modules/user/user.controller.ts b/apps/api/src/modules/user/user.controller.ts index a245187..d44e864 100644 --- a/apps/api/src/modules/user/user.controller.ts +++ b/apps/api/src/modules/user/user.controller.ts @@ -6,6 +6,7 @@ import { Patch, Param, Delete, + Query, HttpCode, HttpStatus, UseGuards, @@ -22,10 +23,12 @@ import { User } from './user.entity'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; +import { QueryUserDto } from './dto/query-user.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard'; +import { PaginatedUserData } from './dto/paginated-response.dto'; @ApiTags('user') @Controller('user') @@ -56,25 +59,25 @@ export class UserController { } /** - * 查询所有用户 + * 查询所有用户(支持分页和筛选) */ @Get() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'super_admin') @ApiBearerAuth() @ApiOperation({ - summary: '查询所有用户', - description: '获取所有用户列表(需要管理员权限)', + summary: '查询用户列表', + description: '获取用户列表,支持分页和多种筛选条件(需要管理员权限)', }) @ApiResponse({ status: 200, description: '查询成功', - type: [User], + type: PaginatedUserData, }) @ApiResponse({ status: 401, description: '未授权' }) @ApiResponse({ status: 403, description: '权限不足' }) - findAll(): Promise { - return this.userService.findAll(); + findAll(@Query() queryDto: QueryUserDto): Promise { + return this.userService.findAllPaginated(queryDto); } // /** diff --git a/apps/api/src/modules/user/user.seeder.ts b/apps/api/src/modules/user/user.seeder.ts index 3eec2dd..4639d51 100644 --- a/apps/api/src/modules/user/user.seeder.ts +++ b/apps/api/src/modules/user/user.seeder.ts @@ -1,14 +1,23 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, In } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { User } from './user.entity'; +import { MOCK_USERS } from './mock-users.data'; /** * 用户数据种子(Seeder) * - * 用途:在应用启动时自动创建初始管理员用户 + * 用途:在应用启动时自动创建初始用户(超级管理员、管理员、普通用户) + * + * 功能: + * 1. 创建超级管理员和管理员各一名(从环境变量读取配置) + * 2. 创建12名普通用户(使用Mock数据) + * + * 性能优化: + * - 使用批量查询(IN查询)检查用户是否存在,只执行2次数据库查询 + * - 只创建不存在的用户,保证幂等性 * * 优点: * 1. 代码化管理,版本控制友好 @@ -18,13 +27,14 @@ import { User } from './user.entity'; * 5. 幂等性:如果用户已存在,不会重复创建 * * 使用方式: - * 1. 通过环境变量配置初始管理员信息 + * 1. 通过环境变量配置管理员信息 * 2. 应用启动时自动执行 * 3. 仅在开发/测试环境自动执行,生产环境建议手动创建 */ @Injectable() export class UserSeeder implements OnModuleInit { private readonly logger = new Logger(UserSeeder.name); + private readonly saltRounds = 10; // bcrypt 加盐轮数 constructor( @InjectRepository(User) @@ -44,15 +54,51 @@ export class UserSeeder implements OnModuleInit { return; } - await this.seedAdminUser(); + // 执行种子数据创建 + await this.seedAllUsers(); } /** - * 创建初始管理员用户 + * 创建所有种子用户(超级管理员、管理员、普通用户) */ - async seedAdminUser(): Promise { + async seedAllUsers(): Promise { try { - // 从环境变量读取配置,如果没有则使用默认值 + // 创建管理员用户(超级管理员和管理员) + await this.seedAdminUsers(); + + // 创建普通用户 + await this.seedMockUsers(); + } catch (error) { + this.logger.error('创建种子用户失败:', error); + // 不抛出错误,避免影响应用启动 + } + } + + /** + * 创建管理员用户(超级管理员和管理员) + * + * 性能优化说明: + * - 使用批量查询(IN查询)一次检查两个管理员用户是否存在 + * - 只创建不存在的用户 + * - 只执行1次数据库查询,而不是2次 + */ + async seedAdminUsers(): Promise { + try { + // 从环境变量读取超级管理员配置 + const superAdminUsername = + this.configService.get('SUPER_ADMIN_USERNAME') || + 'superadmin'; + const superAdminPassword = + this.configService.get('SUPER_ADMIN_PASSWORD') || + 'admin123'; + const superAdminEmail = + this.configService.get('SUPER_ADMIN_EMAIL') || + 'superadmin@vestmind.com'; + const superAdminNickname = + this.configService.get('SUPER_ADMIN_NICKNAME') || + '超级管理员'; + + // 从环境变量读取管理员配置 const adminUsername = this.configService.get('ADMIN_USERNAME') || 'admin'; const adminPassword = @@ -63,57 +109,159 @@ export class UserSeeder implements OnModuleInit { const adminNickname = this.configService.get('ADMIN_NICKNAME') || '系统管理员'; - const adminRole = - this.configService.get('ADMIN_ROLE') || 'admin'; - // 检查管理员用户是否已存在 - const existingAdmin = await this.userRepository.findOne({ - where: { username: adminUsername }, + // 构建管理员用户数据 + const adminUsersToCreate = [ + { + username: superAdminUsername, + email: superAdminEmail, + nickname: superAdminNickname, + password: superAdminPassword, + role: 'super_admin', + }, + { + username: adminUsername, + email: adminEmail, + nickname: adminNickname, + password: adminPassword, + role: 'admin', + }, + ]; + + // 批量查询:一次检查所有管理员用户是否存在(性能优化) + const existingAdminUsernames = await this.userRepository.find({ + where: { + username: In([superAdminUsername, adminUsername]), + }, + select: ['username'], }); - if (existingAdmin) { + const existingUsernamesSet = new Set( + existingAdminUsernames.map((u) => u.username), + ); + + // 过滤出需要创建的用户(不存在的用户) + const usersToCreate = adminUsersToCreate.filter( + (user) => !existingUsernamesSet.has(user.username), + ); + + if (usersToCreate.length === 0) { this.logger.log( - `管理员用户 "${adminUsername}" 已存在,跳过创建`, + `管理员用户已存在(超级管理员: ${superAdminUsername}, 管理员: ${adminUsername}),跳过创建`, ); return; } - // 检查邮箱是否已被使用 - const existingByEmail = await this.userRepository.findOne({ - where: { email: adminEmail }, - }); + // 批量创建用户 + const usersToSave = await Promise.all( + usersToCreate.map(async (userData) => { + const passwordHash = await bcrypt.hash( + userData.password, + this.saltRounds, + ); - if (existingByEmail) { + return this.userRepository.create({ + username: userData.username, + passwordHash, + email: userData.email, + nickname: userData.nickname, + role: userData.role, + status: 'active', + }); + }), + ); + + await this.userRepository.save(usersToSave); + + // 记录日志 + const createdUsernames = usersToCreate.map((u) => u.username); + this.logger.log( + `✅ 成功创建管理员用户: ${createdUsernames.join(', ')}`, + ); + usersToCreate.forEach((user) => { this.logger.warn( - `邮箱 "${adminEmail}" 已被使用,跳过创建管理员用户`, + `⚠️ ${user.role === 'super_admin' ? '超级管理员' : '管理员'} "${user.username}" 默认密码: ${user.password},请尽快修改!`, + ); + }); + } catch (error) { + this.logger.error('创建管理员用户失败:', error); + throw error; + } + } + + /** + * 创建普通用户(Mock数据) + * + * 性能优化说明: + * - 使用批量查询(IN查询)一次检查所有12个普通用户是否存在 + * - 只创建不存在的用户 + * - 只执行1次数据库查询,而不是12次 + * + * 关于只检查第一个用户的方案评估: + * - 如果第一个用户存在,但其他11个用户被删除了,会导致其他用户不会被创建 + * - 使用批量查询既能保证性能(只查询1次),又能保证数据完整性 + */ + async seedMockUsers(): Promise { + try { + const mockUsernames = MOCK_USERS.map((user) => user.username); + + // 批量查询:一次检查所有普通用户是否存在(性能优化) + const existingUsers = await this.userRepository.find({ + where: { + username: In(mockUsernames), + }, + select: ['username'], + }); + + const existingUsernamesSet = new Set( + existingUsers.map((u) => u.username), + ); + + // 过滤出需要创建的用户(不存在的用户) + const usersToCreate = MOCK_USERS.filter( + (user) => !existingUsernamesSet.has(user.username), + ); + + if (usersToCreate.length === 0) { + this.logger.log( + `所有普通用户(共${MOCK_USERS.length}名)已存在,跳过创建`, ); return; } - // 使用 bcrypt 加密密码 - // saltRounds 指的是生成 bcrypt 哈希时的加盐轮数(成本系数),轮数越高,计算越慢,安全性越高,通常 10~12 为常用值 - const saltRounds = 10; - const passwordHash = await bcrypt.hash(adminPassword, saltRounds); + // 批量创建用户 + const usersToSave = await Promise.all( + usersToCreate.map(async (mockUser) => { + const passwordHash = await bcrypt.hash( + mockUser.password, + this.saltRounds, + ); - // 创建管理员用户 - const adminUser = this.userRepository.create({ - username: adminUsername, - passwordHash, - email: adminEmail, - nickname: adminNickname, - role: adminRole, - status: 'active', - }); + return this.userRepository.create({ + username: mockUser.username, + passwordHash, + email: mockUser.email, + nickname: mockUser.nickname, + phone: mockUser.phone, + role: 'user', + status: 'active', + }); + }), + ); - await this.userRepository.save(adminUser); + await this.userRepository.save(usersToSave); this.logger.log( - `✅ 成功创建初始管理员用户: ${adminUsername} (${adminEmail})`, + `✅ 成功创建 ${usersToCreate.length} 名普通用户(共 ${MOCK_USERS.length} 名)`, ); - this.logger.warn(`⚠️ 默认密码: ${adminPassword},请尽快修改!`); + if (usersToCreate.length < MOCK_USERS.length) { + this.logger.log( + ` 已存在 ${MOCK_USERS.length - usersToCreate.length} 名用户,已跳过`, + ); + } } catch (error) { - this.logger.error('创建初始管理员用户失败:', error); - // 不抛出错误,避免影响应用启动 + this.logger.error('创建普通用户失败:', error); + throw error; } } @@ -121,6 +269,6 @@ export class UserSeeder implements OnModuleInit { * 手动执行种子数据(可用于 CLI 命令) */ async run(): Promise { - await this.seedAdminUser(); + await this.seedAllUsers(); } } diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts index 43c887f..f6f6645 100644 --- a/apps/api/src/modules/user/user.service.ts +++ b/apps/api/src/modules/user/user.service.ts @@ -5,13 +5,14 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; import * as bcrypt from 'bcrypt'; import { User } from './user.entity'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { QueryUserDto } from './dto/query-user.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; @Injectable() export class UserService { @@ -119,6 +120,88 @@ export class UserService { }); } + /** + * 查询用户(支持多种查询条件和分页) + */ + async findAllPaginated(queryDto: QueryUserDto): Promise<{ + list: User[]; + pagination: PaginationInfo; + }> { + const where: FindOptionsWhere = {}; + + if (queryDto.username) { + where.username = queryDto.username; + } + + if (queryDto.nickname) { + where.nickname = queryDto.nickname; + } + + if (queryDto.email) { + where.email = queryDto.email; + } + + if (queryDto.phone) { + where.phone = queryDto.phone; + } + + if (queryDto.role) { + where.role = queryDto.role; + } + + if (queryDto.status) { + where.status = queryDto.status; + } + + // 分页参数 + const page = queryDto.page || 1; + const limit = queryDto.limit || 10; + const skip = (page - 1) * limit; + + // 排序字段映射 + const sortBy = queryDto.sortBy || 'createdAt'; + const sortOrder = queryDto.sortOrder || 'DESC'; + + // 构建排序对象 + const order: Record = {}; + if (sortBy === 'createdAt') { + order.createdAt = sortOrder; + } else if (sortBy === 'updatedAt') { + order.updatedAt = sortOrder; + } else if (sortBy === 'lastLoginAt') { + order.lastLoginAt = sortOrder; + } else { + order.createdAt = 'DESC'; + } + + // 添加默认排序 + order.userId = 'ASC'; + + // 查询总数 + const total = await this.userRepository.count({ where }); + + // 查询分页数据 + const list = await this.userRepository.find({ + where, + order, + skip, + take: limit, + }); + + // 计算总页数 + const total_page = Math.ceil(total / limit); + + return { + list, + pagination: { + total, + total_page, + page_size: limit, + current_page: page, + }, + }; + } + /** * 根据 username 或 email 查询单个用户 */ diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aba29b0..da7af4b 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,25 +1,28 @@ { - "compilerOptions": { - "module": "nodenext", - "moduleResolution": "nodenext", - "resolvePackageJsonExports": true, - "esModuleInterop": true, - "isolatedModules": true, - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2023", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "forceConsistentCasingInFileNames": true, - "noImplicitAny": false, - "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false - } + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + }, + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false + } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f3ab39e..6ad2ceb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,5 @@ import { RouterProvider } from 'react-router'; -import { ConfigProvider } from 'antd'; +import { ConfigProvider, App as AntdApp } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { router } from './router'; import './App.css'; @@ -15,7 +15,9 @@ function App() { }, }} > - + + + ); } diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..e4da582 --- /dev/null +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,73 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import ErrorPage from './ErrorPage'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): State { + return { + hasError: true, + error, + errorInfo: null, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // 记录错误信息 + console.error('ErrorBoundary 捕获到错误:', error, errorInfo); + + this.setState({ + error, + errorInfo, + }); + + // 可以在这里将错误发送到错误监控服务 + // 例如:Sentry, LogRocket 等 + } + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/apps/web/src/components/ErrorPage.css b/apps/web/src/components/ErrorPage.css new file mode 100644 index 0000000..0672eea --- /dev/null +++ b/apps/web/src/components/ErrorPage.css @@ -0,0 +1,130 @@ +.error-page { + min-height: calc(100vh - 64px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: #f9fafb; +} + +.error-page-card { + max-width: 600px; + width: 100%; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-radius: 12px; +} + +.error-page-content { + text-align: center; + padding: 20px; +} + +.error-icon { + font-size: 80px; + color: #8b5cf6; + margin-bottom: 24px; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.8; + transform: scale(1.05); + } +} + +.error-title { + color: #1f2937; + margin-bottom: 16px !important; +} + +.error-description { + color: #6b7280; + font-size: 16px; + line-height: 1.6; + margin-bottom: 24px; +} + +.error-message { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 24px; + text-align: left; +} + +.error-actions { + margin-top: 32px; + margin-bottom: 24px; +} + +.error-details { + margin-top: 24px; + text-align: left; +} + +.error-details-content { + max-height: 400px; + overflow-y: auto; +} + +.error-detail-section { + margin-bottom: 20px; +} + +.error-detail-section:last-child { + margin-bottom: 0; +} + +.error-detail-section h5 { + color: #1f2937; + margin-bottom: 8px; +} + +.error-stack { + background: #1f2937; + color: #f9fafb; + padding: 16px; + border-radius: 6px; + font-size: 12px; + line-height: 1.6; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; +} + +/* 响应式 */ +@media (max-width: 768px) { + .error-page { + padding: 16px; + } + + .error-icon { + font-size: 60px; + } + + .error-title { + font-size: 24px !important; + } + + .error-description { + font-size: 14px; + } + + .error-actions { + flex-direction: column; + width: 100%; + } + + .error-actions .ant-btn { + width: 100%; + } +} diff --git a/apps/web/src/components/ErrorPage.tsx b/apps/web/src/components/ErrorPage.tsx new file mode 100644 index 0000000..991f9b5 --- /dev/null +++ b/apps/web/src/components/ErrorPage.tsx @@ -0,0 +1,184 @@ +import { Button, Card, Typography, Space, Collapse } from 'antd'; +import { ReloadOutlined, HomeOutlined, BugOutlined, FileSearchOutlined } from '@ant-design/icons'; +import { useNavigate, useRouteError, isRouteErrorResponse } from 'react-router'; +import type { ErrorInfo } from 'react'; +import './ErrorPage.css'; + +const { Title, Paragraph, Text } = Typography; + +interface ErrorPageProps { + error?: Error | null; + errorInfo?: ErrorInfo | null; + onReset?: () => void; + is404?: boolean; +} + +const ErrorPage = ({ error: propError, errorInfo, onReset, is404: propIs404 }: ErrorPageProps) => { + const navigate = useNavigate(); + const routeError = useRouteError(); + + // 判断是否为 404 错误 + const is404 = + propIs404 || + (isRouteErrorResponse(routeError) && routeError.status === 404) || + (routeError instanceof Error && routeError.message.includes('404')); + + // 获取错误信息 + const error = propError || (routeError instanceof Error ? routeError : null); + const errorMessage = is404 + ? '页面未找到' + : isRouteErrorResponse(routeError) + ? routeError.statusText || '页面加载失败' + : error?.message || '未知错误'; + + const handleGoHome = () => { + navigate('/'); + if (onReset) { + onReset(); + } + }; + + const handleReload = () => { + window.location.reload(); + }; + + const handleGoBack = () => { + navigate(-1); + }; + + return ( +
+ +
+
+ {is404 ? : } +
+ + {is404 ? '页面未找到' : '哎呀,出错了!'} + + + {is404 ? ( + <> + 抱歉,您访问的页面不存在。 +
+ 请检查 URL 是否正确,或返回首页继续浏览。 + + ) : ( + <> + 应用遇到了一个意外错误,我们已经记录了这个问题。 +
+ 您可以尝试刷新页面或返回首页。 + + )} +
+ + {errorMessage && !is404 && ( +
+ + {errorMessage} + +
+ )} + + + {!is404 && ( + + )} + + {is404 && ( + + )} + + + {import.meta.env.DEV && + !is404 && + (error || + errorInfo || + (routeError !== null && routeError !== undefined)) && ( + + {error && ( +
+ 错误信息: +
+                                                            {error.stack || error.toString()}
+                                                        
+
+ )} + {isRouteErrorResponse(routeError) && ( +
+ 路由错误: +
+                                                            {`状态码: ${routeError.status}\n状态文本: ${routeError.statusText}\n数据: ${JSON.stringify(
+                                                                routeError.data as Record<
+                                                                    string,
+                                                                    unknown
+                                                                >,
+                                                                null,
+                                                                2
+                                                            )}`}
+                                                        
+
+ )} + {errorInfo && ( +
+ 组件堆栈: +
+                                                            {errorInfo.componentStack}
+                                                        
+
+ )} + {routeError !== null && + routeError !== undefined && + !(routeError instanceof Error) && + !isRouteErrorResponse(routeError) && ( +
+ 路由错误详情: +
+                                                                {JSON.stringify(
+                                                                    routeError as Record<
+                                                                        string,
+                                                                        unknown
+                                                                    >,
+                                                                    null,
+                                                                    2
+                                                                )}
+                                                            
+
+ )} +
+ ), + }, + ]} + /> + )} +
+ + + ); +}; + +export default ErrorPage; diff --git a/apps/web/src/layouts/MainLayout.css b/apps/web/src/layouts/MainLayout.css index c09e930..6e721fa 100644 --- a/apps/web/src/layouts/MainLayout.css +++ b/apps/web/src/layouts/MainLayout.css @@ -5,6 +5,8 @@ /* 侧边栏样式 */ .main-sider { background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%) !important; + display: flex; + flex-direction: column; } .sidebar-header { @@ -25,26 +27,7 @@ color: rgba(255, 255, 255, 0.8); } -.sidebar-menu { - background: transparent !important; - border: none; -} - -.sidebar-menu .ant-menu-item { - margin: 0 !important; - padding: 12px 20px !important; - height: auto !important; - line-height: 1.5 !important; -} - -.sidebar-menu .ant-menu-item-selected { - background: rgba(255, 255, 255, 0.2) !important; - border-left: 3px solid white; -} - -.sidebar-menu .ant-menu-item:hover { - background: rgba(255, 255, 255, 0.1) !important; -} +/* 侧边栏菜单样式已移至 SidebarMenu.css */ /* 顶部栏样式 */ .main-header { diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index ccb0771..dd4cb3d 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -1,10 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router'; -import { Layout, Menu, Avatar, Badge, Drawer, Dropdown, Button, message } from 'antd'; +import { Layout, Avatar, Badge, Drawer, Dropdown, message } from 'antd'; import { - BarChartOutlined, - FileTextOutlined, - EditOutlined, MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, @@ -13,23 +10,14 @@ import { import type { MenuProps } from 'antd'; import { authService } from '@/services/auth'; import type { UserInfo } from '@/types/user'; +import SidebarMenu from './SidebarMenu'; +import ErrorBoundary from '@/components/ErrorBoundary'; +import { getPageInfo } from './menuConfig'; import './MainLayout.css'; const { Header, Sider, Content } = Layout; -interface MainLayoutProps { - isAdmin?: boolean; -} - -// 页面标题映射 -const pageTitles: Record = { - '/': { title: '资产账户', subtitle: '买股票就是买公司' }, - '/assets': { title: '资产账户', subtitle: '买股票就是买公司' }, - '/plans': { title: '交易计划', subtitle: '计划你的交易,交易你的计划' }, - '/review': { title: '投资复盘', subtitle: '回顾过去是为了更好应对将来' }, -}; - -const MainLayout = ({ isAdmin = false }: MainLayoutProps) => { +const MainLayout = () => { const [collapsed, setCollapsed] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isMobile, setIsMobile] = useState(false); @@ -45,7 +33,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => { // 根据路由获取页面标题 const pageInfo = useMemo(() => { - return pageTitles[location.pathname] || pageTitles['/']; + return getPageInfo(location.pathname); }, [location.pathname]); // 检测移动端 @@ -62,33 +50,6 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => { return () => window.removeEventListener('resize', checkMobile); }, []); - // 菜单项配置 - const menuItems: MenuProps['items'] = [ - { - key: '/assets', - icon: , - label: '资产账户', - }, - { - key: '/plans', - icon: , - label: '交易计划', - }, - { - key: '/review', - icon: , - label: '投资复盘', - }, - ]; - - // 处理菜单点击 - const handleMenuClick = ({ key }: { key: string }) => { - navigate(key); - if (isMobile) { - setMobileMenuOpen(false); - } - }; - // 处理登出 const handleLogout = () => { authService.logout(); @@ -134,15 +95,6 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => { return 'success'; }; - // 获取当前选中的菜单项 - const selectedKeys = useMemo(() => { - const path = location.pathname; - if (path === '/' || path === '/assets') { - return ['/assets']; - } - return [path]; - }, [location.pathname]); - return ( {/* 桌面端侧边栏 */} @@ -165,14 +117,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
{collapsed ? '投' : '投小记'}
{!collapsed &&
VestMind
} - + )} @@ -186,12 +131,10 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => { bodyStyle={{ padding: 0 }} width={240} > - setMobileMenuOpen(false)} /> )} @@ -258,7 +201,9 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => { {/* 内容区域 */} - + + + diff --git a/apps/web/src/layouts/SidebarMenu.css b/apps/web/src/layouts/SidebarMenu.css new file mode 100644 index 0000000..6e63b08 --- /dev/null +++ b/apps/web/src/layouts/SidebarMenu.css @@ -0,0 +1,155 @@ +.sidebar-menu-container { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.sidebar-menu { + background: transparent !important; + border: none; + height: 100%; + overflow-y: auto; + overflow-x: hidden; +} + +.sidebar-menu .ant-menu-item { + margin: 0 !important; + padding: 12px 20px !important; + height: auto !important; + line-height: 1.5 !important; + transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: all 0.3s !important; +} + +/* 菜单文本样式 - 加大字体并加粗 */ +.sidebar-menu .ant-menu-item { + font-size: 15px !important; + font-weight: 600 !important; +} + +.sidebar-menu .ant-menu-item .ant-menu-title-content { + font-size: 15px !important; + font-weight: 600 !important; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: inline-block; +} + +/* hover状态 - 不缩进,只改变背景 */ +.sidebar-menu .ant-menu-item:hover { + background: rgba(255, 255, 255, 0.1) !important; +} + +.sidebar-menu .ant-menu-item:hover .ant-menu-title-content { + transform: translateX(0); +} + +/* 选中状态 - 文本缩进 */ +.sidebar-menu .ant-menu-item-selected { + background: rgba(255, 255, 255, 0.2) !important; + border-left: 3px solid white; +} + +.sidebar-menu .ant-menu-item-selected .ant-menu-title-content { + transform: translateX(8px); +} + +.sidebar-menu .ant-menu-item-selected:hover { + background: rgba(255, 255, 255, 0.25) !important; +} + +.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content { + transform: translateX(8px); +} + +/* 菜单分组标题 */ +.sidebar-menu .ant-menu-item-group-title { + padding: 12px 20px 8px !important; + font-size: 12px !important; + color: rgba(255, 255, 255, 0.7) !important; + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 500; + transition: + opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + height 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar-menu .menu-section-title .ant-menu-item-group-title { + opacity: 1; +} + +/* 折叠状态下隐藏分组标题 */ +.sidebar-menu .ant-menu-item-group-title:empty { + opacity: 0; + height: 0; + padding: 0; + margin: 0; + overflow: hidden; +} + +/* 分隔线 */ +.sidebar-menu .ant-menu-item-divider { + height: 1px; + background: rgba(255, 255, 255, 0.1) !important; + margin: 8px 20px !important; + border: none; +} + +.sidebar-menu .menu-divider { + height: 1px; + background: rgba(255, 255, 255, 0.1) !important; + margin: 8px 20px !important; + border: none; +} + +/* 管理员功能区域动画 */ +.sidebar-menu .admin-section { + animation: fadeInDown 0.4s ease-out; +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 菜单项图标样式和动画 */ +.sidebar-menu .ant-menu-item-icon { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-size: 18px !important; + display: inline-flex; + align-items: center; +} + +/* hover状态 - 图标不缩进,只轻微放大 */ +.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon { + transform: scale(1.05); +} + +/* 选中状态下图标也跟随文本缩进 */ +.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon { + transform: translateX(8px); +} + +.sidebar-menu .ant-menu-item-selected:hover .ant-menu-item-icon { + transform: translateX(8px) scale(1.05); +} + +/* 折叠状态下的样式调整 */ +.sidebar-menu .ant-menu-item-group-title { + transition: opacity 0.2s ease; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .sidebar-menu .ant-menu-item { + padding: 14px 20px !important; + } +} diff --git a/apps/web/src/layouts/SidebarMenu.tsx b/apps/web/src/layouts/SidebarMenu.tsx new file mode 100644 index 0000000..f120e8a --- /dev/null +++ b/apps/web/src/layouts/SidebarMenu.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router'; +import { Menu } from 'antd'; +import type { MenuProps } from 'antd'; +import type { UserInfo } from '@/types/user'; +import { getMainMenuItems, getAdminMenuItems } from './menuConfig'; +import './SidebarMenu.css'; + +interface SidebarMenuProps { + collapsed: boolean; + user: UserInfo | null; + onMenuClick?: () => void; +} + +const SidebarMenu = ({ collapsed, user, onMenuClick }: SidebarMenuProps) => { + const location = useLocation(); + const navigate = useNavigate(); + + // 判断是否为管理员 + const isAdmin = useMemo(() => { + return user?.role === 'admin' || user?.role === 'super_admin'; + }, [user?.role]); + + // 合并菜单项(带分组) + const menuItems: MenuProps['items'] = useMemo(() => { + // 获取主要功能菜单项 + const mainMenuConfigs = getMainMenuItems(); + const mainMenuItems: MenuProps['items'] = mainMenuConfigs.map((config) => ({ + key: config.key, + icon: config.icon, + label: config.label, + })); + + // 获取管理员功能菜单项 + const adminMenuConfigs = getAdminMenuItems(); + const adminMenuItems: MenuProps['items'] = adminMenuConfigs.map((config) => ({ + key: config.key, + icon: config.icon, + label: config.label, + })); + + const items: MenuProps['items'] = [ + { + type: 'group', + label: collapsed ? '' : '主要功能', + className: 'menu-section-title', + children: mainMenuItems, + }, + ]; + + // 如果是管理员,添加分隔线和管理员功能 + if (isAdmin && adminMenuItems.length > 0) { + items.push({ + type: 'divider', + className: 'menu-divider', + }); + items.push({ + type: 'group', + label: collapsed ? '' : '管理员功能', + className: 'menu-section-title admin-section', + children: adminMenuItems, + }); + } + + return items; + }, [collapsed, isAdmin]); + + // 处理菜单点击 + const handleMenuClick = ({ key }: { key: string }) => { + navigate(key); + if (onMenuClick) { + onMenuClick(); + } + }; + + // 获取当前选中的菜单项 + const selectedKeys = useMemo(() => { + const path = location.pathname; + if (path === '/' || path === '/assets') { + return ['/assets']; + } + return [path]; + }, [location.pathname]); + + return ( +
+ +
+ ); +}; + +export default SidebarMenu; diff --git a/apps/web/src/layouts/menuConfig.tsx b/apps/web/src/layouts/menuConfig.tsx new file mode 100644 index 0000000..a242c1e --- /dev/null +++ b/apps/web/src/layouts/menuConfig.tsx @@ -0,0 +1,155 @@ +import { + BarChartOutlined, + FileTextOutlined, + EditOutlined, + SettingOutlined, + DashboardOutlined, + BankOutlined, + UserOutlined, +} from '@ant-design/icons'; +import type { ReactNode } from 'react'; + +/** + * 路由菜单配置项 + */ +export interface RouteMenuConfig { + /** 路由路径 */ + path: string; + /** 菜单键值(通常与 path 相同) */ + key: string; + /** 菜单图标 */ + icon: ReactNode; + /** 菜单标签 */ + label: string; + /** 页面标题 */ + title: string; + /** 页面副标题 */ + subtitle: string; + /** 菜单分组:'main' 主要功能,'admin' 管理员功能 */ + group: 'main' | 'admin'; + /** 是否需要管理员权限 */ + requireAdmin?: boolean; +} + +/** + * 路由菜单配置列表 + */ +export const routeMenuConfig: RouteMenuConfig[] = [ + { + path: '/assets', + key: '/assets', + icon: , + label: '资产账户', + title: '资产账户', + subtitle: '买股票就是买公司', + group: 'main', + }, + { + path: '/plans', + key: '/plans', + icon: , + label: '交易计划', + title: '交易计划', + subtitle: '计划你的交易,交易你的计划', + group: 'main', + }, + { + path: '/review', + key: '/review', + icon: , + label: '投资复盘', + title: '投资复盘', + subtitle: '回顾过去是为了更好应对将来', + group: 'main', + }, + { + path: '/user', + key: '/user', + icon: , + label: '用户管理', + title: '用户管理', + subtitle: '管理用户信息', + group: 'admin', + requireAdmin: true, + }, + { + path: '/broker', + key: '/broker', + icon: , + label: '券商管理', + title: '券商管理', + subtitle: '管理券商信息', + group: 'admin', + requireAdmin: true, + }, + { + path: '/seo', + key: '/seo', + icon: , + label: 'SEO配置', + title: 'SEO配置', + subtitle: '优化搜索引擎可见性', + group: 'admin', + requireAdmin: true, + }, + { + path: '/analytics', + key: '/analytics', + icon: , + label: '数据统计', + title: '数据统计', + subtitle: '了解用户行为与系统数据', + group: 'admin', + requireAdmin: true, + }, +]; + +/** + * 根据路径获取页面标题信息 + */ +export const getPageInfo = (path: string): { title: string; subtitle: string } => { + // 处理根路径 + if (path === '/' || path === '') { + const defaultRoute = routeMenuConfig.find((item) => item.path === '/assets'); + return defaultRoute + ? { title: defaultRoute.title, subtitle: defaultRoute.subtitle } + : { title: '资产账户', subtitle: '买股票就是买公司' }; + } + + const config = routeMenuConfig.find((item) => item.path === path); + return config + ? { title: config.title, subtitle: config.subtitle } + : { title: '资产账户', subtitle: '买股票就是买公司' }; +}; + +/** + * 获取主要功能菜单项 + */ +export const getMainMenuItems = () => { + return routeMenuConfig.filter((item) => item.group === 'main'); +}; + +/** + * 获取管理员功能菜单项 + */ +export const getAdminMenuItems = () => { + return routeMenuConfig.filter((item) => item.group === 'admin' && item.requireAdmin); +}; + +/** + * 页面标题映射(用于向后兼容) + */ +export const pageTitles: Record = (() => { + const titles: Record = { + '/': getPageInfo('/assets'), + }; + + routeMenuConfig.forEach((config) => { + titles[config.path] = { + title: config.title, + subtitle: config.subtitle, + }; + }); + + return titles; +})(); diff --git a/apps/web/src/pages/broker/BrokerFormModal.tsx b/apps/web/src/pages/broker/BrokerFormModal.tsx new file mode 100644 index 0000000..06f7875 --- /dev/null +++ b/apps/web/src/pages/broker/BrokerFormModal.tsx @@ -0,0 +1,172 @@ +import { useEffect } from 'react'; +import { Modal, Form, Input, Select, Upload, message } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import type { UploadProps } from 'antd'; +import { brokerService } from '@/services/broker'; +import type { Broker, CreateBrokerRequest } from '@/types/broker'; +import { REGION_OPTIONS } from '@/types/broker'; + +const { Option } = Select; + +interface BrokerFormModalProps { + visible: boolean; + editingBroker: Broker | null; + onCancel: () => void; + onSuccess: () => void; +} + +const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: BrokerFormModalProps) => { + const [form] = Form.useForm(); + const isEdit = !!editingBroker; + + // 当编辑数据变化时,更新表单 + useEffect(() => { + if (visible) { + if (editingBroker) { + form.setFieldsValue({ + brokerCode: editingBroker.brokerCode, + brokerName: editingBroker.brokerName, + region: editingBroker.region, + brokerImage: editingBroker.brokerImage, + }); + } else { + form.resetFields(); + } + } + }, [visible, editingBroker, form]); + + // 图片上传配置(仅UI,不上传) + const uploadProps: UploadProps = { + name: 'file', + listType: 'picture-card', + maxCount: 1, + beforeUpload: () => { + // 阻止自动上传 + return false; + }, + onChange: (info) => { + if (info.file.status === 'done') { + message.success(`${info.file.name} 文件上传成功`); + } else if (info.file.status === 'error') { + message.error(`${info.file.name} 文件上传失败`); + } + }, + onRemove: () => { + form.setFieldValue('brokerImage', ''); + }, + }; + + // 处理图片变化(仅UI,实际应该上传后获取URL) + const handleImageChange = (info: any) => { + // 这里只做UI处理,实际应该调用上传接口获取图片URL + // 临时处理:使用本地预览 + if (info.file) { + const reader = new FileReader(); + reader.onload = () => { + // 实际应该调用上传接口,这里只是示例 + // form.setFieldValue('brokerImage', uploadUrl); + }; + reader.readAsDataURL(info.file); + } + }; + + // 提交表单 + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + const formData: CreateBrokerRequest = { + brokerCode: values.brokerCode, + brokerName: values.brokerName, + region: values.region, + brokerImage: values.brokerImage || undefined, + }; + + if (isEdit && editingBroker) { + await brokerService.updateBroker(editingBroker.brokerId, formData); + message.success('更新成功'); + } else { + await brokerService.createBroker(formData); + message.success('创建成功'); + } + + onSuccess(); + } catch (error: any) { + if (error.errorFields) { + // 表单验证错误 + return; + } + message.error(error.message || (isEdit ? '更新失败' : '创建失败')); + } + }; + + return ( + +
+ + + + + + + + + + + + + + + + + + +
+ +
上传
+
+
+
+ 注意:上传功能仅做UI演示,实际需要调用上传接口获取图片URL +
+
+
+
+ ); +}; + +export default BrokerFormModal; diff --git a/apps/web/src/pages/broker/BrokerPage.css b/apps/web/src/pages/broker/BrokerPage.css new file mode 100644 index 0000000..0b913a6 --- /dev/null +++ b/apps/web/src/pages/broker/BrokerPage.css @@ -0,0 +1,38 @@ +.broker-page { + padding: 0; +} + +.broker-search-form { + margin-bottom: 16px; +} + +.broker-search-form .ant-form-item { + margin-bottom: 16px; +} + +.broker-action-bar { + margin-bottom: 16px; + display: flex; + justify-content: flex-end; +} + +/* 表格样式优化 */ +.broker-page .ant-table { + background: #fff; +} + +.broker-page .ant-table-thead > tr > th { + background: #fafafa; + font-weight: 600; +} + +/* 响应式 */ +@media (max-width: 768px) { + .broker-search-form .ant-form-item { + margin-bottom: 12px; + } + + .broker-action-bar { + margin-bottom: 12px; + } +} diff --git a/apps/web/src/pages/broker/BrokerPage.tsx b/apps/web/src/pages/broker/BrokerPage.tsx new file mode 100644 index 0000000..34a512d --- /dev/null +++ b/apps/web/src/pages/broker/BrokerPage.tsx @@ -0,0 +1,328 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Table, + Button, + Input, + Select, + Space, + Image, + Popconfirm, + Card, + Form, + Row, + Col, + App as AntdApp, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, + SearchOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { brokerService } from '@/services/broker'; +import type { Broker, QueryBrokerRequest } from '@/types/broker'; +import { REGION_OPTIONS, getRegionText } from '@/types/broker'; +import BrokerFormModal from './BrokerFormModal'; +import './BrokerPage.css'; + +const { Option } = Select; + +const BrokerPage = () => { + const { message: messageApi } = AntdApp.useApp(); + const [brokers, setBrokers] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: 0, + }); + const [form] = Form.useForm(); + const [modalVisible, setModalVisible] = useState(false); + const [editingBroker, setEditingBroker] = useState(null); + const formRef = useRef({}); + + // 加载数据 + const loadData = async (params?: QueryBrokerRequest, resetPage = false) => { + setLoading(true); + try { + const currentPage = resetPage ? 1 : pagination.current; + const pageSize = pagination.pageSize; + + const queryParams: QueryBrokerRequest = { + page: currentPage, + limit: pageSize, + sortBy: 'createdAt', + sortOrder: 'DESC', + ...formRef.current, + ...params, + }; + + const response = await brokerService.getBrokerList(queryParams); + + setBrokers(response.list); + setPagination((prev) => ({ + ...prev, + current: response.pagination.current_page, + pageSize: response.pagination.page_size, + total: response.pagination.total, + })); + } catch (error: any) { + messageApi.error(error.message || '加载券商列表失败'); + } finally { + setLoading(false); + } + }; + + // 初始加载 + useEffect(() => { + loadData({}, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 当分页改变时,重新加载数据 + useEffect(() => { + // 避免初始加载时重复请求 + if (pagination.current > 0 && pagination.pageSize > 0) { + loadData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination.current, pagination.pageSize]); + + // 查询 + const handleSearch = () => { + const values = form.getFieldsValue(); + formRef.current = { + brokerCode: values.brokerCode || undefined, + brokerName: values.brokerName || undefined, + region: values.region || undefined, + }; + loadData(formRef.current, true); + }; + + // 重置 + const handleReset = () => { + form.resetFields(); + formRef.current = {}; + loadData({}, true); + }; + + // 新建 + const handleCreate = () => { + setEditingBroker(null); + setModalVisible(true); + }; + + // 编辑 + const handleEdit = (record: Broker) => { + setEditingBroker(record); + setModalVisible(true); + }; + + // 删除 + const handleDelete = async (id: number) => { + try { + await brokerService.deleteBroker(id); + messageApi.success('删除成功'); + loadData(); + } catch (error: any) { + messageApi.error(error.message || '删除失败'); + } + }; + + // 保存成功回调 + const handleSaveSuccess = () => { + setModalVisible(false); + setEditingBroker(null); + loadData(); + }; + + // 表格列定义 + const columns: ColumnsType = [ + { + title: '券商Logo', + dataIndex: 'brokerImage', + key: 'brokerImage', + width: 100, + render: (image: string) => { + if (image) { + return ( + 券商Logo + ); + } + return ( +
+ Logo +
+ ); + }, + }, + { + title: '券商代码', + dataIndex: 'brokerCode', + key: 'brokerCode', + width: 120, + }, + { + title: '券商名称', + dataIndex: 'brokerName', + key: 'brokerName', + width: 200, + }, + { + title: '地区', + dataIndex: 'region', + key: 'region', + width: 120, + render: (region: string) => getRegionText(region), + }, + { + title: '操作', + key: 'action', + width: 150, + fixed: 'right', + render: (_: any, record: Broker) => ( + + + handleDelete(record.brokerId)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ]; + + return ( +
+ + {/* 查询表单 */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* 操作栏 */} +
+ +
+ + {/* 表格 */} + `共 ${total} 条`, + onChange: (page, pageSize) => { + setPagination((prev) => ({ + ...prev, + current: page, + pageSize: pageSize || 10, + })); + }, + }} + scroll={{ x: 800 }} + /> + + + {/* 新建/编辑弹窗 */} + { + setModalVisible(false); + setEditingBroker(null); + }} + onSuccess={handleSaveSuccess} + /> + + ); +}; + +export default BrokerPage; diff --git a/apps/web/src/pages/broker/index.ts b/apps/web/src/pages/broker/index.ts new file mode 100644 index 0000000..a16a72f --- /dev/null +++ b/apps/web/src/pages/broker/index.ts @@ -0,0 +1 @@ +export { default } from './BrokerPage'; diff --git a/apps/web/src/pages/user/UserDetailModal.tsx b/apps/web/src/pages/user/UserDetailModal.tsx new file mode 100644 index 0000000..1035914 --- /dev/null +++ b/apps/web/src/pages/user/UserDetailModal.tsx @@ -0,0 +1,137 @@ +import { Modal, Descriptions, Avatar, Tag, Image, Space, Button, Popconfirm } from 'antd'; +import { UserOutlined, DeleteOutlined } from '@ant-design/icons'; +import type { User } from '@/types/user'; +import { getRoleText, getStatusText } from '@/types/user'; +import dayjs from 'dayjs'; + +interface UserDetailModalProps { + visible: boolean; + user: User | null; + onCancel: () => void; + onDelete?: (userId: number) => void; +} + +const UserDetailModal = ({ visible, user, onCancel, onDelete }: UserDetailModalProps) => { + if (!user) { + return null; + } + + const isFrozen = user.status === 'inactive'; + const isSuperAdmin = user.role === 'super_admin'; + const canDelete = isFrozen && !isSuperAdmin && onDelete; + + return ( + + 关闭 + , + canDelete && ( + onDelete?.(user.userId)} + okText="确定" + cancelText="取消" + > + + + ), + ].filter(Boolean)} + width={700} + > + {/* 头像和昵称区域 */} +
+ + {user.avatarUrl ? ( + + ) : ( + } /> + )} + + {user.nickname || user.username} + + +
+ + + {user.userId} + + {user.username} + + {user.email} + + {user.phone || '-'} + + + + {getRoleText(user.role)} + + + + + + {getStatusText(user.status)} + + + + {user.openId && ( + {user.openId} + )} + + {user.unionId && ( + {user.unionId} + )} + + + {dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + + {dayjs(user.updatedAt).format('YYYY-MM-DD HH:mm:ss')} + + + + {user.lastLoginAt ? dayjs(user.lastLoginAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + + +
+ ); +}; + +export default UserDetailModal; diff --git a/apps/web/src/pages/user/UserPage.css b/apps/web/src/pages/user/UserPage.css new file mode 100644 index 0000000..1d61155 --- /dev/null +++ b/apps/web/src/pages/user/UserPage.css @@ -0,0 +1,7 @@ +.user-page .user-search-form { + margin-bottom: 16px; +} + +.user-page .user-search-form .ant-form-item { + margin-bottom: 16px; +} diff --git a/apps/web/src/pages/user/UserPage.tsx b/apps/web/src/pages/user/UserPage.tsx new file mode 100644 index 0000000..8a8b4cb --- /dev/null +++ b/apps/web/src/pages/user/UserPage.tsx @@ -0,0 +1,431 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Table, + Button, + Input, + Select, + Space, + Avatar, + Popconfirm, + Card, + Form, + Row, + Col, + App as AntdApp, + Tag, + Image, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + SearchOutlined, + ReloadOutlined, + EyeOutlined, + LockOutlined, + UnlockOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { userService } from '@/services/user'; +import type { User, QueryUserRequest } from '@/types/user'; +import { USER_ROLE_OPTIONS, getRoleText, getStatusText, USER_STATUS_OPTIONS } from '@/types/user'; +import UserDetailModal from './UserDetailModal'; +import dayjs from 'dayjs'; +import './UserPage.css'; + +const { Option } = Select; + +const UserPage = () => { + const { message: messageApi } = AntdApp.useApp(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: 0, + }); + const [form] = Form.useForm(); + const [detailModalVisible, setDetailModalVisible] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const formRef = useRef({}); + + // 加载数据 + const loadData = async (params?: QueryUserRequest, resetPage = false) => { + setLoading(true); + try { + const currentPage = resetPage ? 1 : pagination.current; + const pageSize = pagination.pageSize; + + const queryParams: QueryUserRequest = { + page: currentPage, + limit: pageSize, + sortBy: 'createdAt', + sortOrder: 'DESC', + ...formRef.current, + ...params, + }; + + const response = await userService.getUserList(queryParams); + + setUsers(response.list); + setPagination((prev) => ({ + ...prev, + current: response.pagination.current_page, + pageSize: response.pagination.page_size, + total: response.pagination.total, + })); + } catch (error: any) { + messageApi.error(error.message || '加载用户列表失败'); + } finally { + setLoading(false); + } + }; + + // 初始加载 + useEffect(() => { + loadData({}, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 当分页改变时,重新加载数据 + useEffect(() => { + // 避免初始加载时重复请求 + if (pagination.current > 0 && pagination.pageSize > 0) { + loadData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination.current, pagination.pageSize]); + + // 查询 + const handleSearch = () => { + const values = form.getFieldsValue(); + formRef.current = { + username: values.username || undefined, + nickname: values.nickname || undefined, + email: values.email || undefined, + phone: values.phone || undefined, + role: values.role || undefined, + status: values.status || undefined, + }; + loadData(formRef.current, true); + }; + + // 重置 + const handleReset = () => { + form.resetFields(); + formRef.current = {}; + loadData({}, true); + }; + + // 查看详情 + const handleView = (record: User) => { + setSelectedUser(record); + setDetailModalVisible(true); + }; + + // 冻结/解冻 + const handleToggleStatus = async (user: User) => { + try { + const newStatus = user.status === 'active' ? 'inactive' : 'active'; + await userService.updateUserStatus(user.userId, newStatus); + messageApi.success(newStatus === 'inactive' ? '用户已冻结' : '用户已解冻'); + loadData(); + } catch (error: any) { + messageApi.error(error.message || '操作失败'); + } + }; + + // 删除 + const handleDelete = async (id: number) => { + try { + await userService.deleteUser(id); + messageApi.success('删除成功'); + loadData(); + } catch (error: any) { + messageApi.error(error.message || '删除失败'); + } + }; + + // 表格列定义 + const columns: ColumnsType = [ + { + title: '用户头像', + dataIndex: 'avatarUrl', + key: 'avatarUrl', + width: 80, + render: (avatarUrl: string) => { + if (avatarUrl) { + return ( + + ); + } + return } style={{ fontSize: 12 }} />; + }, + }, + { + title: '用户名', + dataIndex: 'username', + key: 'username', + width: 120, + }, + { + title: '昵称', + dataIndex: 'nickname', + key: 'nickname', + width: 120, + render: (nickname: string) => nickname || '-', + }, + { + title: '邮箱', + dataIndex: 'email', + key: 'email', + width: 180, + }, + { + title: '电话', + dataIndex: 'phone', + key: 'phone', + width: 120, + render: (phone: string) => phone || '-', + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 160, + render: (date: Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '最后登录时间', + dataIndex: 'lastLoginAt', + key: 'lastLoginAt', + width: 160, + render: (date: Date | undefined) => + date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: string, _record: User) => ( + + {getStatusText(status)} + + ), + }, + { + title: '角色', + dataIndex: 'role', + key: 'role', + width: 120, + render: (role: string) => ( + + {getRoleText(role)} + + ), + }, + { + title: '操作', + key: 'action', + width: 180, + fixed: 'right', + render: (_: any, record: User) => { + const isFrozen = record.status === 'inactive'; + const isSuperAdmin = record.role === 'super_admin'; + return ( + + + {/* 超级管理员不允许冻结 */} + {!isSuperAdmin && ( + <> + {isFrozen ? ( + handleToggleStatus(record)} + okText="确定" + cancelText="取消" + > + + + ) : ( + handleToggleStatus(record)} + okText="确定" + cancelText="取消" + > + + + )} + + )} + + ); + }, + }, + ]; + + return ( +
+ + {/* 查询表单 */} +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 表格 */} +
`共 ${total} 条`, + onChange: (page, pageSize) => { + setPagination((prev) => ({ + ...prev, + current: page, + pageSize: pageSize || 10, + })); + }, + }} + scroll={{ x: 1500 }} + /> + + + {/* 用户详情弹窗 */} + { + setDetailModalVisible(false); + setSelectedUser(null); + }} + onDelete={async (userId: number) => { + try { + await handleDelete(userId); + setDetailModalVisible(false); + setSelectedUser(null); + } catch (error) { + // 错误已在 handleDelete 中处理 + } + }} + /> + + ); +}; + +export default UserPage; diff --git a/apps/web/src/pages/user/index.ts b/apps/web/src/pages/user/index.ts new file mode 100644 index 0000000..da07147 --- /dev/null +++ b/apps/web/src/pages/user/index.ts @@ -0,0 +1 @@ +export { default } from './UserPage'; diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index aedabec..b7d5483 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -1,15 +1,20 @@ +import { lazy } from 'react'; import { createBrowserRouter } from 'react-router'; import MainLayout from '../layouts/MainLayout'; import ProtectedRoute from '../components/ProtectedRoute'; +import ErrorPage from '../components/ErrorPage'; import LoginPage from '../pages/LoginPage'; -import AssetsPage from '../pages/AssetsPage'; -import PlansPage from '../pages/PlansPage'; -import ReviewPage from '../pages/ReviewPage'; +const AssetsPage = lazy(() => import('../pages/AssetsPage')); +const PlansPage = lazy(() => import('../pages/PlansPage')); +const ReviewPage = lazy(() => import('../pages/ReviewPage')); +const BrokerPage = lazy(() => import('../pages/broker')); +const UserPage = lazy(() => import('@/pages/user')); export const router = createBrowserRouter([ { path: '/login', element: , + errorElement: , }, { path: '/', @@ -18,6 +23,7 @@ export const router = createBrowserRouter([ ), + errorElement: , children: [ { index: true, @@ -35,6 +41,18 @@ export const router = createBrowserRouter([ path: 'review', element: , }, + { + path: 'broker', + element: , + }, + { + path: 'user', + element: , + }, ], }, + { + path: '*', + element: , + }, ]); diff --git a/apps/web/src/services/broker.ts b/apps/web/src/services/broker.ts new file mode 100644 index 0000000..9a31141 --- /dev/null +++ b/apps/web/src/services/broker.ts @@ -0,0 +1,59 @@ +import { api } from './api'; +import type { + Broker, + CreateBrokerRequest, + UpdateBrokerRequest, + QueryBrokerRequest, + PaginatedBrokerResponse, + ApiResponse, +} from '@/types/broker'; + +/** + * 券商服务 + */ +class BrokerService { + /** + * 查询券商列表(分页) + */ + async getBrokerList(params: QueryBrokerRequest): Promise { + // api.get 返回的是 TransformInterceptor 处理后的 { code, message, data } + // 其中 data 就是 PaginatedBrokerResponse + const response = await api.get>('/broker', { params }); + // 如果 response 已经是 PaginatedBrokerResponse 格式,直接返回 + if ('list' in response && 'pagination' in response) { + return response as PaginatedBrokerResponse; + } + // 否则从 ApiResponse 中取 data + return (response as ApiResponse).data; + } + + /** + * 根据ID查询券商 + */ + async getBrokerById(id: number): Promise { + return await api.get(`/broker/${id}`); + } + + /** + * 创建券商 + */ + async createBroker(data: CreateBrokerRequest): Promise { + return await api.post('/broker', data); + } + + /** + * 更新券商 + */ + async updateBroker(id: number, data: UpdateBrokerRequest): Promise { + return await api.patch(`/broker/${id}`, data); + } + + /** + * 删除券商 + */ + async deleteBroker(id: number): Promise { + await api.delete(`/broker/${id}`); + } +} + +export const brokerService = new BrokerService(); diff --git a/apps/web/src/services/user.ts b/apps/web/src/services/user.ts new file mode 100644 index 0000000..cd1509e --- /dev/null +++ b/apps/web/src/services/user.ts @@ -0,0 +1,46 @@ +import { api } from './api'; +import type { User, QueryUserRequest, PaginatedUserResponse } from '@/types/user'; +import type { ApiResponse } from '@/types/common'; + +/** + * 用户服务 + */ +class UserService { + /** + * 查询用户列表(分页) + */ + async getUserList(params: QueryUserRequest): Promise { + const response = await api.get>('/user', { + params, + }); + // 如果 response 已经是 PaginatedUserResponse 格式,直接返回 + if ('list' in response && 'pagination' in response) { + return response as PaginatedUserResponse; + } + // 否则从 ApiResponse 中取 data + return (response as ApiResponse).data; + } + + /** + * 根据ID查询用户 + */ + async getUserById(id: number): Promise { + return await api.get(`/user/${id}`); + } + + /** + * 更新用户状态(冻结/解冻) + */ + async updateUserStatus(id: number, status: 'active' | 'inactive'): Promise { + return await api.patch(`/user/${id}`, { status }); + } + + /** + * 删除用户 + */ + async deleteUser(id: number): Promise { + await api.delete(`/user/${id}`); + } +} + +export const userService = new UserService(); diff --git a/apps/web/src/types/broker.ts b/apps/web/src/types/broker.ts new file mode 100644 index 0000000..d5ab1c5 --- /dev/null +++ b/apps/web/src/types/broker.ts @@ -0,0 +1,90 @@ +/** + * 券商信息接口 + */ +export interface Broker { + brokerId: number; + brokerCode: string; + brokerName: string; + brokerImage?: string; + region: string; + sortOrder?: number; + isActive?: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** + * 创建券商请求参数 + */ +export interface CreateBrokerRequest { + brokerCode: string; + brokerName: string; + brokerImage?: string; + region: string; + sortOrder?: number; + isActive?: boolean; +} + +/** + * 更新券商请求参数 + */ +export interface UpdateBrokerRequest extends Partial {} + +/** + * 查询券商请求参数 + */ +export interface QueryBrokerRequest { + brokerId?: number; + brokerCode?: string; + brokerName?: string; + region?: string; + isActive?: boolean; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * 分页信息 + */ +export interface PaginationInfo { + total: number; + total_page: number; + page_size: number; + current_page: number; +} + +/** + * 分页响应数据 + */ +export interface PaginatedBrokerResponse { + list: Broker[]; + pagination: PaginationInfo; +} + +/** + * API 响应格式 + */ +export interface ApiResponse { + code: number; + message: string; + data: T; +} + +/** + * 地区选项 + */ +export const REGION_OPTIONS = [ + { label: '中国大陆', value: 'CN' }, + { label: '香港', value: 'HK' }, + { label: '美国', value: 'US' }, +] as const; + +/** + * 获取地区显示文本 + */ +export const getRegionText = (region: string): string => { + const option = REGION_OPTIONS.find((opt) => opt.value === region); + return option ? option.label : region; +}; diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index 8c70ddb..d02046d 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -17,6 +17,11 @@ export interface UserInfo { lastLoginAt?: Date; } +/** + * 用户类型(用于列表展示,与UserInfo相同) + */ +export type User = UserInfo; + /** * 登录请求参数 */ @@ -32,3 +37,67 @@ export interface LoginResponse { accessToken: string; user: UserInfo; } + +/** + * 查询用户请求参数 + */ +export interface QueryUserRequest { + username?: string; + nickname?: string; + email?: string; + phone?: string; + role?: string; + status?: string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * 分页响应数据 + */ +export interface PaginatedUserResponse { + list: User[]; + pagination: { + total: number; + total_page: number; + page_size: number; + current_page: number; + }; +} + +/** + * 用户角色选项 + */ +export const USER_ROLE_OPTIONS = [ + { label: '不限', value: '' }, + { label: '普通用户', value: 'user' }, + { label: '管理员', value: 'admin' }, + { label: '超级管理员', value: 'super_admin' }, +] as const; + +/** + * 用户状态选项 + */ +export const USER_STATUS_OPTIONS = [ + { label: '不限', value: '' }, + { label: '活跃', value: 'active' }, + { label: '冻结', value: 'inactive' }, +] as const; + +/** + * 获取角色显示文本 + */ +export const getRoleText = (role: string): string => { + const option = USER_ROLE_OPTIONS.find((opt) => opt.value === role); + return option ? option.label : role; +}; + +/** + * 获取状态显示文本 + */ +export const getStatusText = (status: string): string => { + const option = USER_STATUS_OPTIONS.find((opt) => opt.value === status); + return option ? option.label : status; +};