feat: 完成券商和用户管理
This commit is contained in:
29
apps/api/src/common/dto/pagination.dto.ts
Normal file
29
apps/api/src/common/dto/pagination.dto.ts
Normal file
@@ -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<T> {
|
||||||
|
@ApiProperty({ description: '数据列表' })
|
||||||
|
list: T[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
@@ -9,14 +9,25 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} 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 { BrokerService } from './broker.service';
|
||||||
import { CreateBrokerDto } from './dto/create-broker.dto';
|
import { CreateBrokerDto } from './dto/create-broker.dto';
|
||||||
import { UpdateBrokerDto } from './dto/update-broker.dto';
|
import { UpdateBrokerDto } from './dto/update-broker.dto';
|
||||||
import { QueryBrokerDto } from './dto/query-broker.dto';
|
import { QueryBrokerDto } from './dto/query-broker.dto';
|
||||||
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
||||||
|
import { PaginatedBrokerData } from './dto/paginated-response.dto';
|
||||||
import { Broker } from './broker.entity';
|
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')
|
@ApiTags('broker')
|
||||||
@Controller('broker')
|
@Controller('broker')
|
||||||
@@ -27,6 +38,9 @@ export class BrokerController {
|
|||||||
* 单独创建 broker
|
* 单独创建 broker
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: '创建券商', description: '创建单个券商信息' })
|
@ApiOperation({ summary: '创建券商', description: '创建单个券商信息' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -44,6 +58,9 @@ export class BrokerController {
|
|||||||
* 批量创建 broker
|
* 批量创建 broker
|
||||||
*/
|
*/
|
||||||
@Post('batch')
|
@Post('batch')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '批量创建券商',
|
summary: '批量创建券商',
|
||||||
@@ -63,9 +80,9 @@ export class BrokerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询 broker(支持多种查询条件)
|
* 查询 broker(支持多种查询条件和分页)
|
||||||
* 支持按 broker_id、broker_code、broker_name、region 查询
|
* 支持按 broker_id、broker_code、broker_name、region 查询
|
||||||
* 返回一个或多个 broker
|
* 返回分页数据
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -75,9 +92,9 @@ export class BrokerController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: '查询成功',
|
description: '查询成功',
|
||||||
type: [Broker],
|
type: PaginatedBrokerData,
|
||||||
})
|
})
|
||||||
findAll(@Query() queryDto: QueryBrokerDto): Promise<Broker[]> {
|
findAll(@Query() queryDto: QueryBrokerDto): Promise<PaginatedBrokerData> {
|
||||||
return this.brokerService.findAll(queryDto);
|
return this.brokerService.findAll(queryDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +121,9 @@ export class BrokerController {
|
|||||||
* 更新 broker
|
* 更新 broker
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '更新券商',
|
summary: '更新券商',
|
||||||
description: '更新券商的部分或全部信息',
|
description: '更新券商的部分或全部信息',
|
||||||
@@ -127,6 +147,9 @@ export class BrokerController {
|
|||||||
* 删除 broker
|
* 删除 broker
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '删除券商',
|
summary: '删除券商',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { CreateBrokerDto } from './dto/create-broker.dto';
|
|||||||
import { UpdateBrokerDto } from './dto/update-broker.dto';
|
import { UpdateBrokerDto } from './dto/update-broker.dto';
|
||||||
import { QueryBrokerDto } from './dto/query-broker.dto';
|
import { QueryBrokerDto } from './dto/query-broker.dto';
|
||||||
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
||||||
|
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerService {
|
export class BrokerService {
|
||||||
@@ -109,9 +110,12 @@ export class BrokerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询 broker(支持多种查询条件)
|
* 查询 broker(支持多种查询条件和分页)
|
||||||
*/
|
*/
|
||||||
async findAll(queryDto: QueryBrokerDto): Promise<Broker[]> {
|
async findAll(queryDto: QueryBrokerDto): Promise<{
|
||||||
|
list: Broker[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}> {
|
||||||
const where: FindOptionsWhere<Broker> = {};
|
const where: FindOptionsWhere<Broker> = {};
|
||||||
|
|
||||||
if (queryDto.brokerId) {
|
if (queryDto.brokerId) {
|
||||||
@@ -134,13 +138,53 @@ export class BrokerService {
|
|||||||
where.isActive = queryDto.isActive;
|
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<string, 'ASC' | 'DESC'> = {};
|
||||||
|
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,
|
where,
|
||||||
order: {
|
order,
|
||||||
sortOrder: 'ASC',
|
skip,
|
||||||
brokerId: 'ASC',
|
take: limit,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 计算总页数
|
||||||
|
const total_page = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
total_page,
|
||||||
|
page_size: limit,
|
||||||
|
current_page: page,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
14
apps/api/src/modules/broker/dto/paginated-response.dto.ts
Normal file
14
apps/api/src/modules/broker/dto/paginated-response.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
14
apps/api/src/modules/user/dto/paginated-response.dto.ts
Normal file
14
apps/api/src/modules/user/dto/paginated-response.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class QueryUserDto {
|
export class QueryUserDto {
|
||||||
@@ -10,11 +18,91 @@ export class QueryUserDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '昵称',
|
||||||
|
example: 'John Doe',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nickname?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '邮箱',
|
description: '邮箱',
|
||||||
example: 'user@example.com',
|
example: 'user@example.com',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsString()
|
||||||
email?: string;
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
102
apps/api/src/modules/user/mock-users.data.ts
Normal file
102
apps/api/src/modules/user/mock-users.data.ts
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
|
Query,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -22,10 +23,12 @@ 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 { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { QueryUserDto } from './dto/query-user.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';
|
import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard';
|
||||||
|
import { PaginatedUserData } from './dto/paginated-response.dto';
|
||||||
|
|
||||||
@ApiTags('user')
|
@ApiTags('user')
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
@@ -56,25 +59,25 @@ 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: '查询用户列表',
|
||||||
description: '获取所有用户列表(需要管理员权限)',
|
description: '获取用户列表,支持分页和多种筛选条件(需要管理员权限)',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: '查询成功',
|
description: '查询成功',
|
||||||
type: [User],
|
type: PaginatedUserData,
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 401, description: '未授权' })
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
@ApiResponse({ status: 403, description: '权限不足' })
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
findAll(): Promise<User[]> {
|
findAll(@Query() queryDto: QueryUserDto): Promise<PaginatedUserData> {
|
||||||
return this.userService.findAll();
|
return this.userService.findAllPaginated(queryDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
|
import { MOCK_USERS } from './mock-users.data';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户数据种子(Seeder)
|
* 用户数据种子(Seeder)
|
||||||
*
|
*
|
||||||
* 用途:在应用启动时自动创建初始管理员用户
|
* 用途:在应用启动时自动创建初始用户(超级管理员、管理员、普通用户)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 创建超级管理员和管理员各一名(从环境变量读取配置)
|
||||||
|
* 2. 创建12名普通用户(使用Mock数据)
|
||||||
|
*
|
||||||
|
* 性能优化:
|
||||||
|
* - 使用批量查询(IN查询)检查用户是否存在,只执行2次数据库查询
|
||||||
|
* - 只创建不存在的用户,保证幂等性
|
||||||
*
|
*
|
||||||
* 优点:
|
* 优点:
|
||||||
* 1. 代码化管理,版本控制友好
|
* 1. 代码化管理,版本控制友好
|
||||||
@@ -18,13 +27,14 @@ import { User } from './user.entity';
|
|||||||
* 5. 幂等性:如果用户已存在,不会重复创建
|
* 5. 幂等性:如果用户已存在,不会重复创建
|
||||||
*
|
*
|
||||||
* 使用方式:
|
* 使用方式:
|
||||||
* 1. 通过环境变量配置初始管理员信息
|
* 1. 通过环境变量配置管理员信息
|
||||||
* 2. 应用启动时自动执行
|
* 2. 应用启动时自动执行
|
||||||
* 3. 仅在开发/测试环境自动执行,生产环境建议手动创建
|
* 3. 仅在开发/测试环境自动执行,生产环境建议手动创建
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSeeder implements OnModuleInit {
|
export class UserSeeder implements OnModuleInit {
|
||||||
private readonly logger = new Logger(UserSeeder.name);
|
private readonly logger = new Logger(UserSeeder.name);
|
||||||
|
private readonly saltRounds = 10; // bcrypt 加盐轮数
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
@@ -44,15 +54,51 @@ export class UserSeeder implements OnModuleInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.seedAdminUser();
|
// 执行种子数据创建
|
||||||
|
await this.seedAllUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建初始管理员用户
|
* 创建所有种子用户(超级管理员、管理员、普通用户)
|
||||||
*/
|
*/
|
||||||
async seedAdminUser(): Promise<void> {
|
async seedAllUsers(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 从环境变量读取配置,如果没有则使用默认值
|
// 创建管理员用户(超级管理员和管理员)
|
||||||
|
await this.seedAdminUsers();
|
||||||
|
|
||||||
|
// 创建普通用户
|
||||||
|
await this.seedMockUsers();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('创建种子用户失败:', error);
|
||||||
|
// 不抛出错误,避免影响应用启动
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建管理员用户(超级管理员和管理员)
|
||||||
|
*
|
||||||
|
* 性能优化说明:
|
||||||
|
* - 使用批量查询(IN查询)一次检查两个管理员用户是否存在
|
||||||
|
* - 只创建不存在的用户
|
||||||
|
* - 只执行1次数据库查询,而不是2次
|
||||||
|
*/
|
||||||
|
async seedAdminUsers(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 从环境变量读取超级管理员配置
|
||||||
|
const superAdminUsername =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_USERNAME') ||
|
||||||
|
'superadmin';
|
||||||
|
const superAdminPassword =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_PASSWORD') ||
|
||||||
|
'admin123';
|
||||||
|
const superAdminEmail =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_EMAIL') ||
|
||||||
|
'superadmin@vestmind.com';
|
||||||
|
const superAdminNickname =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_NICKNAME') ||
|
||||||
|
'超级管理员';
|
||||||
|
|
||||||
|
// 从环境变量读取管理员配置
|
||||||
const adminUsername =
|
const adminUsername =
|
||||||
this.configService.get<string>('ADMIN_USERNAME') || 'admin';
|
this.configService.get<string>('ADMIN_USERNAME') || 'admin';
|
||||||
const adminPassword =
|
const adminPassword =
|
||||||
@@ -63,57 +109,159 @@ export class UserSeeder implements OnModuleInit {
|
|||||||
const adminNickname =
|
const adminNickname =
|
||||||
this.configService.get<string>('ADMIN_NICKNAME') ||
|
this.configService.get<string>('ADMIN_NICKNAME') ||
|
||||||
'系统管理员';
|
'系统管理员';
|
||||||
const adminRole =
|
|
||||||
this.configService.get<string>('ADMIN_ROLE') || 'admin';
|
|
||||||
|
|
||||||
// 检查管理员用户是否已存在
|
// 构建管理员用户数据
|
||||||
const existingAdmin = await this.userRepository.findOne({
|
const adminUsersToCreate = [
|
||||||
where: { username: adminUsername },
|
{
|
||||||
});
|
username: superAdminUsername,
|
||||||
|
email: superAdminEmail,
|
||||||
if (existingAdmin) {
|
nickname: superAdminNickname,
|
||||||
this.logger.log(
|
password: superAdminPassword,
|
||||||
`管理员用户 "${adminUsername}" 已存在,跳过创建`,
|
role: 'super_admin',
|
||||||
);
|
},
|
||||||
return;
|
{
|
||||||
}
|
|
||||||
|
|
||||||
// 检查邮箱是否已被使用
|
|
||||||
const existingByEmail = await this.userRepository.findOne({
|
|
||||||
where: { email: adminEmail },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingByEmail) {
|
|
||||||
this.logger.warn(
|
|
||||||
`邮箱 "${adminEmail}" 已被使用,跳过创建管理员用户`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 bcrypt 加密密码
|
|
||||||
// saltRounds 指的是生成 bcrypt 哈希时的加盐轮数(成本系数),轮数越高,计算越慢,安全性越高,通常 10~12 为常用值
|
|
||||||
const saltRounds = 10;
|
|
||||||
const passwordHash = await bcrypt.hash(adminPassword, saltRounds);
|
|
||||||
|
|
||||||
// 创建管理员用户
|
|
||||||
const adminUser = this.userRepository.create({
|
|
||||||
username: adminUsername,
|
username: adminUsername,
|
||||||
passwordHash,
|
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
nickname: adminNickname,
|
nickname: adminNickname,
|
||||||
role: adminRole,
|
password: adminPassword,
|
||||||
status: 'active',
|
role: 'admin',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 批量查询:一次检查所有管理员用户是否存在(性能优化)
|
||||||
|
const existingAdminUsernames = await this.userRepository.find({
|
||||||
|
where: {
|
||||||
|
username: In([superAdminUsername, adminUsername]),
|
||||||
|
},
|
||||||
|
select: ['username'],
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.userRepository.save(adminUser);
|
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(
|
||||||
|
`管理员用户已存在(超级管理员: ${superAdminUsername}, 管理员: ${adminUsername}),跳过创建`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量创建用户
|
||||||
|
const usersToSave = await Promise.all(
|
||||||
|
usersToCreate.map(async (userData) => {
|
||||||
|
const passwordHash = await bcrypt.hash(
|
||||||
|
userData.password,
|
||||||
|
this.saltRounds,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
`⚠️ ${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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量创建用户
|
||||||
|
const usersToSave = await Promise.all(
|
||||||
|
usersToCreate.map(async (mockUser) => {
|
||||||
|
const passwordHash = await bcrypt.hash(
|
||||||
|
mockUser.password,
|
||||||
|
this.saltRounds,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(usersToSave);
|
||||||
|
|
||||||
this.logger.log(
|
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) {
|
} catch (error) {
|
||||||
this.logger.error('创建初始管理员用户失败:', error);
|
this.logger.error('创建普通用户失败:', error);
|
||||||
// 不抛出错误,避免影响应用启动
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +269,6 @@ export class UserSeeder implements OnModuleInit {
|
|||||||
* 手动执行种子数据(可用于 CLI 命令)
|
* 手动执行种子数据(可用于 CLI 命令)
|
||||||
*/
|
*/
|
||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
await this.seedAdminUser();
|
await this.seedAllUsers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
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 { QueryUserDto } from './dto/query-user.dto';
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -119,6 +120,88 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户(支持多种查询条件和分页)
|
||||||
|
*/
|
||||||
|
async findAllPaginated(queryDto: QueryUserDto): Promise<{
|
||||||
|
list: User[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}> {
|
||||||
|
const where: FindOptionsWhere<User> = {};
|
||||||
|
|
||||||
|
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<string, 'ASC' | 'DESC'> = {};
|
||||||
|
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 查询单个用户
|
* 根据 username 或 email 查询单个用户
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RouterProvider } from 'react-router';
|
import { RouterProvider } from 'react-router';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -15,7 +15,9 @@ function App() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<AntdApp>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</AntdApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
73
apps/web/src/components/ErrorBoundary.tsx
Normal file
73
apps/web/src/components/ErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<ErrorPage
|
||||||
|
error={this.state.error}
|
||||||
|
errorInfo={this.state.errorInfo}
|
||||||
|
onReset={this.handleReset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
130
apps/web/src/components/ErrorPage.css
Normal file
130
apps/web/src/components/ErrorPage.css
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
184
apps/web/src/components/ErrorPage.tsx
Normal file
184
apps/web/src/components/ErrorPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="error-page">
|
||||||
|
<Card className="error-page-card">
|
||||||
|
<div className="error-page-content">
|
||||||
|
<div className="error-icon">
|
||||||
|
{is404 ? <FileSearchOutlined /> : <BugOutlined />}
|
||||||
|
</div>
|
||||||
|
<Title level={2} className="error-title">
|
||||||
|
{is404 ? '页面未找到' : '哎呀,出错了!'}
|
||||||
|
</Title>
|
||||||
|
<Paragraph className="error-description">
|
||||||
|
{is404 ? (
|
||||||
|
<>
|
||||||
|
抱歉,您访问的页面不存在。
|
||||||
|
<br />
|
||||||
|
请检查 URL 是否正确,或返回首页继续浏览。
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
应用遇到了一个意外错误,我们已经记录了这个问题。
|
||||||
|
<br />
|
||||||
|
您可以尝试刷新页面或返回首页。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{errorMessage && !is404 && (
|
||||||
|
<div className="error-message">
|
||||||
|
<Text type="danger" strong>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space size="middle" className="error-actions">
|
||||||
|
{!is404 && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleReload}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type={is404 ? 'primary' : 'default'}
|
||||||
|
icon={<HomeOutlined />}
|
||||||
|
onClick={handleGoHome}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
{is404 && (
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleGoBack} size="large">
|
||||||
|
返回上页
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{import.meta.env.DEV &&
|
||||||
|
!is404 &&
|
||||||
|
(error ||
|
||||||
|
errorInfo ||
|
||||||
|
(routeError !== null && routeError !== undefined)) && (
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
className="error-details"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: '错误详情(开发模式)',
|
||||||
|
children: (
|
||||||
|
<div className="error-details-content">
|
||||||
|
{error && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>错误信息:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{error.stack || error.toString()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isRouteErrorResponse(routeError) && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>路由错误:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{`状态码: ${routeError.status}\n状态文本: ${routeError.statusText}\n数据: ${JSON.stringify(
|
||||||
|
routeError.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorInfo && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>组件堆栈:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{errorInfo.componentStack}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{routeError !== null &&
|
||||||
|
routeError !== undefined &&
|
||||||
|
!(routeError instanceof Error) &&
|
||||||
|
!isRouteErrorResponse(routeError) && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>路由错误详情:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{JSON.stringify(
|
||||||
|
routeError as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorPage;
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
/* 侧边栏样式 */
|
/* 侧边栏样式 */
|
||||||
.main-sider {
|
.main-sider {
|
||||||
background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%) !important;
|
background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%) !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
@@ -25,26 +27,7 @@
|
|||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-menu {
|
/* 侧边栏菜单样式已移至 SidebarMenu.css */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部栏样式 */
|
/* 顶部栏样式 */
|
||||||
.main-header {
|
.main-header {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
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, Dropdown, Button, message } from 'antd';
|
import { Layout, Avatar, Badge, Drawer, Dropdown, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
BarChartOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
@@ -13,23 +10,14 @@ import {
|
|||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { authService } from '@/services/auth';
|
import { authService } from '@/services/auth';
|
||||||
import type { UserInfo } from '@/types/user';
|
import type { UserInfo } from '@/types/user';
|
||||||
|
import SidebarMenu from './SidebarMenu';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { getPageInfo } from './menuConfig';
|
||||||
import './MainLayout.css';
|
import './MainLayout.css';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
interface MainLayoutProps {
|
const MainLayout = () => {
|
||||||
isAdmin?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面标题映射
|
|
||||||
const pageTitles: Record<string, { title: string; subtitle: string }> = {
|
|
||||||
'/': { title: '资产账户', subtitle: '买股票就是买公司' },
|
|
||||||
'/assets': { title: '资产账户', subtitle: '买股票就是买公司' },
|
|
||||||
'/plans': { title: '交易计划', subtitle: '计划你的交易,交易你的计划' },
|
|
||||||
'/review': { title: '投资复盘', subtitle: '回顾过去是为了更好应对将来' },
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -45,7 +33,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
|
|
||||||
// 根据路由获取页面标题
|
// 根据路由获取页面标题
|
||||||
const pageInfo = useMemo(() => {
|
const pageInfo = useMemo(() => {
|
||||||
return pageTitles[location.pathname] || pageTitles['/'];
|
return getPageInfo(location.pathname);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
// 检测移动端
|
// 检测移动端
|
||||||
@@ -62,33 +50,6 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
return () => window.removeEventListener('resize', checkMobile);
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 菜单项配置
|
|
||||||
const menuItems: MenuProps['items'] = [
|
|
||||||
{
|
|
||||||
key: '/assets',
|
|
||||||
icon: <BarChartOutlined />,
|
|
||||||
label: '资产账户',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '/plans',
|
|
||||||
icon: <FileTextOutlined />,
|
|
||||||
label: '交易计划',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '/review',
|
|
||||||
icon: <EditOutlined />,
|
|
||||||
label: '投资复盘',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 处理菜单点击
|
|
||||||
const handleMenuClick = ({ key }: { key: string }) => {
|
|
||||||
navigate(key);
|
|
||||||
if (isMobile) {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理登出
|
// 处理登出
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authService.logout();
|
authService.logout();
|
||||||
@@ -134,15 +95,6 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
return 'success';
|
return 'success';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取当前选中的菜单项
|
|
||||||
const selectedKeys = useMemo(() => {
|
|
||||||
const path = location.pathname;
|
|
||||||
if (path === '/' || path === '/assets') {
|
|
||||||
return ['/assets'];
|
|
||||||
}
|
|
||||||
return [path];
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="main-layout">
|
<Layout className="main-layout">
|
||||||
{/* 桌面端侧边栏 */}
|
{/* 桌面端侧边栏 */}
|
||||||
@@ -165,14 +117,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
<div className="logo">{collapsed ? '投' : '投小记'}</div>
|
<div className="logo">{collapsed ? '投' : '投小记'}</div>
|
||||||
{!collapsed && <div className="logo-subtitle">VestMind</div>}
|
{!collapsed && <div className="logo-subtitle">VestMind</div>}
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<SidebarMenu collapsed={collapsed} user={user} />
|
||||||
theme="dark"
|
|
||||||
mode="inline"
|
|
||||||
selectedKeys={selectedKeys}
|
|
||||||
items={menuItems}
|
|
||||||
onClick={handleMenuClick}
|
|
||||||
className="sidebar-menu"
|
|
||||||
/>
|
|
||||||
</Sider>
|
</Sider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -186,12 +131,10 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
bodyStyle={{ padding: 0 }}
|
bodyStyle={{ padding: 0 }}
|
||||||
width={240}
|
width={240}
|
||||||
>
|
>
|
||||||
<Menu
|
<SidebarMenu
|
||||||
mode="inline"
|
collapsed={false}
|
||||||
selectedKeys={selectedKeys}
|
user={user}
|
||||||
items={menuItems}
|
onMenuClick={() => setMobileMenuOpen(false)}
|
||||||
onClick={handleMenuClick}
|
|
||||||
style={{ border: 'none' }}
|
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
@@ -258,7 +201,9 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
|||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<Content className="main-content">
|
<Content className="main-content">
|
||||||
|
<ErrorBoundary>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</ErrorBoundary>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
155
apps/web/src/layouts/SidebarMenu.css
Normal file
155
apps/web/src/layouts/SidebarMenu.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
apps/web/src/layouts/SidebarMenu.tsx
Normal file
99
apps/web/src/layouts/SidebarMenu.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="sidebar-menu-container">
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
className="sidebar-menu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarMenu;
|
||||||
155
apps/web/src/layouts/menuConfig.tsx
Normal file
155
apps/web/src/layouts/menuConfig.tsx
Normal file
@@ -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: <BarChartOutlined />,
|
||||||
|
label: '资产账户',
|
||||||
|
title: '资产账户',
|
||||||
|
subtitle: '买股票就是买公司',
|
||||||
|
group: 'main',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/plans',
|
||||||
|
key: '/plans',
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
label: '交易计划',
|
||||||
|
title: '交易计划',
|
||||||
|
subtitle: '计划你的交易,交易你的计划',
|
||||||
|
group: 'main',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/review',
|
||||||
|
key: '/review',
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
label: '投资复盘',
|
||||||
|
title: '投资复盘',
|
||||||
|
subtitle: '回顾过去是为了更好应对将来',
|
||||||
|
group: 'main',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user',
|
||||||
|
key: '/user',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: '用户管理',
|
||||||
|
title: '用户管理',
|
||||||
|
subtitle: '管理用户信息',
|
||||||
|
group: 'admin',
|
||||||
|
requireAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/broker',
|
||||||
|
key: '/broker',
|
||||||
|
icon: <BankOutlined />,
|
||||||
|
label: '券商管理',
|
||||||
|
title: '券商管理',
|
||||||
|
subtitle: '管理券商信息',
|
||||||
|
group: 'admin',
|
||||||
|
requireAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/seo',
|
||||||
|
key: '/seo',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: 'SEO配置',
|
||||||
|
title: 'SEO配置',
|
||||||
|
subtitle: '优化搜索引擎可见性',
|
||||||
|
group: 'admin',
|
||||||
|
requireAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/analytics',
|
||||||
|
key: '/analytics',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
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<string, { title: string; subtitle: string }> = (() => {
|
||||||
|
const titles: Record<string, { title: string; subtitle: string }> = {
|
||||||
|
'/': getPageInfo('/assets'),
|
||||||
|
};
|
||||||
|
|
||||||
|
routeMenuConfig.forEach((config) => {
|
||||||
|
titles[config.path] = {
|
||||||
|
title: config.title,
|
||||||
|
subtitle: config.subtitle,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return titles;
|
||||||
|
})();
|
||||||
172
apps/web/src/pages/broker/BrokerFormModal.tsx
Normal file
172
apps/web/src/pages/broker/BrokerFormModal.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
title={isEdit ? '编辑券商' : '新建券商'}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
width={600}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" autoComplete="off" style={{ marginTop: 20 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="brokerCode"
|
||||||
|
label="券商代码"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入券商代码' },
|
||||||
|
{ max: 50, message: '券商代码不能超过50个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入券商代码" disabled={isEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="brokerName"
|
||||||
|
label="券商名称"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入券商名称' },
|
||||||
|
{ max: 100, message: '券商名称不能超过100个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入券商名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="region"
|
||||||
|
label="地区"
|
||||||
|
rules={[{ required: true, message: '请选择地区' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="请选择地区">
|
||||||
|
{REGION_OPTIONS.map((option) => (
|
||||||
|
<Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="brokerImage"
|
||||||
|
label="券商Logo"
|
||||||
|
rules={[{ max: 200, message: '图片地址不能超过200个字符' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入图片URL(或使用下方上传组件)" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="上传Logo(仅UI演示)">
|
||||||
|
<Upload {...uploadProps} onChange={handleImageChange} accept="image/*">
|
||||||
|
<div>
|
||||||
|
<PlusOutlined />
|
||||||
|
<div style={{ marginTop: 8 }}>上传</div>
|
||||||
|
</div>
|
||||||
|
</Upload>
|
||||||
|
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
||||||
|
注意:上传功能仅做UI演示,实际需要调用上传接口获取图片URL
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrokerFormModal;
|
||||||
38
apps/web/src/pages/broker/BrokerPage.css
Normal file
38
apps/web/src/pages/broker/BrokerPage.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
328
apps/web/src/pages/broker/BrokerPage.tsx
Normal file
328
apps/web/src/pages/broker/BrokerPage.tsx
Normal file
@@ -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<Broker[]>([]);
|
||||||
|
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<Broker | null>(null);
|
||||||
|
const formRef = useRef<QueryBrokerRequest>({});
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
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<Broker> = [
|
||||||
|
{
|
||||||
|
title: '券商Logo',
|
||||||
|
dataIndex: 'brokerImage',
|
||||||
|
key: 'brokerImage',
|
||||||
|
width: 100,
|
||||||
|
render: (image: string) => {
|
||||||
|
if (image) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt="券商Logo"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
style={{ objectFit: 'contain' }}
|
||||||
|
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='50'%3E%3Crect width='50' height='50' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3ELogo%3C/text%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #e5e5e5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logo
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
|
||||||
|
修改
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这个券商吗?"
|
||||||
|
onConfirm={() => handleDelete(record.brokerId)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="broker-page">
|
||||||
|
<Card>
|
||||||
|
{/* 查询表单 */}
|
||||||
|
<Form form={form} layout="inline" className="broker-search-form">
|
||||||
|
<Row gutter={16} style={{ width: '100%' }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="brokerCode" label="券商代码">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入券商代码"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="brokerName" label="券商名称">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入券商名称"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="region" label="地区">
|
||||||
|
<Select
|
||||||
|
placeholder="请选择地区"
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{REGION_OPTIONS.map((option) => (
|
||||||
|
<Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* 操作栏 */}
|
||||||
|
<div className="broker-action-bar">
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||||
|
新建券商
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={brokers}
|
||||||
|
rowKey="brokerId"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total: pagination.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
current: page,
|
||||||
|
pageSize: pageSize || 10,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 新建/编辑弹窗 */}
|
||||||
|
<BrokerFormModal
|
||||||
|
visible={modalVisible}
|
||||||
|
editingBroker={editingBroker}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setEditingBroker(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handleSaveSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrokerPage;
|
||||||
1
apps/web/src/pages/broker/index.ts
Normal file
1
apps/web/src/pages/broker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './BrokerPage';
|
||||||
137
apps/web/src/pages/user/UserDetailModal.tsx
Normal file
137
apps/web/src/pages/user/UserDetailModal.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
title="用户详情"
|
||||||
|
open={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={[
|
||||||
|
<Button key="close" onClick={onCancel}>
|
||||||
|
关闭
|
||||||
|
</Button>,
|
||||||
|
canDelete && (
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="确定要删除这个用户吗?"
|
||||||
|
description="删除后无法恢复"
|
||||||
|
onConfirm={() => onDelete?.(user.userId)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button key="delete" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
].filter(Boolean)}
|
||||||
|
width={700}
|
||||||
|
>
|
||||||
|
{/* 头像和昵称区域 */}
|
||||||
|
<div style={{ marginBottom: 24, textAlign: 'center' }}>
|
||||||
|
<Space size={16} align="center">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<Image
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt="头像"
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
style={{ borderRadius: 4 }}
|
||||||
|
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Crect width='60' height='60' fill='%23f0f0f0'/%3E%3Cpath d='M30 20c5.5 0 10 4.5 10 10s-4.5 10-10 10-10-4.5-10-10 4.5-10 10-10zm0 24c6.6 0 20 3.3 20 10v6H10v-6c0-6.7 13.4-10 20-10z' fill='%23999'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar size={60} icon={<UserOutlined />} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#262626',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.nickname || user.username}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Descriptions column={2} bordered>
|
||||||
|
<Descriptions.Item label="用户ID">{user.userId}</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="邮箱">{user.email}</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="电话">{user.phone || '-'}</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="角色">
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
user.role === 'super_admin'
|
||||||
|
? 'red'
|
||||||
|
: user.role === 'admin'
|
||||||
|
? 'orange'
|
||||||
|
: 'blue'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getRoleText(user.role)}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
user.status === 'active'
|
||||||
|
? 'success'
|
||||||
|
: user.status === 'inactive'
|
||||||
|
? 'warning'
|
||||||
|
: 'error'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getStatusText(user.status)}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
{user.openId && (
|
||||||
|
<Descriptions.Item label="微信OpenID">{user.openId}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.unionId && (
|
||||||
|
<Descriptions.Item label="微信UnionID">{user.unionId}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Descriptions.Item label="创建时间" span={2}>
|
||||||
|
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="更新时间" span={2}>
|
||||||
|
{dayjs(user.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
<Descriptions.Item label="最后登录时间" span={2}>
|
||||||
|
{user.lastLoginAt ? dayjs(user.lastLoginAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserDetailModal;
|
||||||
7
apps/web/src/pages/user/UserPage.css
Normal file
7
apps/web/src/pages/user/UserPage.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.user-page .user-search-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page .user-search-form .ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
431
apps/web/src/pages/user/UserPage.tsx
Normal file
431
apps/web/src/pages/user/UserPage.tsx
Normal file
@@ -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<User[]>([]);
|
||||||
|
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<User | null>(null);
|
||||||
|
const formRef = useRef<QueryUserRequest>({});
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
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<User> = [
|
||||||
|
{
|
||||||
|
title: '用户头像',
|
||||||
|
dataIndex: 'avatarUrl',
|
||||||
|
key: 'avatarUrl',
|
||||||
|
width: 80,
|
||||||
|
render: (avatarUrl: string) => {
|
||||||
|
if (avatarUrl) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="头像"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||||
|
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect width='20' height='20' fill='%23f0f0f0'/%3E%3Cpath d='M10 7c1.7 0 3 1.3 3 3s-1.3 3-3 3-3-1.3-3-3 1.3-3 3-3zm0 8c2.2 0 6.7 1.1 6.7 3.3v1.7H3.3v-1.7c0-2.2 4.5-3.3 6.7-3.3z' fill='%23999'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Avatar size={32} icon={<UserOutlined />} 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) => (
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
status === 'active'
|
||||||
|
? 'success'
|
||||||
|
: status === 'inactive'
|
||||||
|
? 'warning'
|
||||||
|
: 'error'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getStatusText(status)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
key: 'role',
|
||||||
|
width: 120,
|
||||||
|
render: (role: string) => (
|
||||||
|
<Tag color={role === 'super_admin' ? 'red' : role === 'admin' ? 'orange' : 'blue'}>
|
||||||
|
{getRoleText(role)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 180,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_: any, record: User) => {
|
||||||
|
const isFrozen = record.status === 'inactive';
|
||||||
|
const isSuperAdmin = record.role === 'super_admin';
|
||||||
|
return (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleView(record)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
{/* 超级管理员不允许冻结 */}
|
||||||
|
{!isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
{isFrozen ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要解冻这个用户吗?"
|
||||||
|
description="解冻后用户可以正常使用"
|
||||||
|
onConfirm={() => handleToggleStatus(record)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="link" icon={<UnlockOutlined />}>
|
||||||
|
解冻
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要冻结这个用户吗?"
|
||||||
|
description="冻结后用户将无法登录和使用系统"
|
||||||
|
onConfirm={() => handleToggleStatus(record)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="link" icon={<LockOutlined />}>
|
||||||
|
冻结
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-page">
|
||||||
|
<Card>
|
||||||
|
{/* 查询表单 */}
|
||||||
|
<Form form={form} layout="inline" className="user-search-form">
|
||||||
|
<Row gutter={16} style={{ width: '100%' }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="username" label="用户名">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="nickname" label="昵称">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="email" label="邮箱">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="phone" label="电话">
|
||||||
|
<Input
|
||||||
|
placeholder="请输入电话"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="role" label="角色" initialValue="">
|
||||||
|
<Select
|
||||||
|
placeholder="请选择角色"
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{USER_ROLE_OPTIONS.map((option) => (
|
||||||
|
<Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="status" label="状态" initialValue="">
|
||||||
|
<Select
|
||||||
|
placeholder="请选择状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{USER_STATUS_OPTIONS.map((option) => (
|
||||||
|
<Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={users}
|
||||||
|
rowKey="userId"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total: pagination.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
current: page,
|
||||||
|
pageSize: pageSize || 10,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1500 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 用户详情弹窗 */}
|
||||||
|
<UserDetailModal
|
||||||
|
visible={detailModalVisible}
|
||||||
|
user={selectedUser}
|
||||||
|
onCancel={() => {
|
||||||
|
setDetailModalVisible(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}}
|
||||||
|
onDelete={async (userId: number) => {
|
||||||
|
try {
|
||||||
|
await handleDelete(userId);
|
||||||
|
setDetailModalVisible(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
} catch (error) {
|
||||||
|
// 错误已在 handleDelete 中处理
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserPage;
|
||||||
1
apps/web/src/pages/user/index.ts
Normal file
1
apps/web/src/pages/user/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './UserPage';
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
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 ProtectedRoute from '../components/ProtectedRoute';
|
||||||
|
import ErrorPage from '../components/ErrorPage';
|
||||||
import LoginPage from '../pages/LoginPage';
|
import LoginPage from '../pages/LoginPage';
|
||||||
import AssetsPage from '../pages/AssetsPage';
|
const AssetsPage = lazy(() => import('../pages/AssetsPage'));
|
||||||
import PlansPage from '../pages/PlansPage';
|
const PlansPage = lazy(() => import('../pages/PlansPage'));
|
||||||
import ReviewPage from '../pages/ReviewPage';
|
const ReviewPage = lazy(() => import('../pages/ReviewPage'));
|
||||||
|
const BrokerPage = lazy(() => import('../pages/broker'));
|
||||||
|
const UserPage = lazy(() => import('@/pages/user'));
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
|
errorElement: <ErrorPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -18,6 +23,7 @@ export const router = createBrowserRouter([
|
|||||||
<MainLayout />
|
<MainLayout />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
|
errorElement: <ErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
@@ -35,6 +41,18 @@ export const router = createBrowserRouter([
|
|||||||
path: 'review',
|
path: 'review',
|
||||||
element: <ReviewPage />,
|
element: <ReviewPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'broker',
|
||||||
|
element: <BrokerPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user',
|
||||||
|
element: <UserPage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
element: <ErrorPage is404={true} />,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
59
apps/web/src/services/broker.ts
Normal file
59
apps/web/src/services/broker.ts
Normal file
@@ -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<PaginatedBrokerResponse> {
|
||||||
|
// api.get 返回的是 TransformInterceptor 处理后的 { code, message, data }
|
||||||
|
// 其中 data 就是 PaginatedBrokerResponse
|
||||||
|
const response = await api.get<ApiResponse<PaginatedBrokerResponse>>('/broker', { params });
|
||||||
|
// 如果 response 已经是 PaginatedBrokerResponse 格式,直接返回
|
||||||
|
if ('list' in response && 'pagination' in response) {
|
||||||
|
return response as PaginatedBrokerResponse;
|
||||||
|
}
|
||||||
|
// 否则从 ApiResponse 中取 data
|
||||||
|
return (response as ApiResponse<PaginatedBrokerResponse>).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询券商
|
||||||
|
*/
|
||||||
|
async getBrokerById(id: number): Promise<Broker> {
|
||||||
|
return await api.get<Broker>(`/broker/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建券商
|
||||||
|
*/
|
||||||
|
async createBroker(data: CreateBrokerRequest): Promise<Broker> {
|
||||||
|
return await api.post<Broker>('/broker', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新券商
|
||||||
|
*/
|
||||||
|
async updateBroker(id: number, data: UpdateBrokerRequest): Promise<Broker> {
|
||||||
|
return await api.patch<Broker>(`/broker/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除券商
|
||||||
|
*/
|
||||||
|
async deleteBroker(id: number): Promise<void> {
|
||||||
|
await api.delete(`/broker/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const brokerService = new BrokerService();
|
||||||
46
apps/web/src/services/user.ts
Normal file
46
apps/web/src/services/user.ts
Normal file
@@ -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<PaginatedUserResponse> {
|
||||||
|
const response = await api.get<ApiResponse<PaginatedUserResponse>>('/user', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
// 如果 response 已经是 PaginatedUserResponse 格式,直接返回
|
||||||
|
if ('list' in response && 'pagination' in response) {
|
||||||
|
return response as PaginatedUserResponse;
|
||||||
|
}
|
||||||
|
// 否则从 ApiResponse 中取 data
|
||||||
|
return (response as ApiResponse<PaginatedUserResponse>).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询用户
|
||||||
|
*/
|
||||||
|
async getUserById(id: number): Promise<User> {
|
||||||
|
return await api.get<User>(`/user/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户状态(冻结/解冻)
|
||||||
|
*/
|
||||||
|
async updateUserStatus(id: number, status: 'active' | 'inactive'): Promise<User> {
|
||||||
|
return await api.patch<User>(`/user/${id}`, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户
|
||||||
|
*/
|
||||||
|
async deleteUser(id: number): Promise<void> {
|
||||||
|
await api.delete(`/user/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userService = new UserService();
|
||||||
90
apps/web/src/types/broker.ts
Normal file
90
apps/web/src/types/broker.ts
Normal file
@@ -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<CreateBrokerRequest> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询券商请求参数
|
||||||
|
*/
|
||||||
|
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<T> {
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -17,6 +17,11 @@ export interface UserInfo {
|
|||||||
lastLoginAt?: Date;
|
lastLoginAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户类型(用于列表展示,与UserInfo相同)
|
||||||
|
*/
|
||||||
|
export type User = UserInfo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录请求参数
|
* 登录请求参数
|
||||||
*/
|
*/
|
||||||
@@ -32,3 +37,67 @@ export interface LoginResponse {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
user: UserInfo;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user