Compare commits
10 Commits
84ddca26b5
...
1f7fded275
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f7fded275 | |||
| 571465cfbb | |||
| 161781cbbd | |||
| aa313b7605 | |||
| b7972153cc | |||
| 838a021ce5 | |||
| 67e4dc6382 | |||
| 457ba6d765 | |||
| 712f66b725 | |||
| 76c22429ad |
@@ -1,5 +1,5 @@
|
||||
# 开发环境配置
|
||||
PORT=3200
|
||||
PORT=3201
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
@@ -11,8 +11,14 @@ DB_DATABASE=vest_mind_dev
|
||||
JWT_SECRET=vest_thinking_key
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 资源上传配置
|
||||
STORAGE_TYPE=local
|
||||
|
||||
ADMIN_USERNAME=joey
|
||||
ADMIN_PASSWORD=joey5628
|
||||
ADMIN_EMAIL=zhangyi5628@126.com
|
||||
ADMIN_NICKNAME=思考的Joey
|
||||
ADMIN_ROLE=super_admin
|
||||
|
||||
STORAGE_PATH=./uploads # 存储路径(默认:./uploads)
|
||||
STORAGE_BASE_URL=http://localhost:3201/uploads # 访问URL(默认:http://localhost:3201/uploads)
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"dev": "nest start --watch",
|
||||
"start": "nest start",
|
||||
"start": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
|
||||
@@ -5,6 +5,11 @@ import { CoreModule } from './core/core.module';
|
||||
import { BrokerModule } from './modules/broker/broker.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { StorageModule } from './modules/storage/storage.module';
|
||||
import { StockInfoModule } from './modules/stock-info/stock-info.module';
|
||||
import { StockDailyPriceModule } from './modules/stock-daily-price/stock-daily-price.module';
|
||||
import { PositionModule } from './modules/position/position.module';
|
||||
import { PositionChangeModule } from './modules/position-change/position-change.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,6 +25,11 @@ import { AuthModule } from './modules/auth/auth.module';
|
||||
BrokerModule,
|
||||
UserModule,
|
||||
AuthModule,
|
||||
StorageModule,
|
||||
StockInfoModule,
|
||||
StockDailyPriceModule,
|
||||
PositionModule,
|
||||
PositionChangeModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+34
-10
@@ -6,34 +6,58 @@ import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import bodyParser from 'body-parser';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
|
||||
async function bootstrap() {
|
||||
const configService = new ConfigService();
|
||||
// const isProduction = configService.get('NODE_ENV') === 'production';
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bodyParser: false, // 禁用默认 bodyParser,使用自定义配置
|
||||
});
|
||||
|
||||
// 安全头设置(必须在其他中间件之前)
|
||||
app.use(helmet());
|
||||
// 配置 CORS(必须在其他中间件之前)
|
||||
app.enableCors({
|
||||
origin: true, // 允许所有域名
|
||||
credentials: true, // 允许携带凭证
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||
});
|
||||
|
||||
// 配置静态文件服务(需要在 CORS 之后,以便静态文件也能应用 CORS)
|
||||
const storagePath =
|
||||
configService.get<string>('STORAGE_PATH') || './uploads';
|
||||
app.useStaticAssets(join(process.cwd(), storagePath), {
|
||||
prefix: '/uploads/',
|
||||
setHeaders: (res) => {
|
||||
// 为静态文件添加 CORS 头
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
},
|
||||
});
|
||||
|
||||
// 安全头设置(必须在 CORS 之后)
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // 允许跨域资源访问
|
||||
}),
|
||||
);
|
||||
|
||||
// 速率限制
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60 * 1000, // 1 分钟
|
||||
max: 100, // 限制每个 IP 在 windowMs 时间内最多 10000 个请求
|
||||
max: 100, // 限制每个 IP 在 windowMs 时间内最多 100 个请求
|
||||
}),
|
||||
);
|
||||
|
||||
// 请求体解析(设置大小限制为 10mb)
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
|
||||
|
||||
app.enableCors();
|
||||
/* app.enableCors({
|
||||
// 允许的域名
|
||||
origin: ['http://localhost:3200'],
|
||||
}); */
|
||||
app.setGlobalPrefix('api');
|
||||
// 响应压缩(在 helmet 之后)
|
||||
app.use(compression());
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { User } from '../../user/user.entity';
|
||||
|
||||
/**
|
||||
* 资源所有者或管理员权限 Guard
|
||||
*
|
||||
* 用途:检查用户是否有权限访问资源
|
||||
* - 管理员(admin、super_admin)可以访问任何资源
|
||||
* - 普通用户只能访问自己的资源(通过比较 userId)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 查询用户信息:管理员可以查询任何用户,普通用户只能查询自己
|
||||
* - 更新用户信息:管理员可以更新任何用户,普通用户只能更新自己
|
||||
* - 删除用户:管理员可以删除任何用户,普通用户只能删除自己
|
||||
*
|
||||
* 使用示例:
|
||||
* @Get(':id')
|
||||
* @UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
* findOneById(@Param('id') id: string) { ... }
|
||||
*/
|
||||
@Injectable()
|
||||
export class OwnerOrAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: User;
|
||||
params: { id?: string };
|
||||
}>();
|
||||
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('未授权访问');
|
||||
}
|
||||
|
||||
// 获取请求的资源ID(从路由参数中)
|
||||
const requestedId = request.params?.id;
|
||||
|
||||
if (!requestedId) {
|
||||
// 如果没有提供资源ID,只允许管理员访问
|
||||
const isAdmin =
|
||||
user.role === 'admin' || user.role === 'super_admin';
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('权限不足,需要管理员权限');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedUserId = +requestedId;
|
||||
|
||||
// 检查权限:管理员可以访问任何资源,普通用户只能访问自己的资源
|
||||
const isAdmin = user.role === 'admin' || user.role === 'super_admin';
|
||||
const isOwner = user.userId === requestedUserId;
|
||||
|
||||
if (!isAdmin && !isOwner) {
|
||||
throw new ForbiddenException(
|
||||
'权限不足,只能访问自己的资源或需要管理员权限',
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,25 @@ import {
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { BrokerService } from './broker.service';
|
||||
import { CreateBrokerDto } from './dto/create-broker.dto';
|
||||
import { UpdateBrokerDto } from './dto/update-broker.dto';
|
||||
import { QueryBrokerDto } from './dto/query-broker.dto';
|
||||
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
||||
import { PaginatedBrokerData } from './dto/paginated-response.dto';
|
||||
import { Broker } from './broker.entity';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('broker')
|
||||
@Controller('broker')
|
||||
@@ -27,6 +38,9 @@ export class BrokerController {
|
||||
* 单独创建 broker
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '创建券商', description: '创建单个券商信息' })
|
||||
@ApiResponse({
|
||||
@@ -44,6 +58,9 @@ export class BrokerController {
|
||||
* 批量创建 broker
|
||||
*/
|
||||
@Post('batch')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '批量创建券商',
|
||||
@@ -63,9 +80,9 @@ export class BrokerController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 broker(支持多种查询条件)
|
||||
* 查询 broker(支持多种查询条件和分页)
|
||||
* 支持按 broker_id、broker_code、broker_name、region 查询
|
||||
* 返回一个或多个 broker
|
||||
* 返回分页数据
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
@@ -75,9 +92,9 @@ export class BrokerController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: [Broker],
|
||||
type: PaginatedBrokerData,
|
||||
})
|
||||
findAll(@Query() queryDto: QueryBrokerDto): Promise<Broker[]> {
|
||||
findAll(@Query() queryDto: QueryBrokerDto): Promise<PaginatedBrokerData> {
|
||||
return this.brokerService.findAll(queryDto);
|
||||
}
|
||||
|
||||
@@ -104,6 +121,9 @@ export class BrokerController {
|
||||
* 更新 broker
|
||||
*/
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '更新券商',
|
||||
description: '更新券商的部分或全部信息',
|
||||
@@ -127,6 +147,9 @@ export class BrokerController {
|
||||
* 删除 broker
|
||||
*/
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: '删除券商',
|
||||
|
||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BrokerService } from './broker.service';
|
||||
import { BrokerController } from './broker.controller';
|
||||
import { Broker } from './broker.entity';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Broker])],
|
||||
imports: [TypeOrmModule.forFeature([Broker]), StorageModule],
|
||||
controllers: [BrokerController],
|
||||
providers: [BrokerService],
|
||||
exports: [BrokerService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
@@ -10,12 +11,17 @@ import { CreateBrokerDto } from './dto/create-broker.dto';
|
||||
import { UpdateBrokerDto } from './dto/update-broker.dto';
|
||||
import { QueryBrokerDto } from './dto/query-broker.dto';
|
||||
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerService {
|
||||
private readonly logger = new Logger(BrokerService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Broker)
|
||||
private readonly brokerRepository: Repository<Broker>,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -109,9 +115,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> = {};
|
||||
|
||||
if (queryDto.brokerId) {
|
||||
@@ -134,13 +143,53 @@ export class BrokerService {
|
||||
where.isActive = queryDto.isActive;
|
||||
}
|
||||
|
||||
return this.brokerRepository.find({
|
||||
// 分页参数
|
||||
const page = queryDto.page || 1;
|
||||
const limit = queryDto.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// 排序字段映射
|
||||
const sortBy = queryDto.sortBy || 'createdAt';
|
||||
const sortOrder = queryDto.sortOrder || 'DESC';
|
||||
|
||||
// 构建排序对象
|
||||
const order: Record<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,
|
||||
order: {
|
||||
sortOrder: 'ASC',
|
||||
brokerId: 'ASC',
|
||||
},
|
||||
order,
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,9 +295,30 @@ export class BrokerService {
|
||||
|
||||
/**
|
||||
* 删除 broker
|
||||
* 同时删除券商Logo图片
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const broker = await this.findOne(id);
|
||||
|
||||
// 删除券商Logo图片
|
||||
if (broker.brokerImage) {
|
||||
try {
|
||||
const imagePath = this.storageService.extractStoragePath(
|
||||
broker.brokerImage,
|
||||
);
|
||||
if (imagePath) {
|
||||
await this.storageService.delete(imagePath);
|
||||
this.logger.log(`已删除券商Logo: ${imagePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// 图片删除失败不影响券商删除操作
|
||||
this.logger.warn(
|
||||
`删除券商Logo失败: ${broker.brokerImage}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.brokerRepository.remove(broker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePositionChangeDto {
|
||||
@ApiProperty({
|
||||
description: '持仓ID',
|
||||
example: 1,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
positionId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更日期',
|
||||
example: '2024-01-01',
|
||||
})
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
changeDate: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更类型',
|
||||
example: 'buy',
|
||||
enum: ['buy', 'sell', 'auto'],
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['buy', 'sell', 'auto'])
|
||||
changeType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更前份额/数量',
|
||||
example: 100,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
beforeShares: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更前成本价',
|
||||
example: 1600.0,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.0001)
|
||||
beforeCostPrice: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更后份额/数量',
|
||||
example: 150,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
afterShares: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更后成本价',
|
||||
example: 1650.0,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.0001)
|
||||
afterCostPrice: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '备注/思考',
|
||||
example: '加仓买入,看好长期走势',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PositionChange } from '../position-change.entity';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
|
||||
/**
|
||||
* 持仓变更记录分页响应数据
|
||||
*/
|
||||
export class PaginatedPositionChangeData {
|
||||
@ApiProperty({
|
||||
description: '持仓变更记录列表',
|
||||
type: [PositionChange],
|
||||
})
|
||||
list: PositionChange[];
|
||||
|
||||
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IsOptional, IsNumber, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class QueryPositionChangeDto {
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdatePositionChangeDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '备注/思考',
|
||||
example: '加仓买入,看好长期走势',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { PositionChangeService } from './position-change.service';
|
||||
import { CreatePositionChangeDto } from './dto/create-position-change.dto';
|
||||
import { UpdatePositionChangeDto } from './dto/update-position-change.dto';
|
||||
import { QueryPositionChangeDto } from './dto/query-position-change.dto';
|
||||
import { PositionChange } from './position-change.entity';
|
||||
import { PaginatedPositionChangeData } from './dto/paginated-response.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@ApiTags('position-change')
|
||||
@Controller('position-change')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class PositionChangeController {
|
||||
constructor(
|
||||
private readonly positionChangeService: PositionChangeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 分页查询单个持仓的所有变更记录
|
||||
*/
|
||||
@Get('position/:positionId')
|
||||
@ApiOperation({
|
||||
summary: '查询单个持仓的所有变更记录',
|
||||
description: '分页查询指定持仓的所有变更记录,按变更日期倒序',
|
||||
})
|
||||
@ApiParam({ name: 'positionId', description: '持仓ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: PaginatedPositionChangeData,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 404, description: '持仓不存在' })
|
||||
findAllByPositionId(
|
||||
@Request() req: { user: User },
|
||||
@Param('positionId') positionId: string,
|
||||
@Query() queryDto: QueryPositionChangeDto,
|
||||
): Promise<PaginatedPositionChangeData> {
|
||||
return this.positionChangeService.findAllByPositionId(
|
||||
+positionId,
|
||||
req.user.userId,
|
||||
queryDto,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建持仓变更记录
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '创建持仓变更记录',
|
||||
description: '为指定持仓创建新的变更记录',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '创建成功',
|
||||
type: PositionChange,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 404, description: '持仓不存在' })
|
||||
create(
|
||||
@Request() req: { user: User },
|
||||
@Body() createPositionChangeDto: CreatePositionChangeDto,
|
||||
): Promise<PositionChange> {
|
||||
return this.positionChangeService.create(
|
||||
req.user.userId,
|
||||
createPositionChangeDto,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新持仓变更记录的备注/思考
|
||||
*/
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: '更新持仓变更记录的备注',
|
||||
description: '更新指定变更记录的备注/思考内容',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '变更记录ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: PositionChange,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '变更记录不存在' })
|
||||
@ApiResponse({ status: 403, description: '无权访问' })
|
||||
update(
|
||||
@Request() req: { user: User },
|
||||
@Param('id') id: string,
|
||||
@Body() updatePositionChangeDto: UpdatePositionChangeDto,
|
||||
): Promise<PositionChange> {
|
||||
return this.positionChangeService.update(
|
||||
+id,
|
||||
req.user.userId,
|
||||
updatePositionChangeDto,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@Entity('position_changes')
|
||||
export class PositionChange {
|
||||
@ApiProperty({ description: '变更记录ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ name: 'change_id' })
|
||||
changeId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '持仓ID',
|
||||
example: 1,
|
||||
})
|
||||
@Column({ name: 'position_id', type: 'bigint' })
|
||||
@Index()
|
||||
positionId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更日期',
|
||||
example: '2024-01-01',
|
||||
})
|
||||
@Column({ name: 'change_date', type: 'date' })
|
||||
@Index()
|
||||
changeDate: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更类型',
|
||||
example: 'buy',
|
||||
enum: ['buy', 'sell', 'auto'],
|
||||
})
|
||||
@Column({
|
||||
name: 'change_type',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
})
|
||||
changeType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更前份额/数量',
|
||||
example: 100,
|
||||
})
|
||||
@Column({
|
||||
name: 'before_shares',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
})
|
||||
beforeShares: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更前成本价',
|
||||
example: 1600.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'before_cost_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
})
|
||||
beforeCostPrice: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更后份额/数量',
|
||||
example: 150,
|
||||
})
|
||||
@Column({
|
||||
name: 'after_shares',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
})
|
||||
afterShares: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '变更后成本价',
|
||||
example: 1650.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'after_cost_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
})
|
||||
afterCostPrice: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '备注/思考',
|
||||
example: '加仓买入,看好长期走势',
|
||||
})
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PositionChangeService } from './position-change.service';
|
||||
import { PositionChangeController } from './position-change.controller';
|
||||
import { PositionChange } from './position-change.entity';
|
||||
import { Position } from '../position/position.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PositionChange, Position])],
|
||||
controllers: [PositionChangeController],
|
||||
providers: [PositionChangeService],
|
||||
exports: [PositionChangeService],
|
||||
})
|
||||
export class PositionChangeModule {}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PositionChange } from './position-change.entity';
|
||||
import { CreatePositionChangeDto } from './dto/create-position-change.dto';
|
||||
import { UpdatePositionChangeDto } from './dto/update-position-change.dto';
|
||||
import { QueryPositionChangeDto } from './dto/query-position-change.dto';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
import { Position } from '../position/position.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PositionChangeService {
|
||||
private readonly logger = new Logger(PositionChangeService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PositionChange)
|
||||
private readonly positionChangeRepository: Repository<PositionChange>,
|
||||
@InjectRepository(Position)
|
||||
private readonly positionRepository: Repository<Position>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 分页查询单个持仓的所有变更记录
|
||||
*/
|
||||
async findAllByPositionId(
|
||||
positionId: number,
|
||||
userId: number,
|
||||
queryDto: QueryPositionChangeDto,
|
||||
): Promise<{
|
||||
list: PositionChange[];
|
||||
pagination: PaginationInfo;
|
||||
}> {
|
||||
// 验证持仓是否属于当前用户
|
||||
const position = await this.positionRepository.findOne({
|
||||
where: { positionId, userId },
|
||||
});
|
||||
|
||||
if (!position) {
|
||||
throw new NotFoundException(`持仓不存在:ID ${positionId}`);
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
const page = queryDto.page || 1;
|
||||
const limit = queryDto.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// 查询总数
|
||||
const total = await this.positionChangeRepository.count({
|
||||
where: { positionId },
|
||||
});
|
||||
|
||||
// 查询分页数据(按变更日期和创建时间倒序)
|
||||
const list = await this.positionChangeRepository.find({
|
||||
where: { positionId },
|
||||
order: {
|
||||
changeDate: 'DESC',
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建持仓变更记录
|
||||
*/
|
||||
async create(
|
||||
userId: number,
|
||||
createPositionChangeDto: CreatePositionChangeDto,
|
||||
): Promise<PositionChange> {
|
||||
// 验证持仓是否属于当前用户
|
||||
const position = await this.positionRepository.findOne({
|
||||
where: {
|
||||
positionId: createPositionChangeDto.positionId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!position) {
|
||||
throw new NotFoundException(
|
||||
`持仓不存在:ID ${createPositionChangeDto.positionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 创建变更记录
|
||||
const positionChange = this.positionChangeRepository.create({
|
||||
...createPositionChangeDto,
|
||||
changeDate: new Date(createPositionChangeDto.changeDate),
|
||||
});
|
||||
|
||||
return this.positionChangeRepository.save(positionChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新持仓变更记录的备注/思考
|
||||
*/
|
||||
async update(
|
||||
changeId: number,
|
||||
userId: number,
|
||||
updatePositionChangeDto: UpdatePositionChangeDto,
|
||||
): Promise<PositionChange> {
|
||||
// 查找变更记录
|
||||
const positionChange = await this.positionChangeRepository.findOne({
|
||||
where: { changeId },
|
||||
relations: [], // 不加载关联,后面手动验证
|
||||
});
|
||||
|
||||
if (!positionChange) {
|
||||
throw new NotFoundException(`变更记录不存在:ID ${changeId}`);
|
||||
}
|
||||
|
||||
// 验证持仓是否属于当前用户
|
||||
const position = await this.positionRepository.findOne({
|
||||
where: {
|
||||
positionId: positionChange.positionId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!position) {
|
||||
throw new ForbiddenException('无权访问该持仓的变更记录');
|
||||
}
|
||||
|
||||
// 只更新备注字段
|
||||
if (updatePositionChangeDto.notes !== undefined) {
|
||||
positionChange.notes = updatePositionChangeDto.notes;
|
||||
}
|
||||
|
||||
return this.positionChangeRepository.save(positionChange);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
MaxLength,
|
||||
IsIn,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePositionDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '券商ID(可选)',
|
||||
example: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
brokerId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产类型',
|
||||
example: 'stock',
|
||||
enum: ['stock', 'fund', 'cash', 'bond', 'other'],
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['stock', 'fund', 'cash', 'bond', 'other'])
|
||||
assetType: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '资产代码(股票代码、基金代码等),现金和其他类型可为空',
|
||||
example: '600519',
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
symbol?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产名称,现金类型固定为"现金"',
|
||||
example: '贵州茅台',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市场(A股/港股/美股等)',
|
||||
example: 'sh',
|
||||
maxLength: 20,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
market?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '持仓份额/数量',
|
||||
example: 100,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
shares: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '成本价(每股/每份)',
|
||||
example: 1600.0,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.0001)
|
||||
costPrice: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最新市场价(系统自动更新)',
|
||||
example: 1850.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
currentPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '货币类型',
|
||||
example: 'CNY',
|
||||
default: 'CNY',
|
||||
maxLength: 10,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '汇率(用于多货币)',
|
||||
example: 1.0,
|
||||
default: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
exchangeRate?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否自动更新价格(付费用户功能)',
|
||||
example: false,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoPriceUpdate?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '状态',
|
||||
example: 'active',
|
||||
enum: ['active', 'suspended', 'delisted'],
|
||||
default: 'active',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['active', 'suspended', 'delisted'])
|
||||
status?: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Position } from '../position.entity';
|
||||
|
||||
export class PositionResponseDto extends Position {
|
||||
@ApiProperty({
|
||||
description: '持仓成本(持仓份额 × 成本价)',
|
||||
example: 160000.0,
|
||||
})
|
||||
costValue: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '持仓市值(持仓份额 × 最新市场价)',
|
||||
example: 185000.0,
|
||||
})
|
||||
marketValue: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '持仓盈亏(持仓市值 - 持仓成本)',
|
||||
example: 25000.0,
|
||||
})
|
||||
profit: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '持仓盈利比例(%)',
|
||||
example: 15.625,
|
||||
})
|
||||
profitPercent: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '持仓天数(当前时间 - 创建时间)',
|
||||
example: 365,
|
||||
})
|
||||
holdingDays: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '当前持仓占用户总资产的百分比(%)',
|
||||
example: 25.5,
|
||||
})
|
||||
assetPercent: number;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class SearchAssetDto {
|
||||
@ApiProperty({
|
||||
description: '搜索关键词(股票代码或名称)',
|
||||
example: '600519',
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
keyword: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产类型过滤(可选)',
|
||||
example: 'stock',
|
||||
enum: ['stock', 'fund', 'bond'],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assetType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '返回结果数量限制',
|
||||
example: 10,
|
||||
default: 10,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class AssetSearchResult {
|
||||
@ApiProperty({ description: '股票代码', example: '600519' })
|
||||
symbol: string;
|
||||
|
||||
@ApiProperty({ description: '股票名称', example: '贵州茅台' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '市场代码', example: 'sh' })
|
||||
market: string;
|
||||
|
||||
@ApiProperty({ description: '资产类型', example: 'stock' })
|
||||
assetType: string;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsIn,
|
||||
Min,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdatePositionDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '券商ID',
|
||||
example: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
brokerId?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '成本价(每股/每份)',
|
||||
example: 1600.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.0001)
|
||||
costPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最新市场价(系统自动更新)',
|
||||
example: 1850.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
currentPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '货币类型',
|
||||
example: 'CNY',
|
||||
maxLength: 10,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '汇率(用于多货币)',
|
||||
example: 1.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
exchangeRate?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '状态',
|
||||
example: 'active',
|
||||
enum: ['active', 'suspended', 'delisted'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['active', 'suspended', 'delisted'])
|
||||
status?: string;
|
||||
|
||||
// 注意:assetType, symbol, name, market 字段不允许更新
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Request,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { PositionService } from './position.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
import { PositionResponseDto } from './dto/position-response.dto';
|
||||
import { AssetSearchResult } from './dto/search-asset.dto';
|
||||
import { Position } from './position.entity';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@ApiTags('position')
|
||||
@Controller('position')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class PositionController {
|
||||
constructor(private readonly positionService: PositionService) {}
|
||||
|
||||
/**
|
||||
* 搜索资产(股票代码或名称)
|
||||
*/
|
||||
@Get('search')
|
||||
@ApiOperation({
|
||||
summary: '搜索资产',
|
||||
description: '根据关键词搜索股票代码或名称,支持字符串匹配',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'keyword',
|
||||
description: '搜索关键词(股票代码或名称)',
|
||||
example: '600519',
|
||||
required: true,
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'assetType',
|
||||
description: '资产类型过滤(可选)',
|
||||
example: 'stock',
|
||||
enum: ['stock', 'fund', 'bond'],
|
||||
required: false,
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
description: '返回结果数量限制',
|
||||
example: 10,
|
||||
required: false,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '搜索成功',
|
||||
type: [AssetSearchResult],
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
async searchAssets(
|
||||
@Query('keyword') keyword: string,
|
||||
@Query('assetType') assetType?: string,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<AssetSearchResult[]> {
|
||||
return this.positionService.searchAssets(keyword, assetType, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户所有持仓(包含计算字段)
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '查询用户所有持仓',
|
||||
description:
|
||||
'查询当前登录用户的所有持仓信息,包含计算字段:持仓成本、持仓市值、持仓盈亏、持仓盈利比例、持仓天数、占用户总资产百分比',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: [PositionResponseDto],
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
findAll(@Request() req: { user: User }): Promise<PositionResponseDto[]> {
|
||||
return this.positionService.findAllByUserId(req.user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建持仓
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '创建持仓',
|
||||
description: '为当前登录用户创建新的持仓记录',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '创建成功',
|
||||
type: Position,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '持仓已存在' })
|
||||
create(
|
||||
@Request() req: { user: User },
|
||||
@Body() createPositionDto: CreatePositionDto,
|
||||
): Promise<Position> {
|
||||
return this.positionService.create(req.user.userId, createPositionDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新持仓
|
||||
*/
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: '更新持仓',
|
||||
description:
|
||||
'更新持仓信息,只能更新:成本价、最新市场价、货币类型、券商、状态、汇率。资产类型、资产代码和名称、市场不可修改。',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '持仓ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: Position,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '持仓不存在' })
|
||||
update(
|
||||
@Request() req: { user: User },
|
||||
@Param('id') id: string,
|
||||
@Body() updatePositionDto: UpdatePositionDto,
|
||||
): Promise<Position> {
|
||||
return this.positionService.update(
|
||||
+id,
|
||||
req.user.userId,
|
||||
updatePositionDto,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除持仓
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: '删除持仓',
|
||||
description: '删除指定的持仓记录',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '持仓ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '持仓不存在' })
|
||||
remove(
|
||||
@Request() req: { user: User },
|
||||
@Param('id') id: string,
|
||||
): Promise<void> {
|
||||
return this.positionService.remove(+id, req.user.userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@Entity('positions')
|
||||
@Unique(['userId', 'brokerId', 'symbol', 'market', 'assetType'])
|
||||
export class Position {
|
||||
@ApiProperty({ description: '持仓ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ name: 'position_id' })
|
||||
positionId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户ID',
|
||||
example: 1,
|
||||
})
|
||||
@Column({
|
||||
name: 'user_id',
|
||||
type: 'bigint',
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => (value ? parseInt(value, 10) : null),
|
||||
},
|
||||
})
|
||||
@Index()
|
||||
userId: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '券商ID(可选)',
|
||||
example: 1,
|
||||
})
|
||||
@Column({
|
||||
name: 'broker_id',
|
||||
type: 'bigint',
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (value: number | null | undefined) => value ?? null,
|
||||
from: (value: string | null) =>
|
||||
value ? parseInt(value, 10) : null,
|
||||
},
|
||||
})
|
||||
@Index()
|
||||
brokerId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产类型',
|
||||
example: 'stock',
|
||||
enum: ['stock', 'fund', 'cash', 'bond', 'other'],
|
||||
})
|
||||
@Column({
|
||||
name: 'asset_type',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
})
|
||||
@Index()
|
||||
assetType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产代码(股票代码、基金代码等)',
|
||||
example: '600519',
|
||||
maxLength: 50,
|
||||
})
|
||||
@Column({ name: 'symbol', type: 'varchar', length: 50 })
|
||||
@Index()
|
||||
symbol: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产名称',
|
||||
example: '贵州茅台',
|
||||
maxLength: 100,
|
||||
})
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市场(A股/港股/美股等)',
|
||||
example: 'sh',
|
||||
maxLength: 20,
|
||||
})
|
||||
@Column({ name: 'market', type: 'varchar', length: 20, nullable: true })
|
||||
market?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '持仓份额/数量',
|
||||
example: 100,
|
||||
})
|
||||
@Column({
|
||||
name: 'shares',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
default: 0,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => (value ? parseFloat(value) : 0),
|
||||
},
|
||||
})
|
||||
shares: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '成本价(每股/每份)',
|
||||
example: 1600.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'cost_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => (value ? parseFloat(value) : null),
|
||||
},
|
||||
})
|
||||
costPrice: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最新市场价(系统自动更新)',
|
||||
example: 1850.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'current_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (value: number | undefined) => value,
|
||||
from: (value: string | null) => (value ? parseFloat(value) : null),
|
||||
},
|
||||
})
|
||||
currentPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '上一次的价格(用于对比显示红绿色)',
|
||||
example: 1800.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'previous_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (value: number | undefined) => value,
|
||||
from: (value: string | null) => (value ? parseFloat(value) : null),
|
||||
},
|
||||
})
|
||||
previousPrice?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '货币类型',
|
||||
example: 'CNY',
|
||||
default: 'CNY',
|
||||
maxLength: 10,
|
||||
})
|
||||
@Column({
|
||||
name: 'currency',
|
||||
type: 'varchar',
|
||||
length: 10,
|
||||
default: 'CNY',
|
||||
})
|
||||
currency: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '汇率(用于多货币)',
|
||||
example: 1.0,
|
||||
default: 1,
|
||||
})
|
||||
@Column({
|
||||
name: 'exchange_rate',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 6,
|
||||
default: 1,
|
||||
transformer: {
|
||||
to: (value: number | undefined) => value,
|
||||
from: (value: string | null) => (value ? parseFloat(value) : 1),
|
||||
},
|
||||
})
|
||||
exchangeRate?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '是否自动更新价格(付费用户功能)',
|
||||
example: false,
|
||||
default: false,
|
||||
})
|
||||
@Column({
|
||||
name: 'auto_price_update',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
autoPriceUpdate: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '状态',
|
||||
example: 'active',
|
||||
enum: ['active', 'suspended', 'delisted'],
|
||||
default: 'active',
|
||||
})
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: 'active',
|
||||
})
|
||||
@Index()
|
||||
status: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PositionService } from './position.service';
|
||||
import { PositionController } from './position.controller';
|
||||
import { Position } from './position.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Position])],
|
||||
controllers: [PositionController],
|
||||
providers: [PositionService],
|
||||
exports: [PositionService],
|
||||
})
|
||||
export class PositionModule {}
|
||||
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, FindOptionsWhere } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Position } from './position.entity';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
import { PositionResponseDto } from './dto/position-response.dto';
|
||||
import { AssetSearchResult } from './dto/search-asset.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PositionService {
|
||||
private readonly logger = new Logger(PositionService.name);
|
||||
private stockDataCache: Record<string, string> | null = null;
|
||||
private stockDataCacheTime: number = 0;
|
||||
private readonly CACHE_TTL = 60 * 60 * 1000; // 缓存1小时
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Position)
|
||||
private readonly positionRepository: Repository<Position>,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 从HTTP地址加载股票数据
|
||||
*/
|
||||
private async loadStockData(): Promise<Record<string, string>> {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果缓存有效,直接返回
|
||||
if (
|
||||
this.stockDataCache &&
|
||||
now - this.stockDataCacheTime < this.CACHE_TTL
|
||||
) {
|
||||
return this.stockDataCache;
|
||||
}
|
||||
|
||||
try {
|
||||
// 从本地文件系统读取(uploads目录已配置为静态文件服务)
|
||||
// 获取uploads目录路径
|
||||
const storagePath =
|
||||
this.configService.get<string>('STORAGE_PATH') || './uploads';
|
||||
const stockDataPath = join(
|
||||
process.cwd(),
|
||||
storagePath,
|
||||
'stock',
|
||||
'stock-data.json',
|
||||
);
|
||||
|
||||
this.logger.log(`正在从 ${stockDataPath} 加载股票数据...`);
|
||||
|
||||
// 读取文件
|
||||
const fileContent = readFileSync(stockDataPath, 'utf-8');
|
||||
const data = JSON.parse(fileContent);
|
||||
|
||||
// 更新缓存
|
||||
this.stockDataCache = data;
|
||||
this.stockDataCacheTime = now;
|
||||
|
||||
this.logger.log(
|
||||
`股票数据加载成功,共 ${Object.keys(data).length} 个市场`,
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`加载股票数据失败: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw new BadRequestException(`加载股票数据失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索资产(股票代码或名称)
|
||||
*/
|
||||
async searchAssets(
|
||||
keyword: string,
|
||||
assetType?: string,
|
||||
limit: number = 10,
|
||||
): Promise<AssetSearchResult[]> {
|
||||
if (!keyword || keyword.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const stockData = await this.loadStockData();
|
||||
const results: AssetSearchResult[] = [];
|
||||
const keywordLower = keyword.toLowerCase().trim();
|
||||
|
||||
// 市场映射:市场代码 -> 资产类型
|
||||
const marketToAssetType: Record<string, string> = {
|
||||
sh: 'stock',
|
||||
sz: 'stock',
|
||||
bj: 'stock',
|
||||
hk: 'stock',
|
||||
us: 'stock',
|
||||
};
|
||||
|
||||
// 遍历所有市场
|
||||
for (const [market, stockList] of Object.entries(stockData)) {
|
||||
// 如果指定了资产类型,跳过不匹配的市场
|
||||
if (assetType && marketToAssetType[market] !== assetType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析股票列表(格式:代码_名称|代码_名称|...)
|
||||
const stocks = stockList.split('|');
|
||||
|
||||
for (const stock of stocks) {
|
||||
if (!stock || stock.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [symbol, ...nameParts] = stock.split('_');
|
||||
const name = nameParts.join('_'); // 处理名称中可能包含下划线的情况
|
||||
|
||||
if (!symbol || !name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 字符串匹配:代码或名称包含关键词(不区分大小写)
|
||||
const symbolMatch = symbol
|
||||
.toLowerCase()
|
||||
.includes(keywordLower);
|
||||
const nameMatch = name.toLowerCase().includes(keywordLower);
|
||||
|
||||
if (symbolMatch || nameMatch) {
|
||||
// 计算匹配度(完全匹配 > 前缀匹配 > 包含匹配)
|
||||
let score = 0;
|
||||
if (symbol.toLowerCase() === keywordLower) {
|
||||
score = 100; // 代码完全匹配
|
||||
} else if (name.toLowerCase() === keywordLower) {
|
||||
score = 90; // 名称完全匹配
|
||||
} else if (
|
||||
symbol.toLowerCase().startsWith(keywordLower)
|
||||
) {
|
||||
score = 80; // 代码前缀匹配
|
||||
} else if (
|
||||
name.toLowerCase().startsWith(keywordLower)
|
||||
) {
|
||||
score = 70; // 名称前缀匹配
|
||||
} else {
|
||||
score = symbolMatch ? 60 : 50; // 包含匹配
|
||||
}
|
||||
|
||||
results.push({
|
||||
symbol,
|
||||
name,
|
||||
market,
|
||||
assetType: marketToAssetType[market] || 'stock',
|
||||
score, // 用于排序(内部使用)
|
||||
} as AssetSearchResult & { score: number });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按匹配度排序,然后限制结果数量
|
||||
const sortedResults = (
|
||||
results as (AssetSearchResult & { score: number })[]
|
||||
)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ score, ...rest }) => rest as AssetSearchResult); // 移除score字段
|
||||
|
||||
return sortedResults;
|
||||
} catch (error) {
|
||||
this.logger.error(`搜索资产失败: ${error.message}`, error.stack);
|
||||
throw new BadRequestException(`搜索资产失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户所有持仓(包含计算字段)
|
||||
*/
|
||||
async findAllByUserId(userId: number): Promise<PositionResponseDto[]> {
|
||||
// 查询用户所有持仓
|
||||
const positions = await this.positionRepository.find({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
// 计算用户总资产(所有active持仓的市值总和)
|
||||
const totalAsset = await this.calculateUserTotalAsset(userId);
|
||||
|
||||
// 计算每个持仓的额外字段
|
||||
const result: PositionResponseDto[] = positions.map((position) => {
|
||||
const costValue = position.shares * position.costPrice;
|
||||
const marketValue = position.currentPrice
|
||||
? position.shares * position.currentPrice
|
||||
: 0;
|
||||
const profit = marketValue - costValue;
|
||||
const profitPercent =
|
||||
costValue > 0 ? (profit / costValue) * 100 : 0;
|
||||
const holdingDays = Math.floor(
|
||||
(Date.now() - position.createdAt.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
);
|
||||
const assetPercent =
|
||||
totalAsset > 0 ? (marketValue / totalAsset) * 100 : 0;
|
||||
|
||||
return {
|
||||
...position,
|
||||
costValue,
|
||||
marketValue,
|
||||
profit,
|
||||
profitPercent,
|
||||
holdingDays,
|
||||
assetPercent,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算用户总资产(所有active持仓的市值总和)
|
||||
*/
|
||||
private async calculateUserTotalAsset(userId: number): Promise<number> {
|
||||
const positions = await this.positionRepository.find({
|
||||
where: { userId, status: 'active' },
|
||||
});
|
||||
|
||||
let totalAsset = 0;
|
||||
for (const position of positions) {
|
||||
if (position.currentPrice) {
|
||||
const marketValue = position.shares * position.currentPrice;
|
||||
totalAsset += marketValue;
|
||||
}
|
||||
}
|
||||
|
||||
return totalAsset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建持仓
|
||||
*/
|
||||
async create(
|
||||
userId: number,
|
||||
createPositionDto: CreatePositionDto,
|
||||
): Promise<Position> {
|
||||
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
|
||||
const whereCondition: FindOptionsWhere<Position> = {
|
||||
userId,
|
||||
symbol: createPositionDto.symbol || '',
|
||||
assetType: createPositionDto.assetType,
|
||||
};
|
||||
|
||||
// 处理 brokerId 字段:如果为 undefined 或 null,查询 null 值
|
||||
if (
|
||||
createPositionDto.brokerId !== undefined &&
|
||||
createPositionDto.brokerId !== null
|
||||
) {
|
||||
whereCondition.brokerId = createPositionDto.brokerId;
|
||||
} else {
|
||||
whereCondition.brokerId = IsNull();
|
||||
}
|
||||
|
||||
// 处理 market 字段:如果为 undefined 或空字符串,查询 null 值
|
||||
if (createPositionDto.market) {
|
||||
whereCondition.market = createPositionDto.market;
|
||||
} else {
|
||||
whereCondition.market = IsNull();
|
||||
}
|
||||
|
||||
const existing = await this.positionRepository.findOne({
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`该持仓已存在:${createPositionDto.name} (${createPositionDto.symbol})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 创建持仓
|
||||
const position = this.positionRepository.create({
|
||||
...createPositionDto,
|
||||
userId,
|
||||
currency: createPositionDto.currency || 'CNY',
|
||||
exchangeRate: createPositionDto.exchangeRate || 1,
|
||||
autoPriceUpdate: createPositionDto.autoPriceUpdate || false,
|
||||
status: createPositionDto.status || 'active',
|
||||
});
|
||||
|
||||
return this.positionRepository.save(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新持仓(只能更新允许的字段)
|
||||
*/
|
||||
async update(
|
||||
positionId: number,
|
||||
userId: number,
|
||||
updatePositionDto: UpdatePositionDto,
|
||||
): Promise<Position> {
|
||||
// 查找持仓
|
||||
const position = await this.positionRepository.findOne({
|
||||
where: { positionId, userId },
|
||||
});
|
||||
|
||||
if (!position) {
|
||||
throw new NotFoundException(`持仓不存在:ID ${positionId}`);
|
||||
}
|
||||
|
||||
// 只更新允许的字段
|
||||
if (updatePositionDto.brokerId !== undefined) {
|
||||
position.brokerId = updatePositionDto.brokerId;
|
||||
}
|
||||
if (updatePositionDto.costPrice !== undefined) {
|
||||
position.costPrice = updatePositionDto.costPrice;
|
||||
}
|
||||
if (updatePositionDto.currentPrice !== undefined) {
|
||||
// 更新价格时,将当前价格保存到 previous_price
|
||||
if (
|
||||
position.currentPrice !== null &&
|
||||
position.currentPrice !== undefined
|
||||
) {
|
||||
position.previousPrice = position.currentPrice;
|
||||
}
|
||||
position.currentPrice = updatePositionDto.currentPrice;
|
||||
}
|
||||
if (updatePositionDto.currency !== undefined) {
|
||||
position.currency = updatePositionDto.currency;
|
||||
}
|
||||
if (updatePositionDto.exchangeRate !== undefined) {
|
||||
position.exchangeRate = updatePositionDto.exchangeRate;
|
||||
}
|
||||
if (updatePositionDto.status !== undefined) {
|
||||
position.status = updatePositionDto.status;
|
||||
}
|
||||
|
||||
// 注意:assetType, symbol, name, market 字段不允许更新
|
||||
|
||||
return this.positionRepository.save(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除持仓
|
||||
*/
|
||||
async remove(positionId: number, userId: number): Promise<void> {
|
||||
const position = await this.positionRepository.findOne({
|
||||
where: { positionId, userId },
|
||||
});
|
||||
|
||||
if (!position) {
|
||||
throw new NotFoundException(`持仓不存在:ID ${positionId}`);
|
||||
}
|
||||
|
||||
await this.positionRepository.remove(position);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { CreateStockDailyPriceDto } from './create-stock-daily-price.dto';
|
||||
|
||||
export class BatchCreateStockDailyPriceDto {
|
||||
@ApiProperty({
|
||||
description: '股票每日价格列表',
|
||||
type: [CreateStockDailyPriceDto],
|
||||
example: [
|
||||
{
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
market: 'sh',
|
||||
tradeDate: '2024-01-01',
|
||||
openPrice: 1000.0,
|
||||
closePrice: 1050.0,
|
||||
highPrice: 1060.0,
|
||||
lowPrice: 995.0,
|
||||
volume: 1000000,
|
||||
amount: 1050000000.0,
|
||||
},
|
||||
],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateStockDailyPriceDto)
|
||||
prices: CreateStockDailyPriceDto[];
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateStockDailyPriceDto {
|
||||
@ApiProperty({
|
||||
description: '股票代码',
|
||||
example: '600519',
|
||||
maxLength: 20,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
stockCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '股票名称',
|
||||
example: '贵州茅台',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
stockName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '市场标识',
|
||||
example: 'sh',
|
||||
maxLength: 20,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
market: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '交易日期',
|
||||
example: '2024-01-01',
|
||||
})
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
tradeDate: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '开盘价',
|
||||
example: 1000.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
openPrice?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '收盘价',
|
||||
example: 1050.0,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
closePrice: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最高价',
|
||||
example: 1060.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
highPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最低价',
|
||||
example: 995.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
lowPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '成交量(单位:手)',
|
||||
example: 1000000,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
volume?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '成交额(单位:元)',
|
||||
example: 1050000000.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '涨跌额',
|
||||
example: 50.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
changeAmount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '涨跌幅(%)',
|
||||
example: 5.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
changePercent?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '60日涨跌幅(%)',
|
||||
example: 15.5,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
changePercent60day?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '年初至今涨跌幅(%)',
|
||||
example: 25.8,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
changePercentYtd?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '换手率(%)',
|
||||
example: 2.5,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
turnoverRate?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市盈率',
|
||||
example: 35.5,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
peRatio?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市净率',
|
||||
example: 8.5,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
pbRatio?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '总市值(单位:元)',
|
||||
example: 2000000000000.0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
marketCap?: number;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StockDailyPrice } from '../stock-daily-price.entity';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
|
||||
/**
|
||||
* 股票每日价格分页响应数据
|
||||
*/
|
||||
export class PaginatedStockDailyPriceData {
|
||||
@ApiProperty({
|
||||
description: '股票每日价格列表',
|
||||
type: [StockDailyPrice],
|
||||
})
|
||||
list: StockDailyPrice[];
|
||||
|
||||
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class QueryStockDailyPriceDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '股票代码',
|
||||
example: '600519',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stockCode?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '股票名称(模糊查询)',
|
||||
example: '茅台',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stockName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市场标识',
|
||||
example: 'sh',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
market?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '交易日期(模糊查询,支持日期范围)',
|
||||
example: '2024-01',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tradeDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '起始日期',
|
||||
example: '2024-01-01',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '结束日期',
|
||||
example: '2024-01-31',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: 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: 'tradeDate',
|
||||
default: 'tradeDate',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string = 'tradeDate';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '排序方向',
|
||||
example: 'DESC',
|
||||
enum: ['ASC', 'DESC'],
|
||||
default: 'DESC',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortOrder?: 'ASC' | 'DESC' = 'DESC';
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateStockDailyPriceDto } from './create-stock-daily-price.dto';
|
||||
|
||||
export class UpdateStockDailyPriceDto extends PartialType(
|
||||
CreateStockDailyPriceDto,
|
||||
) {}
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { StockDailyPriceService } from './stock-daily-price.service';
|
||||
import { CreateStockDailyPriceDto } from './dto/create-stock-daily-price.dto';
|
||||
import { UpdateStockDailyPriceDto } from './dto/update-stock-daily-price.dto';
|
||||
import { QueryStockDailyPriceDto } from './dto/query-stock-daily-price.dto';
|
||||
import { BatchCreateStockDailyPriceDto } from './dto/batch-create-stock-daily-price.dto';
|
||||
import { PaginatedStockDailyPriceData } from './dto/paginated-response.dto';
|
||||
import { StockDailyPrice } from './stock-daily-price.entity';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('stock-daily-price')
|
||||
@Controller('stock-daily-price')
|
||||
export class StockDailyPriceController {
|
||||
constructor(
|
||||
private readonly stockDailyPriceService: StockDailyPriceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 单独创建股票每日价格
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '创建股票每日价格',
|
||||
description: '创建单个股票的每日价格记录',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '创建成功',
|
||||
type: StockDailyPrice,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '价格记录已存在' })
|
||||
create(
|
||||
@Body() createStockDailyPriceDto: CreateStockDailyPriceDto,
|
||||
): Promise<StockDailyPrice> {
|
||||
return this.stockDailyPriceService.create(createStockDailyPriceDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建股票每日价格
|
||||
*/
|
||||
@Post('batch')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '批量创建股票每日价格',
|
||||
description: '一次性创建多个股票的每日价格记录',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '批量创建成功',
|
||||
type: [StockDailyPrice],
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '存在重复的价格记录' })
|
||||
batchCreate(
|
||||
@Body() batchCreateStockDailyPriceDto: BatchCreateStockDailyPriceDto,
|
||||
): Promise<StockDailyPrice[]> {
|
||||
return this.stockDailyPriceService.batchCreate(
|
||||
batchCreateStockDailyPriceDto,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询股票每日价格列表(支持分页和多种查询条件)
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '查询股票每日价格列表',
|
||||
description:
|
||||
'支持分页查询,查询条件:股票代码、股票名称(模糊)、市场、日期(模糊)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: PaginatedStockDailyPriceData,
|
||||
})
|
||||
findAll(@Query() queryDto: QueryStockDailyPriceDto): Promise<{
|
||||
list: StockDailyPrice[];
|
||||
pagination: any;
|
||||
}> {
|
||||
return this.stockDailyPriceService.findAll(queryDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个股票每日价格
|
||||
*/
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '根据ID查询股票每日价格',
|
||||
description: '根据价格记录ID查询单个股票每日价格',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '价格记录ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: StockDailyPrice,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到价格记录' })
|
||||
findOneById(@Param('id') id: string): Promise<StockDailyPrice> {
|
||||
return this.stockDailyPriceService.findOneById(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据股票代码、市场和日期查询单个股票每日价格
|
||||
*/
|
||||
@Get('code/:stockCode')
|
||||
@ApiOperation({
|
||||
summary: '根据股票代码查询股票每日价格',
|
||||
description: '根据股票代码、市场和日期查询单个股票每日价格',
|
||||
})
|
||||
@ApiParam({ name: 'stockCode', description: '股票代码', type: String })
|
||||
@ApiQuery({ name: 'market', description: '市场标识', required: true })
|
||||
@ApiQuery({ name: 'tradeDate', description: '交易日期', required: true })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: StockDailyPrice,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到价格记录' })
|
||||
findOneByCode(
|
||||
@Param('stockCode') stockCode: string,
|
||||
@Query('market') market: string,
|
||||
@Query('tradeDate') tradeDate: string,
|
||||
): Promise<StockDailyPrice> {
|
||||
return this.stockDailyPriceService.findOneByCode(
|
||||
stockCode,
|
||||
market,
|
||||
tradeDate,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据股票代码查询单只股票的所有价格记录
|
||||
*/
|
||||
@Get('stock/:stockCode')
|
||||
@ApiOperation({
|
||||
summary: '根据股票代码查询所有价格记录',
|
||||
description: '根据股票代码和市场查询单只股票的所有价格记录',
|
||||
})
|
||||
@ApiParam({ name: 'stockCode', description: '股票代码', type: String })
|
||||
@ApiQuery({ name: 'market', description: '市场标识', required: true })
|
||||
@ApiQuery({ name: 'limit', description: '返回记录数限制', required: false })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: [StockDailyPrice],
|
||||
})
|
||||
findByStockCode(
|
||||
@Param('stockCode') stockCode: string,
|
||||
@Query('market') market: string,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<StockDailyPrice[]> {
|
||||
return this.stockDailyPriceService.findByStockCode(
|
||||
stockCode,
|
||||
market,
|
||||
limit ? +limit : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新股票每日价格
|
||||
*/
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '更新股票每日价格',
|
||||
description: '根据ID更新股票每日价格记录',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '价格记录ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: StockDailyPrice,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到价格记录' })
|
||||
@ApiResponse({ status: 409, description: '价格记录冲突' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateStockDailyPriceDto: UpdateStockDailyPriceDto,
|
||||
): Promise<StockDailyPrice> {
|
||||
return this.stockDailyPriceService.update(
|
||||
+id,
|
||||
updateStockDailyPriceDto,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新股票每日价格
|
||||
*/
|
||||
@Patch('batch')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '批量更新股票每日价格',
|
||||
description: '批量更新股票每日价格记录',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: [StockDailyPrice],
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到价格记录' })
|
||||
batchUpdate(
|
||||
@Body() updateDtos: UpdateStockDailyPriceDto[],
|
||||
): Promise<StockDailyPrice[]> {
|
||||
return this.stockDailyPriceService.batchUpdate(updateDtos);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除股票每日价格
|
||||
*/
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: '删除股票每日价格',
|
||||
description: '根据ID删除股票每日价格记录',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '价格记录ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '未找到价格记录' })
|
||||
remove(@Param('id') id: string): Promise<void> {
|
||||
return this.stockDailyPriceService.remove(+id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@Entity('stock_daily_price')
|
||||
@Unique(['stockCode', 'market', 'tradeDate'])
|
||||
export class StockDailyPrice {
|
||||
@ApiProperty({ description: '主键ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ name: 'id' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '股票代码',
|
||||
example: '600519',
|
||||
maxLength: 20,
|
||||
})
|
||||
@Column({ name: 'stock_code', type: 'varchar', length: 20 })
|
||||
@Index()
|
||||
stockCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '股票名称',
|
||||
example: '贵州茅台',
|
||||
maxLength: 100,
|
||||
})
|
||||
@Column({ name: 'stock_name', type: 'varchar', length: 100 })
|
||||
@Index()
|
||||
stockName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '市场标识',
|
||||
example: 'sh',
|
||||
maxLength: 20,
|
||||
})
|
||||
@Column({ name: 'market', type: 'varchar', length: 20 })
|
||||
@Index()
|
||||
market: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '交易日期',
|
||||
example: '2024-01-01',
|
||||
})
|
||||
@Column({ name: 'trade_date', type: 'date' })
|
||||
@Index()
|
||||
tradeDate: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '开盘价',
|
||||
example: 1000.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'open_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
})
|
||||
openPrice?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '收盘价',
|
||||
example: 1050.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'close_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
})
|
||||
closePrice: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最高价',
|
||||
example: 1060.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'high_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
})
|
||||
highPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最低价',
|
||||
example: 995.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'low_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
})
|
||||
lowPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '成交量(单位:手)',
|
||||
example: 1000000,
|
||||
})
|
||||
@Column({ name: 'volume', type: 'bigint', nullable: true })
|
||||
volume?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '成交额(单位:元)',
|
||||
example: 1050000000.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'amount',
|
||||
type: 'decimal',
|
||||
precision: 20,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '涨跌额',
|
||||
example: 50.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'change_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
})
|
||||
changeAmount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '涨跌幅(%)',
|
||||
example: 5.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'change_percent',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 6,
|
||||
nullable: true,
|
||||
})
|
||||
changePercent?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '60日涨跌幅(%)',
|
||||
example: 15.5,
|
||||
})
|
||||
@Column({
|
||||
name: 'change_percent_60day',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 6,
|
||||
nullable: true,
|
||||
})
|
||||
changePercent60day?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '年初至今涨跌幅(%)',
|
||||
example: 25.8,
|
||||
})
|
||||
@Column({
|
||||
name: 'change_percent_ytd',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 6,
|
||||
nullable: true,
|
||||
})
|
||||
changePercentYtd?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '换手率(%)',
|
||||
example: 2.5,
|
||||
})
|
||||
@Column({
|
||||
name: 'turnover_rate',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 6,
|
||||
nullable: true,
|
||||
})
|
||||
turnoverRate?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市盈率',
|
||||
example: 35.5,
|
||||
})
|
||||
@Column({
|
||||
name: 'pe_ratio',
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
})
|
||||
peRatio?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市净率',
|
||||
example: 8.5,
|
||||
})
|
||||
@Column({
|
||||
name: 'pb_ratio',
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
})
|
||||
pbRatio?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '总市值(单位:元)',
|
||||
example: 2000000000000.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'market_cap',
|
||||
type: 'decimal',
|
||||
precision: 20,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
marketCap?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { StockDailyPriceService } from './stock-daily-price.service';
|
||||
import { StockDailyPriceController } from './stock-daily-price.controller';
|
||||
import { StockDailyPrice } from './stock-daily-price.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([StockDailyPrice])],
|
||||
controllers: [StockDailyPriceController],
|
||||
providers: [StockDailyPriceService],
|
||||
exports: [StockDailyPriceService],
|
||||
})
|
||||
export class StockDailyPriceModule {}
|
||||
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StockDailyPrice } from './stock-daily-price.entity';
|
||||
import { CreateStockDailyPriceDto } from './dto/create-stock-daily-price.dto';
|
||||
import { UpdateStockDailyPriceDto } from './dto/update-stock-daily-price.dto';
|
||||
import { QueryStockDailyPriceDto } from './dto/query-stock-daily-price.dto';
|
||||
import { BatchCreateStockDailyPriceDto } from './dto/batch-create-stock-daily-price.dto';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
|
||||
@Injectable()
|
||||
export class StockDailyPriceService {
|
||||
private readonly logger = new Logger(StockDailyPriceService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(StockDailyPrice)
|
||||
private readonly stockDailyPriceRepository: Repository<StockDailyPrice>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 单独创建股票每日价格
|
||||
*/
|
||||
async create(
|
||||
createStockDailyPriceDto: CreateStockDailyPriceDto,
|
||||
): Promise<StockDailyPrice> {
|
||||
// 检查同一股票同一日期是否已存在
|
||||
const existing = await this.stockDailyPriceRepository.findOne({
|
||||
where: {
|
||||
stockCode: createStockDailyPriceDto.stockCode,
|
||||
market: createStockDailyPriceDto.market,
|
||||
tradeDate: new Date(createStockDailyPriceDto.tradeDate),
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`股票 ${createStockDailyPriceDto.stockCode} (${createStockDailyPriceDto.market}) 在日期 ${createStockDailyPriceDto.tradeDate} 的价格记录已存在`,
|
||||
);
|
||||
}
|
||||
|
||||
const stockDailyPrice = this.stockDailyPriceRepository.create({
|
||||
...createStockDailyPriceDto,
|
||||
tradeDate: new Date(createStockDailyPriceDto.tradeDate),
|
||||
});
|
||||
|
||||
return this.stockDailyPriceRepository.save(stockDailyPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建股票每日价格
|
||||
*/
|
||||
async batchCreate(
|
||||
batchCreateStockDailyPriceDto: BatchCreateStockDailyPriceDto,
|
||||
): Promise<StockDailyPrice[]> {
|
||||
const prices = batchCreateStockDailyPriceDto.prices.map((dto) =>
|
||||
this.stockDailyPriceRepository.create({
|
||||
...dto,
|
||||
tradeDate: new Date(dto.tradeDate),
|
||||
}),
|
||||
);
|
||||
|
||||
// 检查是否有重复的 stock_code + market + trade_date 组合
|
||||
const uniqueKeys = prices.map((p) => ({
|
||||
stockCode: p.stockCode,
|
||||
market: p.market,
|
||||
tradeDate: p.tradeDate,
|
||||
}));
|
||||
|
||||
// 检查批量数据内部是否有重复
|
||||
const uniquePairs = new Set(
|
||||
uniqueKeys.map(
|
||||
(k) =>
|
||||
`${k.stockCode}-${k.market}-${k.tradeDate.toISOString()}`,
|
||||
),
|
||||
);
|
||||
if (uniquePairs.size !== uniqueKeys.length) {
|
||||
throw new ConflictException(
|
||||
'批量数据中存在重复的股票代码、市场和日期组合',
|
||||
);
|
||||
}
|
||||
|
||||
// 检查数据库中是否已存在
|
||||
const existingPrices = await this.stockDailyPriceRepository.find({
|
||||
where: uniqueKeys.map((k) => ({
|
||||
stockCode: k.stockCode,
|
||||
market: k.market,
|
||||
tradeDate: k.tradeDate,
|
||||
})),
|
||||
});
|
||||
|
||||
if (existingPrices.length > 0) {
|
||||
const conflicts = existingPrices.map(
|
||||
(p) =>
|
||||
`${p.stockCode} (${p.market}) - ${p.tradeDate.toISOString().split('T')[0]}`,
|
||||
);
|
||||
throw new ConflictException(
|
||||
`以下价格记录已存在:${conflicts.join('、')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.stockDailyPriceRepository.save(prices);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新股票每日价格
|
||||
*/
|
||||
async batchUpdate(
|
||||
updateDtos: UpdateStockDailyPriceDto[],
|
||||
): Promise<StockDailyPrice[]> {
|
||||
const updatedPrices: StockDailyPrice[] = [];
|
||||
|
||||
for (const updateDto of updateDtos) {
|
||||
// 必须包含 stockCode、market 和 tradeDate 来定位记录
|
||||
if (
|
||||
!updateDto.stockCode ||
|
||||
!updateDto.market ||
|
||||
!updateDto.tradeDate
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'批量更新时,每条记录必须包含 stockCode、market 和 tradeDate',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.stockDailyPriceRepository.findOne({
|
||||
where: {
|
||||
stockCode: updateDto.stockCode,
|
||||
market: updateDto.market,
|
||||
tradeDate: new Date(updateDto.tradeDate),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(
|
||||
`未找到价格记录:${updateDto.stockCode} (${updateDto.market}) - ${updateDto.tradeDate}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
Object.assign(existing, {
|
||||
...updateDto,
|
||||
tradeDate: updateDto.tradeDate
|
||||
? new Date(updateDto.tradeDate)
|
||||
: existing.tradeDate,
|
||||
});
|
||||
|
||||
const saved = await this.stockDailyPriceRepository.save(existing);
|
||||
updatedPrices.push(saved);
|
||||
}
|
||||
|
||||
return updatedPrices;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询股票每日价格(支持多种查询条件和分页)
|
||||
*/
|
||||
async findAll(queryDto: QueryStockDailyPriceDto): Promise<{
|
||||
list: StockDailyPrice[];
|
||||
pagination: PaginationInfo;
|
||||
}> {
|
||||
// 分页参数
|
||||
const page = queryDto.page || 1;
|
||||
const limit = queryDto.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// 排序字段映射
|
||||
const sortBy = queryDto.sortBy || 'tradeDate';
|
||||
const sortOrder = queryDto.sortOrder || 'DESC';
|
||||
|
||||
// 构建查询
|
||||
let query = this.stockDailyPriceRepository.createQueryBuilder('price');
|
||||
|
||||
if (queryDto.stockCode) {
|
||||
query = query.andWhere('price.stock_code = :stockCode', {
|
||||
stockCode: queryDto.stockCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (queryDto.stockName) {
|
||||
query = query.andWhere('price.stock_name LIKE :stockName', {
|
||||
stockName: `%${queryDto.stockName}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (queryDto.market) {
|
||||
query = query.andWhere('price.market = :market', {
|
||||
market: queryDto.market,
|
||||
});
|
||||
}
|
||||
|
||||
// 日期查询:支持精确日期、日期范围或模糊查询
|
||||
if (queryDto.startDate && queryDto.endDate) {
|
||||
query = query.andWhere(
|
||||
'price.trade_date BETWEEN :startDate AND :endDate',
|
||||
{
|
||||
startDate: queryDto.startDate,
|
||||
endDate: queryDto.endDate,
|
||||
},
|
||||
);
|
||||
} else if (queryDto.startDate) {
|
||||
query = query.andWhere('price.trade_date >= :startDate', {
|
||||
startDate: queryDto.startDate,
|
||||
});
|
||||
} else if (queryDto.endDate) {
|
||||
query = query.andWhere('price.trade_date <= :endDate', {
|
||||
endDate: queryDto.endDate,
|
||||
});
|
||||
} else if (queryDto.tradeDate) {
|
||||
// 模糊查询日期(如 '2024-01' 匹配 2024-01-XX)
|
||||
query = query.andWhere('price.trade_date::text LIKE :tradeDate', {
|
||||
tradeDate: `${queryDto.tradeDate}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const total = await query.getCount();
|
||||
|
||||
// 添加排序和分页
|
||||
query = query
|
||||
.orderBy(`price.${sortBy}`, sortOrder)
|
||||
.addOrderBy('price.id', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const list = await query.getMany();
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个股票每日价格
|
||||
*/
|
||||
async findOneById(id: number): Promise<StockDailyPrice> {
|
||||
const stockDailyPrice = await this.stockDailyPriceRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!stockDailyPrice) {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的价格记录`);
|
||||
}
|
||||
|
||||
return stockDailyPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据股票代码、市场和日期查询单个股票每日价格
|
||||
*/
|
||||
async findOneByCode(
|
||||
stockCode: string,
|
||||
market: string,
|
||||
tradeDate: string,
|
||||
): Promise<StockDailyPrice> {
|
||||
const stockDailyPrice = await this.stockDailyPriceRepository.findOne({
|
||||
where: {
|
||||
stockCode,
|
||||
market,
|
||||
tradeDate: new Date(tradeDate),
|
||||
},
|
||||
});
|
||||
|
||||
if (!stockDailyPrice) {
|
||||
throw new NotFoundException(
|
||||
`未找到价格记录:${stockCode} (${market}) - ${tradeDate}`,
|
||||
);
|
||||
}
|
||||
|
||||
return stockDailyPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据股票代码查询单只股票的所有价格记录
|
||||
*/
|
||||
async findByStockCode(
|
||||
stockCode: string,
|
||||
market: string,
|
||||
limit?: number,
|
||||
): Promise<StockDailyPrice[]> {
|
||||
const query = this.stockDailyPriceRepository
|
||||
.createQueryBuilder('price')
|
||||
.where('price.stock_code = :stockCode', { stockCode })
|
||||
.andWhere('price.market = :market', { market })
|
||||
.orderBy('price.trade_date', 'DESC')
|
||||
.addOrderBy('price.id', 'ASC');
|
||||
|
||||
if (limit) {
|
||||
query.take(limit);
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新股票每日价格
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
updateStockDailyPriceDto: UpdateStockDailyPriceDto,
|
||||
): Promise<StockDailyPrice> {
|
||||
const stockDailyPrice = await this.findOneById(id);
|
||||
|
||||
// 如果更新 stock_code、market 或 trade_date,检查是否冲突
|
||||
if (
|
||||
'stockCode' in updateStockDailyPriceDto ||
|
||||
'market' in updateStockDailyPriceDto ||
|
||||
'tradeDate' in updateStockDailyPriceDto
|
||||
) {
|
||||
const newCode =
|
||||
updateStockDailyPriceDto.stockCode ?? stockDailyPrice.stockCode;
|
||||
const newMarket =
|
||||
updateStockDailyPriceDto.market ?? stockDailyPrice.market;
|
||||
const newDate = updateStockDailyPriceDto.tradeDate
|
||||
? new Date(updateStockDailyPriceDto.tradeDate)
|
||||
: stockDailyPrice.tradeDate;
|
||||
|
||||
const existing = await this.stockDailyPriceRepository.findOne({
|
||||
where: {
|
||||
stockCode: newCode,
|
||||
market: newMarket,
|
||||
tradeDate: newDate,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing && existing.id !== id) {
|
||||
throw new ConflictException(
|
||||
`股票 ${newCode} (${newMarket}) 在日期 ${newDate.toISOString().split('T')[0]} 的价格记录已存在`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(stockDailyPrice, {
|
||||
...updateStockDailyPriceDto,
|
||||
tradeDate: updateStockDailyPriceDto.tradeDate
|
||||
? new Date(updateStockDailyPriceDto.tradeDate)
|
||||
: stockDailyPrice.tradeDate,
|
||||
});
|
||||
return this.stockDailyPriceRepository.save(stockDailyPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除股票每日价格
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const stockDailyPrice = await this.findOneById(id);
|
||||
await this.stockDailyPriceRepository.remove(stockDailyPrice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { CreateStockInfoDto } from './create-stock-info.dto';
|
||||
|
||||
export class BatchCreateStockInfoDto {
|
||||
@ApiProperty({
|
||||
description: '股票信息列表',
|
||||
type: [CreateStockInfoDto],
|
||||
example: [
|
||||
{
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
market: 'sh',
|
||||
fullName: '贵州茅台酒股份有限公司',
|
||||
industry: '白酒',
|
||||
},
|
||||
{
|
||||
stockCode: '000001',
|
||||
stockName: '平安银行',
|
||||
market: 'sz',
|
||||
fullName: '平安银行股份有限公司',
|
||||
industry: '银行',
|
||||
},
|
||||
],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateStockInfoDto)
|
||||
stocks: CreateStockInfoDto[];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateStockInfoDto {
|
||||
@ApiProperty({
|
||||
description: '股票代码',
|
||||
example: '600519',
|
||||
maxLength: 20,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
stockCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '股票名称',
|
||||
example: '贵州茅台',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
stockName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '市场标识',
|
||||
example: 'sh',
|
||||
maxLength: 20,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
market: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '公司全称',
|
||||
example: '贵州茅台酒股份有限公司',
|
||||
maxLength: 200,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
fullName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '所属行业',
|
||||
example: '白酒',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
industry?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '上市日期',
|
||||
example: '2001-08-27',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
listingDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '状态',
|
||||
example: 'active',
|
||||
enum: ['active', 'suspended', 'delisted'],
|
||||
default: 'active',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['active', 'suspended', 'delisted'])
|
||||
status?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StockInfo } from '../stock-info.entity';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
|
||||
/**
|
||||
* 股票信息分页响应数据
|
||||
*/
|
||||
export class PaginatedStockInfoData {
|
||||
@ApiProperty({ description: '股票信息列表', type: [StockInfo] })
|
||||
list: StockInfo[];
|
||||
|
||||
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { IsOptional, IsString, IsNumber, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class QueryStockInfoDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '股票代码',
|
||||
example: '600519',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stockCode?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '股票名称(模糊查询)',
|
||||
example: '茅台',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stockName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '市场标识',
|
||||
example: 'sh',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
market?: 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()
|
||||
sortOrder?: 'ASC' | 'DESC' = 'DESC';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateStockInfoDto } from './create-stock-info.dto';
|
||||
|
||||
export class UpdateStockInfoDto extends PartialType(CreateStockInfoDto) {}
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { StockInfoService } from './stock-info.service';
|
||||
import { CreateStockInfoDto } from './dto/create-stock-info.dto';
|
||||
import { UpdateStockInfoDto } from './dto/update-stock-info.dto';
|
||||
import { QueryStockInfoDto } from './dto/query-stock-info.dto';
|
||||
import { BatchCreateStockInfoDto } from './dto/batch-create-stock-info.dto';
|
||||
import { PaginatedStockInfoData } from './dto/paginated-response.dto';
|
||||
import { StockInfo } from './stock-info.entity';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('stock-info')
|
||||
@Controller('stock-info')
|
||||
export class StockInfoController {
|
||||
constructor(private readonly stockInfoService: StockInfoService) {}
|
||||
|
||||
/**
|
||||
* 单独创建股票信息
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '创建股票信息',
|
||||
description: '创建单个股票基本信息',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '创建成功',
|
||||
type: StockInfo,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '股票代码已存在' })
|
||||
create(@Body() createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
|
||||
return this.stockInfoService.create(createStockInfoDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建股票信息
|
||||
*/
|
||||
@Post('batch')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '批量创建股票信息',
|
||||
description: '一次性创建多个股票基本信息',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '批量创建成功',
|
||||
type: [StockInfo],
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '存在重复的股票代码' })
|
||||
batchCreate(
|
||||
@Body() batchCreateStockInfoDto: BatchCreateStockInfoDto,
|
||||
): Promise<StockInfo[]> {
|
||||
return this.stockInfoService.batchCreate(batchCreateStockInfoDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert:存在则更新,不存在则新增
|
||||
*/
|
||||
@Post('upsert')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '创建或更新股票信息',
|
||||
description: '如果股票代码已存在则更新,不存在则新增',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '操作成功',
|
||||
type: StockInfo,
|
||||
})
|
||||
upsert(@Body() createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
|
||||
return this.stockInfoService.upsert(createStockInfoDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量 Upsert
|
||||
*/
|
||||
@Post('batch-upsert')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '批量创建或更新股票信息',
|
||||
description: '批量操作:存在则更新,不存在则新增',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '操作成功',
|
||||
type: [StockInfo],
|
||||
})
|
||||
batchUpsert(
|
||||
@Body() batchCreateStockInfoDto: BatchCreateStockInfoDto,
|
||||
): Promise<StockInfo[]> {
|
||||
return this.stockInfoService.batchUpsert(batchCreateStockInfoDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询股票信息列表(支持分页和多种查询条件)
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '查询股票信息列表',
|
||||
description:
|
||||
'支持分页查询,查询条件:股票代码、股票名称(模糊)、市场类型',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: PaginatedStockInfoData,
|
||||
})
|
||||
findAll(@Query() queryDto: QueryStockInfoDto): Promise<{
|
||||
list: StockInfo[];
|
||||
pagination: any;
|
||||
}> {
|
||||
return this.stockInfoService.findAll(queryDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个股票信息
|
||||
*/
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '根据ID查询股票信息',
|
||||
description: '根据股票信息ID查询单个股票信息',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '股票信息ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: StockInfo,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到股票信息' })
|
||||
findOneById(@Param('id') id: string): Promise<StockInfo> {
|
||||
return this.stockInfoService.findOneById(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据股票代码和市场查询单个股票信息
|
||||
*/
|
||||
@Get('code/:stockCode')
|
||||
@ApiOperation({
|
||||
summary: '根据股票代码查询股票信息',
|
||||
description: '根据股票代码和市场查询单个股票信息',
|
||||
})
|
||||
@ApiParam({ name: 'stockCode', description: '股票代码', type: String })
|
||||
@ApiQuery({ name: 'market', description: '市场标识', required: true })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: StockInfo,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到股票信息' })
|
||||
findOneByCode(
|
||||
@Param('stockCode') stockCode: string,
|
||||
@Query('market') market: string,
|
||||
): Promise<StockInfo> {
|
||||
return this.stockInfoService.findOneByCode(stockCode, market);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新股票信息
|
||||
*/
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '更新股票信息',
|
||||
description: '根据ID更新股票基本信息',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '股票信息ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: StockInfo,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到股票信息' })
|
||||
@ApiResponse({ status: 409, description: '股票代码冲突' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateStockInfoDto: UpdateStockInfoDto,
|
||||
): Promise<StockInfo> {
|
||||
return this.stockInfoService.update(+id, updateStockInfoDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新股票信息
|
||||
*/
|
||||
@Patch('batch')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '批量更新股票信息',
|
||||
description: '批量更新股票基本信息',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: [StockInfo],
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '未找到股票信息' })
|
||||
batchUpdate(
|
||||
@Body() updateDtos: UpdateStockInfoDto[],
|
||||
): Promise<StockInfo[]> {
|
||||
return this.stockInfoService.batchUpdate(updateDtos);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除股票信息
|
||||
*/
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: '删除股票信息',
|
||||
description: '根据ID删除股票基本信息',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '股票信息ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '未找到股票信息' })
|
||||
remove(@Param('id') id: string): Promise<void> {
|
||||
return this.stockInfoService.remove(+id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@Entity('stock_info')
|
||||
@Unique(['stockCode', 'market'])
|
||||
export class StockInfo {
|
||||
@ApiProperty({ description: '主键ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ name: 'id' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '股票代码',
|
||||
example: '600519',
|
||||
maxLength: 20,
|
||||
})
|
||||
@Column({ name: 'stock_code', type: 'varchar', length: 20 })
|
||||
@Index()
|
||||
stockCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '股票名称',
|
||||
example: '贵州茅台',
|
||||
maxLength: 100,
|
||||
})
|
||||
@Column({ name: 'stock_name', type: 'varchar', length: 100 })
|
||||
@Index()
|
||||
stockName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '市场标识',
|
||||
example: 'sh',
|
||||
maxLength: 20,
|
||||
})
|
||||
@Column({ name: 'market', type: 'varchar', length: 20 })
|
||||
@Index()
|
||||
market: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '公司全称',
|
||||
example: '贵州茅台酒股份有限公司',
|
||||
maxLength: 200,
|
||||
})
|
||||
@Column({ name: 'full_name', type: 'varchar', length: 200, nullable: true })
|
||||
fullName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '所属行业',
|
||||
example: '白酒',
|
||||
maxLength: 100,
|
||||
})
|
||||
@Column({ name: 'industry', type: 'varchar', length: 100, nullable: true })
|
||||
industry?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '上市日期',
|
||||
example: '2001-08-27',
|
||||
})
|
||||
@Column({ name: 'listing_date', type: 'date', nullable: true })
|
||||
listingDate?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '状态',
|
||||
example: 'active',
|
||||
enum: ['active', 'suspended', 'delisted'],
|
||||
default: 'active',
|
||||
})
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: 'active',
|
||||
})
|
||||
@Index()
|
||||
status: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { StockInfoService } from './stock-info.service';
|
||||
import { StockInfoController } from './stock-info.controller';
|
||||
import { StockInfo } from './stock-info.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([StockInfo])],
|
||||
controllers: [StockInfoController],
|
||||
providers: [StockInfoService],
|
||||
exports: [StockInfoService],
|
||||
})
|
||||
export class StockInfoModule {}
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { StockInfo } from './stock-info.entity';
|
||||
import { CreateStockInfoDto } from './dto/create-stock-info.dto';
|
||||
import { UpdateStockInfoDto } from './dto/update-stock-info.dto';
|
||||
import { QueryStockInfoDto } from './dto/query-stock-info.dto';
|
||||
import { BatchCreateStockInfoDto } from './dto/batch-create-stock-info.dto';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
|
||||
@Injectable()
|
||||
export class StockInfoService {
|
||||
private readonly logger = new Logger(StockInfoService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(StockInfo)
|
||||
private readonly stockInfoRepository: Repository<StockInfo>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 单独创建股票信息
|
||||
*/
|
||||
async create(createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
|
||||
// 检查同一市场的 stock_code 是否已存在
|
||||
const existing = await this.stockInfoRepository.findOne({
|
||||
where: {
|
||||
stockCode: createStockInfoDto.stockCode,
|
||||
market: createStockInfoDto.market,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`市场 "${createStockInfoDto.market}" 中已存在代码为 "${createStockInfoDto.stockCode}" 的股票`,
|
||||
);
|
||||
}
|
||||
|
||||
const stockInfo = this.stockInfoRepository.create({
|
||||
...createStockInfoDto,
|
||||
status: createStockInfoDto.status ?? 'active',
|
||||
listingDate: createStockInfoDto.listingDate
|
||||
? new Date(createStockInfoDto.listingDate)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return this.stockInfoRepository.save(stockInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建股票信息
|
||||
*/
|
||||
async batchCreate(
|
||||
batchCreateStockInfoDto: BatchCreateStockInfoDto,
|
||||
): Promise<StockInfo[]> {
|
||||
const stocks = batchCreateStockInfoDto.stocks.map((dto) =>
|
||||
this.stockInfoRepository.create({
|
||||
...dto,
|
||||
status: dto.status ?? 'active',
|
||||
listingDate: dto.listingDate
|
||||
? new Date(dto.listingDate)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// 检查是否有重复的 stock_code + market 组合
|
||||
const codeMarketPairs = stocks.map((s) => ({
|
||||
stockCode: s.stockCode,
|
||||
market: s.market,
|
||||
}));
|
||||
|
||||
const existingStocks = await this.stockInfoRepository.find({
|
||||
where: codeMarketPairs.map((pair) => ({
|
||||
stockCode: pair.stockCode,
|
||||
market: pair.market,
|
||||
})),
|
||||
});
|
||||
|
||||
if (existingStocks.length > 0) {
|
||||
const conflicts = existingStocks.map(
|
||||
(s) => `${s.stockCode} (${s.market})`,
|
||||
);
|
||||
throw new ConflictException(
|
||||
`以下股票已存在:${conflicts.join('、')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 检查批量数据内部是否有重复
|
||||
const uniquePairs = new Set(
|
||||
codeMarketPairs.map((p) => `${p.stockCode}-${p.market}`),
|
||||
);
|
||||
if (uniquePairs.size !== codeMarketPairs.length) {
|
||||
throw new ConflictException(
|
||||
'批量数据中存在重复的股票代码和市场组合',
|
||||
);
|
||||
}
|
||||
|
||||
return this.stockInfoRepository.save(stocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新股票信息
|
||||
*/
|
||||
async batchUpdate(updateDtos: UpdateStockInfoDto[]): Promise<StockInfo[]> {
|
||||
const updatedStocks: StockInfo[] = [];
|
||||
|
||||
for (const updateDto of updateDtos) {
|
||||
// 必须包含 stockCode 和 market 来定位记录
|
||||
if (!updateDto.stockCode || !updateDto.market) {
|
||||
throw new ConflictException(
|
||||
'批量更新时,每条记录必须包含 stockCode 和 market',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.stockInfoRepository.findOne({
|
||||
where: {
|
||||
stockCode: updateDto.stockCode,
|
||||
market: updateDto.market,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(
|
||||
`未找到股票:${updateDto.stockCode} (${updateDto.market})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if (updateDto.stockName !== undefined) {
|
||||
existing.stockName = updateDto.stockName;
|
||||
}
|
||||
if (updateDto.fullName !== undefined) {
|
||||
existing.fullName = updateDto.fullName;
|
||||
}
|
||||
if (updateDto.industry !== undefined) {
|
||||
existing.industry = updateDto.industry;
|
||||
}
|
||||
if (updateDto.listingDate !== undefined) {
|
||||
existing.listingDate = updateDto.listingDate
|
||||
? new Date(updateDto.listingDate)
|
||||
: undefined;
|
||||
}
|
||||
if (updateDto.status !== undefined) {
|
||||
existing.status = updateDto.status;
|
||||
}
|
||||
|
||||
const saved = await this.stockInfoRepository.save(existing);
|
||||
updatedStocks.push(saved);
|
||||
}
|
||||
|
||||
return updatedStocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert:存在则更新,不存在则新增
|
||||
*/
|
||||
async upsert(createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
|
||||
const existing = await this.stockInfoRepository.findOne({
|
||||
where: {
|
||||
stockCode: createStockInfoDto.stockCode,
|
||||
market: createStockInfoDto.market,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有记录
|
||||
Object.assign(existing, {
|
||||
...createStockInfoDto,
|
||||
listingDate: createStockInfoDto.listingDate
|
||||
? new Date(createStockInfoDto.listingDate)
|
||||
: existing.listingDate,
|
||||
status: createStockInfoDto.status ?? existing.status,
|
||||
});
|
||||
return this.stockInfoRepository.save(existing);
|
||||
} else {
|
||||
// 创建新记录
|
||||
return this.create(createStockInfoDto);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量 Upsert
|
||||
*/
|
||||
async batchUpsert(
|
||||
batchCreateStockInfoDto: BatchCreateStockInfoDto,
|
||||
): Promise<StockInfo[]> {
|
||||
const results: StockInfo[] = [];
|
||||
|
||||
for (const dto of batchCreateStockInfoDto.stocks) {
|
||||
const result = await this.upsert(dto);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询股票信息(支持多种查询条件和分页)
|
||||
*/
|
||||
async findAll(queryDto: QueryStockInfoDto): Promise<{
|
||||
list: StockInfo[];
|
||||
pagination: PaginationInfo;
|
||||
}> {
|
||||
const where: FindOptionsWhere<StockInfo> = {};
|
||||
|
||||
if (queryDto.stockCode) {
|
||||
where.stockCode = queryDto.stockCode;
|
||||
}
|
||||
|
||||
if (queryDto.market) {
|
||||
where.market = queryDto.market;
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
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 === 'stockCode') {
|
||||
order.stockCode = sortOrder;
|
||||
} else if (sortBy === 'stockName') {
|
||||
order.stockName = sortOrder;
|
||||
} else {
|
||||
order.createdAt = 'DESC';
|
||||
}
|
||||
// 添加默认排序
|
||||
order.id = 'ASC';
|
||||
|
||||
// 查询数据(如果 stockName 存在,使用 Like 进行模糊查询)
|
||||
let query = this.stockInfoRepository.createQueryBuilder('stock_info');
|
||||
|
||||
if (queryDto.stockCode) {
|
||||
query = query.andWhere('stock_info.stock_code = :stockCode', {
|
||||
stockCode: queryDto.stockCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (queryDto.stockName) {
|
||||
query = query.andWhere('stock_info.stock_name LIKE :stockName', {
|
||||
stockName: `%${queryDto.stockName}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (queryDto.market) {
|
||||
query = query.andWhere('stock_info.market = :market', {
|
||||
market: queryDto.market,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const total = await query.getCount();
|
||||
|
||||
// 添加排序和分页
|
||||
query = query
|
||||
.orderBy(`stock_info.${sortBy}`, sortOrder)
|
||||
.addOrderBy('stock_info.id', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const list = await query.getMany();
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个股票信息
|
||||
*/
|
||||
async findOneById(id: number): Promise<StockInfo> {
|
||||
const stockInfo = await this.stockInfoRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!stockInfo) {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的股票信息`);
|
||||
}
|
||||
|
||||
return stockInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据股票代码和市场查询单个股票信息
|
||||
*/
|
||||
async findOneByCode(stockCode: string, market: string): Promise<StockInfo> {
|
||||
const stockInfo = await this.stockInfoRepository.findOne({
|
||||
where: { stockCode, market },
|
||||
});
|
||||
|
||||
if (!stockInfo) {
|
||||
throw new NotFoundException(`未找到股票:${stockCode} (${market})`);
|
||||
}
|
||||
|
||||
return stockInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新股票信息
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
updateStockInfoDto: UpdateStockInfoDto,
|
||||
): Promise<StockInfo> {
|
||||
const stockInfo = await this.findOneById(id);
|
||||
|
||||
// 如果更新 stock_code 或 market,检查是否冲突
|
||||
if (
|
||||
'stockCode' in updateStockInfoDto ||
|
||||
'market' in updateStockInfoDto
|
||||
) {
|
||||
const newCode = updateStockInfoDto.stockCode ?? stockInfo.stockCode;
|
||||
const newMarket = updateStockInfoDto.market ?? stockInfo.market;
|
||||
|
||||
const existing = await this.stockInfoRepository.findOne({
|
||||
where: {
|
||||
stockCode: newCode,
|
||||
market: newMarket,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing && existing.id !== id) {
|
||||
throw new ConflictException(
|
||||
`市场 "${newMarket}" 中已存在代码为 "${newCode}" 的股票`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(stockInfo, {
|
||||
...updateStockInfoDto,
|
||||
listingDate: updateStockInfoDto.listingDate
|
||||
? new Date(updateStockInfoDto.listingDate)
|
||||
: stockInfo.listingDate,
|
||||
});
|
||||
return this.stockInfoRepository.save(stockInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除股票信息
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const stockInfo = await this.findOneById(id);
|
||||
await this.stockInfoRepository.remove(stockInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsIn } from 'class-validator';
|
||||
|
||||
export class UploadFileDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '存储文件夹',
|
||||
example: 'broker',
|
||||
enum: ['broker', 'user', 'temp'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['broker', 'user', 'temp'])
|
||||
folder?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '自定义文件名',
|
||||
example: 'custom-filename.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
filename?: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 文件上传对象类型
|
||||
*/
|
||||
export interface FileUpload {
|
||||
fieldname: string;
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储提供者接口
|
||||
* 所有存储实现都必须实现此接口,方便切换不同的存储方案
|
||||
*/
|
||||
export interface UploadResult {
|
||||
path: string; // 存储路径(相对路径或完整路径)
|
||||
url: string; // 访问URL
|
||||
filename: string; // 文件名
|
||||
size: number; // 文件大小(字节)
|
||||
mimetype: string; // MIME类型
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
folder?: string; // 存储文件夹,如 'broker', 'user' 等
|
||||
filename?: string; // 自定义文件名
|
||||
maxSize?: number; // 最大文件大小(字节)
|
||||
allowedMimeTypes?: string[]; // 允许的MIME类型
|
||||
}
|
||||
|
||||
export interface IStorageProvider {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param file 文件对象
|
||||
* @param options 上传选项
|
||||
* @returns 上传结果
|
||||
*/
|
||||
upload(file: FileUpload, options?: UploadOptions): Promise<UploadResult>;
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
delete(path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取文件的访问URL
|
||||
* @param path 文件路径
|
||||
* @returns 访问URL
|
||||
*/
|
||||
getUrl(path: string): string;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param path 文件路径
|
||||
* @returns 是否存在
|
||||
*/
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import type {
|
||||
IStorageProvider,
|
||||
UploadResult,
|
||||
UploadOptions,
|
||||
FileUpload,
|
||||
} from '../interfaces/storage-provider.interface';
|
||||
|
||||
/**
|
||||
* 本地存储提供者
|
||||
* 将文件存储在服务器本地目录
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalStorageProvider implements IStorageProvider {
|
||||
private readonly logger = new Logger(LocalStorageProvider.name);
|
||||
private readonly basePath: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// 从环境变量读取配置
|
||||
this.basePath =
|
||||
this.configService.get<string>('STORAGE_PATH') || './uploads';
|
||||
this.baseUrl =
|
||||
this.configService.get<string>('STORAGE_BASE_URL') ||
|
||||
'http://localhost:3200/uploads';
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到本地存储
|
||||
*/
|
||||
async upload(
|
||||
file: FileUpload,
|
||||
options?: UploadOptions,
|
||||
): Promise<UploadResult> {
|
||||
try {
|
||||
// 验证文件大小
|
||||
if (options?.maxSize && file.size > options.maxSize) {
|
||||
throw new Error(
|
||||
`文件大小超过限制 ${options.maxSize / 1024 / 1024}MB`,
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (
|
||||
options?.allowedMimeTypes &&
|
||||
!options.allowedMimeTypes.includes(file.mimetype)
|
||||
) {
|
||||
throw new Error(
|
||||
`不支持的文件类型,允许的类型:${options.allowedMimeTypes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 确定存储文件夹
|
||||
const folder = options?.folder || 'temp';
|
||||
const uploadDir = path.join(this.basePath, folder);
|
||||
|
||||
// 确保目录存在
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
|
||||
// 生成文件名
|
||||
const filename = this.generateFilename(file, options?.filename);
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
const relativePath = path
|
||||
.join(folder, filename)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
// 保存文件
|
||||
await fs.writeFile(filePath, file.buffer);
|
||||
|
||||
// 生成访问URL
|
||||
const url = `${this.baseUrl}/${relativePath}`;
|
||||
|
||||
this.logger.log(`文件上传成功: ${relativePath}`);
|
||||
|
||||
return {
|
||||
path: relativePath,
|
||||
url,
|
||||
filename,
|
||||
size: file.size,
|
||||
mimetype: file.mimetype,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : '未知错误';
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`文件上传失败: ${errorMessage}`, errorStack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(filePath: string): Promise<void> {
|
||||
try {
|
||||
// filePath 是相对路径(如 broker/filename.jpg)
|
||||
const fullPath = path.join(this.basePath, filePath);
|
||||
|
||||
// 安全检查:确保文件路径在 basePath 内,防止路径遍历攻击
|
||||
const resolvedPath = path.resolve(fullPath);
|
||||
const resolvedBasePath = path.resolve(this.basePath);
|
||||
if (!resolvedPath.startsWith(resolvedBasePath)) {
|
||||
throw new Error('非法文件路径');
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(resolvedPath);
|
||||
} catch {
|
||||
this.logger.warn(`文件不存在: ${resolvedPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.unlink(resolvedPath);
|
||||
this.logger.log(`文件删除成功: ${resolvedPath}`);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : '未知错误';
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`文件删除失败: ${errorMessage}`, errorStack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的访问URL
|
||||
*/
|
||||
getUrl(filePath: string): string {
|
||||
const relativePath = filePath.startsWith(this.basePath)
|
||||
? filePath.replace(this.basePath, '').replace(/^[/\\]/, '')
|
||||
: filePath;
|
||||
return `${this.baseUrl}/${relativePath}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fullPath = filePath.startsWith(this.basePath)
|
||||
? filePath
|
||||
: path.join(this.basePath, filePath);
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名
|
||||
* 格式: {timestamp}-{random}-{originalname}
|
||||
*/
|
||||
private generateFilename(file: FileUpload, customName?: string): string {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const random = crypto.randomBytes(8).toString('hex');
|
||||
const ext = path.extname(file.originalname);
|
||||
const nameWithoutExt = path.basename(file.originalname, ext);
|
||||
|
||||
// 清理文件名,移除特殊字符
|
||||
const cleanName = nameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
return `${timestamp}-${random}-${cleanName}${ext}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiConsumes,
|
||||
ApiBody,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { StorageService } from './storage.service';
|
||||
import { UploadFileDto } from './dto/upload-file.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { FileUpload } from './interfaces/storage-provider.interface';
|
||||
|
||||
@ApiTags('storage')
|
||||
@Controller('storage')
|
||||
export class StorageController {
|
||||
constructor(private readonly storageService: StorageService) {}
|
||||
|
||||
/**
|
||||
* 管理员上传文件(需要鉴权)
|
||||
* 用于上传券商Logo等基础数据
|
||||
*/
|
||||
@Post('upload')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: '管理员上传文件',
|
||||
description: '上传单个文件,需要管理员权限,用于上传券商Logo等基础数据',
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: '要上传的文件',
|
||||
},
|
||||
folder: {
|
||||
type: 'string',
|
||||
enum: ['broker', 'user', 'temp'],
|
||||
description: '存储文件夹',
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: '自定义文件名',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '上传成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
example: 'broker/1234567890-abcdef-broker-logo.jpg',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
example:
|
||||
'http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg',
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
example: '1234567890-abcdef-broker-logo.jpg',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
example: 102400,
|
||||
},
|
||||
mimetype: {
|
||||
type: 'string',
|
||||
example: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '文件格式或大小不符合要求' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
async uploadAdmin(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB
|
||||
new FileTypeValidator({
|
||||
fileType: /^image\/(jpeg|jpg|png|gif|webp)$/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: FileUpload,
|
||||
@Body() uploadDto: UploadFileDto,
|
||||
) {
|
||||
const options = {
|
||||
folder: uploadDto.folder || 'temp',
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
allowedMimeTypes: [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
],
|
||||
filename: uploadDto.filename,
|
||||
};
|
||||
|
||||
return await this.storageService.upload(file, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户上传头像(不需要鉴权)
|
||||
* 用于用户注册或更新头像
|
||||
*/
|
||||
@Post('upload/avatar')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: '上传用户头像',
|
||||
description: '上传用户头像,不需要鉴权,用于用户注册或更新头像',
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: '要上传的头像文件',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '上传成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
example: 'user/1234567890-abcdef-avatar.jpg',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
example:
|
||||
'http://localhost:3200/uploads/user/1234567890-abcdef-avatar.jpg',
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
example: '1234567890-abcdef-avatar.jpg',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
example: 102400,
|
||||
},
|
||||
mimetype: {
|
||||
type: 'string',
|
||||
example: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '文件格式或大小不符合要求' })
|
||||
async uploadAvatar(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB
|
||||
new FileTypeValidator({
|
||||
fileType: /^image\/(jpeg|jpg|png|gif|webp)$/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: FileUpload,
|
||||
) {
|
||||
const options = {
|
||||
folder: 'user', // 用户头像固定存储在 user 文件夹
|
||||
maxSize: 2 * 1024 * 1024, // 2MB(头像文件限制更小)
|
||||
allowedMimeTypes: [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
],
|
||||
};
|
||||
|
||||
return await this.storageService.upload(file, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件(需要管理员权限)
|
||||
*/
|
||||
@Delete('*path')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '删除文件',
|
||||
description: '根据文件路径删除文件,需要管理员权限',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'path',
|
||||
description: '文件路径(相对路径)',
|
||||
example: 'broker/1234567890-abcdef-broker-logo.jpg',
|
||||
})
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '文件不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
async delete(@Param('path') filePath: string) {
|
||||
// 移除路径开头的 /,如果有的话
|
||||
const cleanPath = filePath.startsWith('/')
|
||||
? filePath.substring(1)
|
||||
: filePath;
|
||||
await this.storageService.delete(cleanPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StorageController } from './storage.controller';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StorageController],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LocalStorageProvider } from './providers/local-storage.provider';
|
||||
import type {
|
||||
IStorageProvider,
|
||||
UploadResult,
|
||||
UploadOptions,
|
||||
FileUpload,
|
||||
} from './interfaces/storage-provider.interface';
|
||||
|
||||
/**
|
||||
* 存储服务
|
||||
* 根据配置选择不同的存储提供者
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly provider: IStorageProvider;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// 根据环境变量选择存储提供者
|
||||
const storageType =
|
||||
// 是的,STORAGE_TYPE 应配置在 .env 文件中,否则将默认使用 'local'
|
||||
this.configService.get<string>('STORAGE_TYPE') || 'local';
|
||||
|
||||
this.logger.log(`使用存储类型: ${storageType}`);
|
||||
|
||||
switch (storageType) {
|
||||
case 'local':
|
||||
this.provider = new LocalStorageProvider(configService);
|
||||
break;
|
||||
// 未来可以添加其他存储提供者
|
||||
// case 'qiniu':
|
||||
// this.provider = new QiniuStorageProvider(configService);
|
||||
// break;
|
||||
default:
|
||||
this.provider = new LocalStorageProvider(configService);
|
||||
this.logger.warn(
|
||||
`未知的存储类型 ${storageType},使用默认本地存储`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async upload(
|
||||
file: FileUpload,
|
||||
options?: UploadOptions,
|
||||
): Promise<UploadResult> {
|
||||
return this.provider.upload(file, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(path: string): Promise<void> {
|
||||
return this.provider.delete(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件URL
|
||||
*/
|
||||
getUrl(path: string): string {
|
||||
return this.provider.getUrl(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.provider.exists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取存储路径
|
||||
* 例如: http://localhost:3200/uploads/broker/filename.jpg -> broker/filename.jpg
|
||||
* @param url 完整的文件访问URL
|
||||
* @returns 存储路径(相对路径),如果无法解析则返回null
|
||||
*/
|
||||
extractStoragePath(url: string): string | null {
|
||||
try {
|
||||
// 尝试从URL中提取路径
|
||||
// 格式: http://domain/uploads/folder/filename.ext
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
// 移除 /uploads/ 前缀,获取相对路径
|
||||
const uploadsPrefix = '/uploads/';
|
||||
if (pathname.startsWith(uploadsPrefix)) {
|
||||
return pathname.substring(uploadsPrefix.length);
|
||||
}
|
||||
|
||||
// 如果不是标准格式,尝试直接使用路径名(去掉开头的 /)
|
||||
return pathname.startsWith('/') ? pathname.substring(1) : pathname;
|
||||
} catch (error) {
|
||||
// 如果URL格式不正确,返回null
|
||||
this.logger.warn(`无法解析URL: ${url}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Matches,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@@ -95,15 +94,4 @@ export class CreateUserDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
unionId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '用户角色',
|
||||
example: 'user',
|
||||
enum: ['user', 'admin', 'super_admin'],
|
||||
default: 'user',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['user', 'admin', 'super_admin'])
|
||||
role?: string;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
export class QueryUserDto {
|
||||
@@ -10,11 +18,91 @@ export class QueryUserDto {
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '昵称',
|
||||
example: 'John Doe',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '邮箱',
|
||||
example: 'user@example.com',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '电话',
|
||||
example: '13800138000',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '角色',
|
||||
example: 'user',
|
||||
enum: ['user', 'admin', 'super_admin'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['user', 'admin', 'super_admin'])
|
||||
role?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '状态',
|
||||
example: 'active',
|
||||
enum: ['active', 'inactive', 'deleted'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['active', 'inactive', 'deleted'])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '页码',
|
||||
example: 1,
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量',
|
||||
example: 10,
|
||||
minimum: 1,
|
||||
default: 10,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit?: number = 10;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '排序字段',
|
||||
example: 'createdAt',
|
||||
default: 'createdAt',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string = 'createdAt';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '排序方向',
|
||||
example: 'DESC',
|
||||
enum: ['ASC', 'DESC'],
|
||||
default: 'DESC',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC' = 'DESC';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -22,11 +22,13 @@ import { UserService } from './user.service';
|
||||
import { User } from './user.entity';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { QueryUserDto } from './dto/query-user.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { QueryUserDto } from './dto/query-user.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard';
|
||||
import { PaginatedUserData } from './dto/paginated-response.dto';
|
||||
|
||||
@ApiTags('user')
|
||||
@Controller('user')
|
||||
@@ -57,56 +59,59 @@ export class UserController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有用户
|
||||
* 查询所有用户(支持分页和筛选)
|
||||
*/
|
||||
@Get()
|
||||
// @UseGuards(JwtAuthGuard, RolesGuard)
|
||||
// @Roles('admin', 'super_admin')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '查询所有用户',
|
||||
description: '获取所有用户列表(需要管理员权限)',
|
||||
summary: '查询用户列表',
|
||||
description: '获取用户列表,支持分页和多种筛选条件(需要管理员权限)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: [User],
|
||||
type: PaginatedUserData,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
findAll(): Promise<User[]> {
|
||||
return this.userService.findAll();
|
||||
findAll(@Query() queryDto: QueryUserDto): Promise<PaginatedUserData> {
|
||||
return this.userService.findAllPaginated(queryDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 username 或 email 查询单个用户
|
||||
*/
|
||||
@Get('find')
|
||||
@ApiOperation({
|
||||
summary: '查询单个用户',
|
||||
description: '根据 username 或 email 查询用户',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误,必须提供 username 或 email',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
findOne(@Query() queryDto: QueryUserDto): Promise<User> {
|
||||
return this.userService.findOne(queryDto);
|
||||
}
|
||||
// /**
|
||||
// * 根据 username 或 email 查询单个用户
|
||||
// */
|
||||
// @Get('find')
|
||||
// @ApiOperation({
|
||||
// summary: '查询单个用户',
|
||||
// description: '根据 username 或 email 查询用户',
|
||||
// })
|
||||
// @ApiResponse({
|
||||
// status: 200,
|
||||
// description: '查询成功',
|
||||
// type: User,
|
||||
// })
|
||||
// @ApiResponse({
|
||||
// status: 400,
|
||||
// description: '请求参数错误,必须提供 username 或 email',
|
||||
// })
|
||||
// @ApiResponse({ status: 404, description: '用户不存在' })
|
||||
// findOne(@Query() queryDto: QueryUserDto): Promise<User> {
|
||||
// return this.userService.findOne(queryDto);
|
||||
// }
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个用户
|
||||
* 需要管理员权限或者是用户本人
|
||||
*/
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '根据ID查询用户',
|
||||
description: '根据用户ID获取详细信息',
|
||||
description: '根据用户ID获取详细信息,需要管理员权限或者是用户本人',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||
@ApiResponse({
|
||||
@@ -114,6 +119,11 @@ export class UserController {
|
||||
description: '查询成功',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: '权限不足,只能查询自己的信息或需要管理员权限',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
findOneById(@Param('id') id: string): Promise<User> {
|
||||
return this.userService.findOneById(+id);
|
||||
@@ -123,6 +133,8 @@ export class UserController {
|
||||
* 更新用户信息
|
||||
*/
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '更新用户信息',
|
||||
description: '更新用户信息,不允许修改 username、openId、unionId',
|
||||
@@ -147,6 +159,8 @@ export class UserController {
|
||||
*/
|
||||
@Patch(':id/password')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '修改密码',
|
||||
description: '修改用户密码,需要先验证旧密码',
|
||||
@@ -167,6 +181,8 @@ export class UserController {
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '删除用户',
|
||||
description: '软删除用户,将状态更新为 deleted',
|
||||
|
||||
@@ -4,9 +4,10 @@ import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserSeeder } from './user.seeder';
|
||||
import { User } from './user.entity';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
imports: [TypeOrmModule.forFeature([User]), StorageModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, UserSeeder],
|
||||
exports: [UserService],
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './user.entity';
|
||||
import { MOCK_USERS } from './mock-users.data';
|
||||
|
||||
/**
|
||||
* 用户数据种子(Seeder)
|
||||
*
|
||||
* 用途:在应用启动时自动创建初始管理员用户
|
||||
* 用途:在应用启动时自动创建初始用户(超级管理员、管理员、普通用户)
|
||||
*
|
||||
* 功能:
|
||||
* 1. 创建超级管理员和管理员各一名(从环境变量读取配置)
|
||||
* 2. 创建12名普通用户(使用Mock数据)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用批量查询(IN查询)检查用户是否存在,只执行2次数据库查询
|
||||
* - 只创建不存在的用户,保证幂等性
|
||||
*
|
||||
* 优点:
|
||||
* 1. 代码化管理,版本控制友好
|
||||
@@ -18,13 +27,14 @@ import { User } from './user.entity';
|
||||
* 5. 幂等性:如果用户已存在,不会重复创建
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 通过环境变量配置初始管理员信息
|
||||
* 1. 通过环境变量配置管理员信息
|
||||
* 2. 应用启动时自动执行
|
||||
* 3. 仅在开发/测试环境自动执行,生产环境建议手动创建
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserSeeder implements OnModuleInit {
|
||||
private readonly logger = new Logger(UserSeeder.name);
|
||||
private readonly saltRounds = 10; // bcrypt 加盐轮数
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
@@ -44,15 +54,51 @@ export class UserSeeder implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.seedAdminUser();
|
||||
// 执行种子数据创建
|
||||
await this.seedAllUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建初始管理员用户
|
||||
* 创建所有种子用户(超级管理员、管理员、普通用户)
|
||||
*/
|
||||
async seedAdminUser(): Promise<void> {
|
||||
async seedAllUsers(): Promise<void> {
|
||||
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 =
|
||||
this.configService.get<string>('ADMIN_USERNAME') || 'admin';
|
||||
const adminPassword =
|
||||
@@ -63,57 +109,159 @@ export class UserSeeder implements OnModuleInit {
|
||||
const adminNickname =
|
||||
this.configService.get<string>('ADMIN_NICKNAME') ||
|
||||
'系统管理员';
|
||||
const adminRole =
|
||||
this.configService.get<string>('ADMIN_ROLE') || 'admin';
|
||||
|
||||
// 检查管理员用户是否已存在
|
||||
const existingAdmin = await this.userRepository.findOne({
|
||||
where: { username: adminUsername },
|
||||
});
|
||||
|
||||
if (existingAdmin) {
|
||||
this.logger.log(
|
||||
`管理员用户 "${adminUsername}" 已存在,跳过创建`,
|
||||
);
|
||||
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({
|
||||
// 构建管理员用户数据
|
||||
const adminUsersToCreate = [
|
||||
{
|
||||
username: superAdminUsername,
|
||||
email: superAdminEmail,
|
||||
nickname: superAdminNickname,
|
||||
password: superAdminPassword,
|
||||
role: 'super_admin',
|
||||
},
|
||||
{
|
||||
username: adminUsername,
|
||||
passwordHash,
|
||||
email: adminEmail,
|
||||
nickname: adminNickname,
|
||||
role: adminRole,
|
||||
status: 'active',
|
||||
password: adminPassword,
|
||||
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(
|
||||
`✅ 成功创建初始管理员用户: ${adminUsername} (${adminEmail})`,
|
||||
`✅ 成功创建 ${usersToCreate.length} 名普通用户(共 ${MOCK_USERS.length} 名)`,
|
||||
);
|
||||
this.logger.warn(`⚠️ 默认密码: ${adminPassword},请尽快修改!`);
|
||||
if (usersToCreate.length < MOCK_USERS.length) {
|
||||
this.logger.log(
|
||||
` 已存在 ${MOCK_USERS.length - usersToCreate.length} 名用户,已跳过`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('创建初始管理员用户失败:', error);
|
||||
// 不抛出错误,避免影响应用启动
|
||||
this.logger.error('创建普通用户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +269,6 @@ export class UserSeeder implements OnModuleInit {
|
||||
* 手动执行种子数据(可用于 CLI 命令)
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
await this.seedAdminUser();
|
||||
await this.seedAllUsers();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,27 @@ import {
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './user.entity';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { QueryUserDto } from './dto/query-user.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly logger = new Logger(UserService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -91,6 +97,7 @@ export class UserService {
|
||||
);
|
||||
|
||||
// 创建用户
|
||||
// 注意:所有注册用户的 role 固定为 'user',不允许通过注册接口设置其他角色
|
||||
const user = this.userRepository.create({
|
||||
username: createUserDto.username,
|
||||
passwordHash,
|
||||
@@ -101,7 +108,7 @@ export class UserService {
|
||||
openId: createUserDto.openId,
|
||||
unionId: createUserDto.unionId,
|
||||
status: 'active',
|
||||
role: createUserDto.role || 'user', // 默认为普通用户
|
||||
role: 'user', // 固定为普通用户,不允许通过注册接口修改
|
||||
});
|
||||
|
||||
return this.userRepository.save(user);
|
||||
@@ -118,6 +125,95 @@ 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,
|
||||
});
|
||||
|
||||
// 移除 passwordHash 字段
|
||||
const listWithoutPassword = list.map((user) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword as User;
|
||||
});
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list: listWithoutPassword,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 username 或 email 查询单个用户
|
||||
*/
|
||||
@@ -158,7 +254,10 @@ export class UserService {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||
}
|
||||
|
||||
return user;
|
||||
// 移除 passwordHash 字段
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword as User;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,9 +330,27 @@ export class UserService {
|
||||
|
||||
/**
|
||||
* 删除用户(软删除,更新状态为 deleted)
|
||||
* 同时删除用户头像图片
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const user = await this.findOneById(id);
|
||||
|
||||
// 删除用户头像图片
|
||||
if (user.avatarUrl) {
|
||||
try {
|
||||
const imagePath = this.storageService.extractStoragePath(
|
||||
user.avatarUrl,
|
||||
);
|
||||
if (imagePath) {
|
||||
await this.storageService.delete(imagePath);
|
||||
this.logger.log(`已删除用户头像: ${imagePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// 图片删除失败不影响用户删除操作
|
||||
this.logger.warn(`删除用户头像失败: ${user.avatarUrl}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
user.status = 'deleted';
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('UserService (集成测试)', () => {
|
||||
password: 'password123',
|
||||
email: 'findall3@example.com',
|
||||
nickname: '查询测试用户3',
|
||||
role: 'admin',
|
||||
// 注意:注册时不允许传入 role,所有注册用户的 role 固定为 'user'
|
||||
},
|
||||
];
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('UserService (集成测试)', () => {
|
||||
nickname: '完整信息用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
phone: '13800138000',
|
||||
role: 'admin',
|
||||
// 注意:注册时不允许传入 role,所有注册用户的 role 固定为 'user'
|
||||
};
|
||||
|
||||
await service.create(createUserDto);
|
||||
@@ -307,7 +307,7 @@ describe('UserService (集成测试)', () => {
|
||||
expect(foundUser?.nickname).toBe(createUserDto.nickname);
|
||||
expect(foundUser?.avatarUrl).toBe(createUserDto.avatarUrl);
|
||||
expect(foundUser?.phone).toBe(createUserDto.phone);
|
||||
expect(foundUser?.role).toBe(createUserDto.role);
|
||||
expect(foundUser?.role).toBe('user'); // 注册用户的 role 固定为 'user'
|
||||
expect(foundUser?.status).toBe('active');
|
||||
expect(foundUser?.createdAt).toBeDefined();
|
||||
expect(foundUser?.updatedAt).toBeDefined();
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,439 @@
|
||||
# 资源上传文档
|
||||
|
||||
## 概述
|
||||
|
||||
本系统提供了完整的文件上传和管理功能,支持本地存储(未来可扩展至云存储)。主要用于上传券商Logo、用户头像等资源文件。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
storage/
|
||||
├── dto/
|
||||
│ └── upload-file.dto.ts # 上传文件DTO
|
||||
├── interfaces/
|
||||
│ └── storage-provider.interface.ts # 存储提供者接口
|
||||
├── providers/
|
||||
│ └── local-storage.provider.ts # 本地存储实现
|
||||
├── storage.controller.ts # 存储控制器
|
||||
├── storage.module.ts # 存储模块
|
||||
└── storage.service.ts # 存储服务
|
||||
```
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 必需的环境变量
|
||||
|
||||
在 `.env` 文件中配置以下环境变量:
|
||||
|
||||
```env
|
||||
# 存储类型:local(本地存储),未来可扩展为 qiniu、oss 等
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# 文件存储路径(相对于项目根目录)
|
||||
STORAGE_PATH=./uploads
|
||||
|
||||
# 文件访问基础URL(用于生成文件访问链接)
|
||||
STORAGE_BASE_URL=http://localhost:3200/uploads
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 示例 |
|
||||
| ------------------ | --------------- | ------------------------------- | --------------------------------- |
|
||||
| `STORAGE_TYPE` | 存储类型 | `local` | `local`、`qiniu`(未来支持) |
|
||||
| `STORAGE_PATH` | 文件存储路径 | `./uploads` | `./uploads`、`/var/www/uploads` |
|
||||
| `STORAGE_BASE_URL` | 文件访问基础URL | `http://localhost:3200/uploads` | `https://api.example.com/uploads` |
|
||||
|
||||
### 生产环境配置建议
|
||||
|
||||
```env
|
||||
# 生产环境配置示例
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_PATH=/var/www/invest-mind/uploads
|
||||
STORAGE_BASE_URL=https://api.example.com/uploads
|
||||
```
|
||||
|
||||
## 静态文件服务配置
|
||||
|
||||
系统在 `main.ts` 中自动配置了静态文件服务,无需额外配置:
|
||||
|
||||
```typescript
|
||||
// 配置静态文件服务
|
||||
const storagePath = configService.get<string>('STORAGE_PATH') || './uploads';
|
||||
app.useStaticAssets(join(process.cwd(), storagePath), {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
```
|
||||
|
||||
这意味着所有存储在 `STORAGE_PATH` 目录下的文件都可以通过 `/uploads/` 前缀访问。
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 管理员上传文件
|
||||
|
||||
**接口地址:** `POST /api/storage/upload`
|
||||
|
||||
**权限要求:** 需要管理员权限(`admin` 或 `super_admin`)
|
||||
|
||||
**请求格式:** `multipart/form-data`
|
||||
|
||||
**请求参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 | 可选值 |
|
||||
| ---------- | ------ | ---- | ------------ | ------------------------ |
|
||||
| `file` | File | 是 | 要上传的文件 | - |
|
||||
| `folder` | string | 否 | 存储文件夹 | `broker`、`user`、`temp` |
|
||||
| `filename` | string | 否 | 自定义文件名 | - |
|
||||
|
||||
**文件限制:**
|
||||
|
||||
- 最大文件大小:5MB
|
||||
- 允许的文件类型:`image/jpeg`、`image/jpg`、`image/png`、`image/gif`、`image/webp`
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3200/api/storage/upload \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-F "file=@/path/to/image.jpg" \
|
||||
-F "folder=broker" \
|
||||
-F "filename=custom-name.jpg"
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "broker/1234567890-abcdef-broker-logo.jpg",
|
||||
"url": "http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg",
|
||||
"filename": "1234567890-abcdef-broker-logo.jpg",
|
||||
"size": 102400,
|
||||
"mimetype": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 用户上传头像
|
||||
|
||||
**接口地址:** `POST /api/storage/upload/avatar`
|
||||
|
||||
**权限要求:** 无需鉴权(公开接口)
|
||||
|
||||
**请求格式:** `multipart/form-data`
|
||||
|
||||
**请求参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---- | ---- | ---------------- |
|
||||
| `file` | File | 是 | 要上传的头像文件 |
|
||||
|
||||
**文件限制:**
|
||||
|
||||
- 最大文件大小:2MB
|
||||
- 允许的文件类型:`image/jpeg`、`image/jpg`、`image/png`、`image/gif`、`image/webp`
|
||||
- 文件固定存储在 `user` 文件夹
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3200/api/storage/upload/avatar \
|
||||
-F "file=@/path/to/avatar.jpg"
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "user/1234567890-abcdef-avatar.jpg",
|
||||
"url": "http://localhost:3200/uploads/user/1234567890-abcdef-avatar.jpg",
|
||||
"filename": "1234567890-abcdef-avatar.jpg",
|
||||
"size": 51200,
|
||||
"mimetype": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 删除文件
|
||||
|
||||
**接口地址:** `DELETE /api/storage/{path}`
|
||||
|
||||
**权限要求:** 需要管理员权限(`admin` 或 `super_admin`)
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 参数名 | 类型 | 说明 | 示例 |
|
||||
| ------ | ------ | ------------ | ------------------------------------------ |
|
||||
| `path` | string | 文件相对路径 | `broker/1234567890-abcdef-broker-logo.jpg` |
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3200/api/storage/broker/1234567890-abcdef-broker-logo.jpg \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
- 成功:`204 No Content`
|
||||
- 文件不存在:`404 Not Found`
|
||||
- 权限不足:`403 Forbidden`
|
||||
|
||||
## 文件夹类型说明
|
||||
|
||||
系统支持三种文件夹类型,用于分类存储不同类型的文件:
|
||||
|
||||
| 文件夹 | 用途 | 说明 |
|
||||
| -------- | ------------ | ------------------------------ |
|
||||
| `broker` | 券商相关文件 | 用于存储券商Logo等基础数据文件 |
|
||||
| `user` | 用户相关文件 | 用于存储用户头像等个人文件 |
|
||||
| `temp` | 临时文件 | 用于存储临时文件,可定期清理 |
|
||||
|
||||
## 文件访问
|
||||
|
||||
### 访问方式
|
||||
|
||||
上传成功后,系统会返回文件的访问URL,可以通过以下方式访问:
|
||||
|
||||
1. **直接访问URL**:使用返回的 `url` 字段直接访问
|
||||
|
||||
```
|
||||
http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg
|
||||
```
|
||||
|
||||
2. **通过静态文件服务**:所有文件都通过 `/uploads/` 前缀提供静态文件服务
|
||||
```
|
||||
http://localhost:3200/uploads/{folder}/{filename}
|
||||
```
|
||||
|
||||
### 文件命名规则
|
||||
|
||||
如果不指定自定义文件名,系统会自动生成文件名,格式为:
|
||||
|
||||
```
|
||||
{timestamp}-{random}-{originalname}
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
- 原始文件名:`logo.jpg`
|
||||
- 生成文件名:`1234567890-abcdef-logo.jpg`
|
||||
|
||||
其中:
|
||||
|
||||
- `timestamp`:时间戳(毫秒)
|
||||
- `random`:8字节随机十六进制字符串
|
||||
- `originalname`:原始文件名(去除特殊字符)
|
||||
|
||||
### 文件名清理规则
|
||||
|
||||
系统会自动清理文件名中的特殊字符:
|
||||
|
||||
- 保留:字母、数字、下划线 `_`、连字符 `-`
|
||||
- 替换:其他特殊字符会被替换为下划线 `_`
|
||||
|
||||
## 文件存储结构
|
||||
|
||||
本地存储的文件结构如下:
|
||||
|
||||
```
|
||||
uploads/
|
||||
├── broker/ # 券商相关文件
|
||||
│ └── 1234567890-abcdef-broker-logo.jpg
|
||||
├── user/ # 用户相关文件
|
||||
│ └── 1234567890-abcdef-avatar.jpg
|
||||
└── temp/ # 临时文件
|
||||
└── 1234567890-abcdef-temp-file.jpg
|
||||
```
|
||||
|
||||
## 安全特性
|
||||
|
||||
### 1. 路径遍历防护
|
||||
|
||||
删除文件时会进行安全检查,防止路径遍历攻击:
|
||||
|
||||
```typescript
|
||||
// 安全检查:确保文件路径在 basePath 内
|
||||
const resolvedPath = path.resolve(fullPath);
|
||||
const resolvedBasePath = path.resolve(this.basePath);
|
||||
if (!resolvedPath.startsWith(resolvedBasePath)) {
|
||||
throw new Error('非法文件路径');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 文件类型验证
|
||||
|
||||
- 仅允许上传图片文件(jpeg、jpg、png、gif、webp)
|
||||
- 通过 MIME 类型和文件扩展名双重验证
|
||||
|
||||
### 3. 文件大小限制
|
||||
|
||||
- 管理员上传:最大 5MB
|
||||
- 用户头像:最大 2MB
|
||||
|
||||
### 4. 权限控制
|
||||
|
||||
- 管理员上传和删除操作需要管理员权限
|
||||
- 用户头像上传无需鉴权(用于注册场景)
|
||||
|
||||
## 使用示例
|
||||
|
||||
### JavaScript/TypeScript 示例
|
||||
|
||||
```typescript
|
||||
// 上传文件
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
formData.append('folder', 'broker');
|
||||
formData.append('filename', 'custom-name.jpg');
|
||||
|
||||
const response = await fetch('http://localhost:3200/api/storage/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('文件URL:', result.url);
|
||||
```
|
||||
|
||||
### React 示例
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
|
||||
function FileUpload() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('folder', 'broker');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'http://localhost:3200/api/storage/upload',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<button onClick={handleUpload} disabled={uploading}>
|
||||
{uploading ? '上传中...' : '上传'}
|
||||
</button>
|
||||
{result && (
|
||||
<div>
|
||||
<p>上传成功!</p>
|
||||
<img src={result.url} alt="上传的文件" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 添加新的存储提供者
|
||||
|
||||
系统采用策略模式,可以轻松添加新的存储提供者(如七牛云、阿里云OSS等):
|
||||
|
||||
1. 实现 `IStorageProvider` 接口
|
||||
2. 在 `StorageService` 中添加新的存储类型判断
|
||||
3. 配置相应的环境变量
|
||||
|
||||
示例:
|
||||
|
||||
```typescript
|
||||
// providers/qiniu-storage.provider.ts
|
||||
export class QiniuStorageProvider implements IStorageProvider {
|
||||
// 实现接口方法
|
||||
}
|
||||
|
||||
// storage.service.ts
|
||||
switch (storageType) {
|
||||
case 'qiniu':
|
||||
this.provider = new QiniuStorageProvider(configService);
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 文件上传失败
|
||||
|
||||
**问题:** 上传文件时返回 400 错误
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 检查文件大小是否超过限制(管理员5MB,头像2MB)
|
||||
- 检查文件类型是否为支持的图片格式
|
||||
- 检查文件是否损坏
|
||||
|
||||
### 2. 文件无法访问
|
||||
|
||||
**问题:** 上传成功但无法通过URL访问文件
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 检查 `STORAGE_BASE_URL` 配置是否正确
|
||||
- 检查静态文件服务是否正常启动
|
||||
- 检查文件是否实际存在于 `STORAGE_PATH` 目录
|
||||
- 检查文件权限是否正确
|
||||
|
||||
### 3. 删除文件失败
|
||||
|
||||
**问题:** 删除文件时返回 404 或 403 错误
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 404:检查文件路径是否正确,文件是否存在
|
||||
- 403:检查是否有管理员权限
|
||||
- 检查文件路径是否包含非法字符
|
||||
|
||||
### 4. 生产环境配置
|
||||
|
||||
**问题:** 如何在生产环境配置文件存储
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. 设置 `STORAGE_PATH` 为绝对路径(如 `/var/www/uploads`)
|
||||
2. 设置 `STORAGE_BASE_URL` 为生产域名(如 `https://api.example.com/uploads`)
|
||||
3. 确保目录有写入权限:`chmod -R 755 /var/www/uploads`
|
||||
4. 考虑使用云存储服务(未来支持)
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `storage.controller.ts` - API 控制器
|
||||
- `storage.service.ts` - 存储服务
|
||||
- `local-storage.provider.ts` - 本地存储实现
|
||||
- `storage-provider.interface.ts` - 存储提供者接口
|
||||
- `main.ts` - 静态文件服务配置
|
||||
|
||||
## 更新日志
|
||||
|
||||
- 初始版本:支持本地存储、文件上传、文件删除功能
|
||||
@@ -2,10 +2,10 @@
|
||||
# 运行 pnpm dev 时会加载此文件
|
||||
|
||||
# 开发服务器端口
|
||||
VITE_PORT=3201
|
||||
VITE_PORT=3200
|
||||
|
||||
# API 基础地址(开发环境)
|
||||
VITE_API_BASE_URL=http://localhost:3200/api
|
||||
VITE_API_BASE_URL=http://localhost:3201/api
|
||||
|
||||
# 应用名称
|
||||
VITE_APP_NAME=投小记
|
||||
|
||||
+4
-1
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
<title>投小记</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -19,7 +19,8 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.11.0",
|
||||
"styled-components": "^6.1.19"
|
||||
"styled-components": "^6.1.19",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { router } from './router';
|
||||
import './App.css';
|
||||
@@ -15,7 +15,9 @@ function App() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由守卫组件
|
||||
* 用于保护需要登录才能访问的页面
|
||||
*/
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const isAuthenticated = authService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// 未登录,重定向到登录页
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// 已登录,渲染子组件
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
/* 侧边栏样式 */
|
||||
.main-sider {
|
||||
background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -25,26 +27,7 @@
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item {
|
||||
margin: 0 !important;
|
||||
padding: 12px 20px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left: 3px solid white;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
/* 侧边栏菜单样式已移至 SidebarMenu.css */
|
||||
|
||||
/* 顶部栏样式 */
|
||||
.main-header {
|
||||
@@ -60,7 +43,7 @@
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -74,9 +57,15 @@
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #1f2937;
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-trigger:hover,
|
||||
@@ -96,6 +85,7 @@
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@@ -119,6 +109,7 @@
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
@@ -129,23 +120,37 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-details .ant-badge {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-details .ant-badge-status-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.main-content {
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
background: #f3f4f6;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { Layout, Menu, Avatar, Badge, Drawer } from 'antd';
|
||||
import { Layout, Avatar, Badge, Drawer, Dropdown, message } from 'antd';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
EditOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { authService } from '@/services/auth';
|
||||
import type { UserInfo } from '@/types/user';
|
||||
import { useBrokerStore } from '@/stores/broker';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { getPageInfo } from './menuConfig';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
interface MainLayoutProps {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// 页面标题映射
|
||||
const pageTitles: Record<string, { title: string; subtitle: string }> = {
|
||||
'/': { title: '资产账户', subtitle: '买股票就是买公司' },
|
||||
'/assets': { title: '资产账户', subtitle: '买股票就是买公司' },
|
||||
'/plans': { title: '交易计划', subtitle: '计划你的交易,交易你的计划' },
|
||||
'/review': { title: '投资复盘', subtitle: '回顾过去是为了更好应对将来' },
|
||||
};
|
||||
|
||||
const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 获取用户信息
|
||||
useEffect(() => {
|
||||
const currentUser = authService.getUser();
|
||||
setUser(currentUser);
|
||||
}, []);
|
||||
|
||||
// 初始化券商数据
|
||||
useEffect(() => {
|
||||
useBrokerStore.getState().fetchBrokers();
|
||||
}, []);
|
||||
|
||||
// 根据路由获取页面标题
|
||||
const pageInfo = useMemo(() => {
|
||||
return pageTitles[location.pathname] || pageTitles['/'];
|
||||
return getPageInfo(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 检测移动端
|
||||
@@ -51,41 +56,50 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems: MenuProps['items'] = [
|
||||
// 处理登出
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
message.success('已退出登录');
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
// 用户下拉菜单
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/assets',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '资产账户',
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人资料',
|
||||
onClick: () => navigate('/user-info'),
|
||||
},
|
||||
{
|
||||
key: '/plans',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '交易计划',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: '/review',
|
||||
icon: <EditOutlined />,
|
||||
label: '投资复盘',
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
if (isMobile) {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
// 获取角色显示文本
|
||||
const getRoleText = (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
user: '普通用户',
|
||||
admin: '管理员',
|
||||
super_admin: '超级管理员',
|
||||
};
|
||||
return roleMap[role] || '普通用户';
|
||||
};
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/' || path === '/assets') {
|
||||
return ['/assets'];
|
||||
// 获取角色状态
|
||||
const getRoleStatus = (role: string): 'success' | 'warning' | 'error' => {
|
||||
if (role === 'admin' || role === 'super_admin') {
|
||||
return 'warning';
|
||||
}
|
||||
return [path];
|
||||
}, [location.pathname]);
|
||||
return 'success';
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout className="main-layout">
|
||||
@@ -109,14 +123,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
<div className="logo">{collapsed ? '投' : '投小记'}</div>
|
||||
{!collapsed && <div className="logo-subtitle">VestMind</div>}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
<SidebarMenu collapsed={collapsed} user={user} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
@@ -130,12 +137,10 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
bodyStyle={{ padding: 0 }}
|
||||
width={240}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ border: 'none' }}
|
||||
<SidebarMenu
|
||||
collapsed={false}
|
||||
user={user}
|
||||
onMenuClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
@@ -171,29 +176,40 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<div className="user-info">
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div className="user-info" style={{ cursor: 'pointer' }}>
|
||||
<Avatar
|
||||
style={{
|
||||
backgroundColor: '#8b5cf6',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
src={user?.avatarUrl}
|
||||
>
|
||||
U
|
||||
{user?.nickname?.[0] || user?.username?.[0] || 'U'}
|
||||
</Avatar>
|
||||
<div className="user-details">
|
||||
<div className="user-name">用户名</div>
|
||||
<div className="user-name">
|
||||
{user?.nickname || user?.username || '用户'}
|
||||
</div>
|
||||
<Badge
|
||||
status="success"
|
||||
text={<span className="user-role">普通用户</span>}
|
||||
status={user ? getRoleStatus(user.role) : 'success'}
|
||||
text={
|
||||
<span className="user-role">
|
||||
{user ? getRoleText(user.role) : '普通用户'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Content className="main-content">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
EditOutlined,
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
BankOutlined,
|
||||
UserOutlined,
|
||||
StockOutlined,
|
||||
LineChartOutlined,
|
||||
} 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-info',
|
||||
// key: '/user-info',
|
||||
// icon: <UserOutlined />,
|
||||
// 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: '/stock-info',
|
||||
key: '/stock-info',
|
||||
icon: <StockOutlined />,
|
||||
label: '股票信息',
|
||||
title: '股票信息',
|
||||
subtitle: '管理股票基本信息',
|
||||
group: 'admin',
|
||||
requireAdmin: true,
|
||||
},
|
||||
{
|
||||
path: '/stock-daily-price',
|
||||
key: '/stock-daily-price',
|
||||
icon: <LineChartOutlined />,
|
||||
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;
|
||||
})();
|
||||
@@ -6,5 +6,5 @@ import App from './App.tsx'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
.assets-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.positions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.position-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.position-item:hover {
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.position-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.position-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.position-code {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.position-stats {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.position-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.position-profit {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.position-profit.positive {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.position-profit.negative {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.position-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.position-stats {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-form .ant-input-affix-wrapper,
|
||||
.login-form .ant-input {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: var(--primary-color, #8b5cf6);
|
||||
border-color: var(--primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: var(--primary-dark, #7c3aed);
|
||||
border-color: var(--primary-dark, #7c3aed);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Form, Input, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { authService } from '../services/auth';
|
||||
import type { LoginRequest } from '@/types/user';
|
||||
import './LoginPage.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 如果已登录,重定向到首页
|
||||
useEffect(() => {
|
||||
if (authService.isAuthenticated()) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const onFinish = async (values: LoginRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await authService.login(values);
|
||||
message.success('登录成功');
|
||||
navigate('/', { replace: true });
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '登录失败,请检查用户名和密码');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-box">
|
||||
<div className="login-header">
|
||||
<h1>投小记</h1>
|
||||
<p>投资决策与复盘工具</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="login-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="usernameOrEmail"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入用户名或邮箱',
|
||||
},
|
||||
{
|
||||
min: 3,
|
||||
message: '用户名或邮箱至少3个字符',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名或邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: '密码至少6个字符',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="login-button"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/* 持仓网格布局 - 响应式 */
|
||||
.positions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.positions-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.positions-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 持仓卡片 */
|
||||
.position-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.position-card:hover {
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 持仓卡片内容区域 */
|
||||
.position-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 左侧:基本信息 */
|
||||
.position-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.position-meta-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.position-symbol {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
padding: 2px 6px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.position-holding-info {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.position-holding-days {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 右侧:价格和盈亏信息 */
|
||||
.position-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.position-current-price {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.position-market-value {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.position-value-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.position-profit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.position-profit-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.position-profit-percent {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (min-width: 768px) {
|
||||
.position-name {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.position-current-price {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.position-value-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.position-profit-amount {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.position-profit-percent {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 分割线和更新按钮 */
|
||||
.position-footer {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.position-update-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
color: #8b5cf6;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.position-update-btn:hover {
|
||||
color: rgba(139, 92, 246, 0.8);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.position-update-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.position-update-btn {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.position-update-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Card, Row, Col, Statistic, Button } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import { useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic } from 'antd';
|
||||
import { ArrowUpOutlined } from '@ant-design/icons';
|
||||
import PositionList from './components/PositionList';
|
||||
import { stockDataService } from '@/services/stock-data';
|
||||
import './AssetsPage.css';
|
||||
|
||||
const AssetsPage = () => {
|
||||
// 写死的数据
|
||||
// 初始化股票数据(页面加载时)
|
||||
useEffect(() => {
|
||||
stockDataService.init().catch((error) => {
|
||||
console.error('初始化股票数据失败', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 写死的数据(占位)
|
||||
const stats = {
|
||||
totalAssets: 1234567,
|
||||
totalProfit: 234567,
|
||||
@@ -11,36 +21,6 @@ const AssetsPage = () => {
|
||||
recordDays: 300,
|
||||
};
|
||||
|
||||
const positions = [
|
||||
{
|
||||
name: '贵州茅台',
|
||||
code: '600519',
|
||||
market: '上海',
|
||||
broker: '华泰证券',
|
||||
value: 456789,
|
||||
profit: 56789,
|
||||
profitRate: 14.2,
|
||||
},
|
||||
{
|
||||
name: '腾讯控股',
|
||||
code: '00700',
|
||||
market: '香港',
|
||||
broker: '富途证券',
|
||||
value: 345678,
|
||||
profit: 45678,
|
||||
profitRate: 15.2,
|
||||
},
|
||||
{
|
||||
name: '苹果公司',
|
||||
code: 'AAPL',
|
||||
market: '美股',
|
||||
broker: '盈透证券',
|
||||
value: 432100,
|
||||
profit: -12100,
|
||||
profitRate: -2.7,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="assets-page">
|
||||
{/* 统计卡片 */}
|
||||
@@ -104,42 +84,7 @@ const AssetsPage = () => {
|
||||
</Card>
|
||||
|
||||
{/* 持仓列表 */}
|
||||
<Card
|
||||
title="我的持仓"
|
||||
extra={
|
||||
<Button type="primary" size="small">
|
||||
+ 添加资产
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="positions-list">
|
||||
{positions.map((position, index) => (
|
||||
<div key={index} className="position-item">
|
||||
<div className="position-info">
|
||||
<div className="position-name">{position.name}</div>
|
||||
<div className="position-code">
|
||||
{position.code} · {position.market} · {position.broker}
|
||||
</div>
|
||||
</div>
|
||||
<div className="position-stats">
|
||||
<div className="position-value">
|
||||
¥{position.value.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={`position-profit ${
|
||||
position.profit >= 0 ? 'positive' : 'negative'
|
||||
}`}
|
||||
>
|
||||
{position.profit >= 0 ? '+' : ''}¥
|
||||
{Math.abs(position.profit).toLocaleString()} (
|
||||
{position.profitRate >= 0 ? '+' : ''}
|
||||
{position.profitRate}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<PositionList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,624 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Switch,
|
||||
AutoComplete,
|
||||
Button,
|
||||
Alert,
|
||||
Tag,
|
||||
App as AntdApp,
|
||||
} from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { positionService } from '@/services/position';
|
||||
import { stockDataService, type AssetSearchResult } from '@/services/stock-data';
|
||||
import { useBrokerStore } from '@/stores/broker';
|
||||
import type { CreatePositionRequest } from '@/types/position';
|
||||
|
||||
// 简单的防抖函数
|
||||
function debounce<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
func(...args);
|
||||
};
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
interface CreatePositionModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const CreatePositionModal = ({ open, onCancel, onSuccess }: CreatePositionModalProps) => {
|
||||
const [form] = Form.useForm();
|
||||
const { message: messageApi } = AntdApp.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<AssetSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetSearchResult | null>(null);
|
||||
const [assetType, setAssetType] = useState<string>('');
|
||||
const [showManualInput, setShowManualInput] = useState(false);
|
||||
const nameInputRef = useRef<any>(null);
|
||||
|
||||
const brokers = useBrokerStore((state) => state.brokers);
|
||||
|
||||
// 初始化股票数据(弹窗打开时)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
stockDataService.init().catch((error) => {
|
||||
console.error('初始化股票数据失败', error);
|
||||
messageApi.warning('股票数据加载失败,搜索功能可能不可用');
|
||||
});
|
||||
}
|
||||
}, [open, messageApi]);
|
||||
|
||||
// 防抖搜索(前端字符串匹配)
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
debounce((keyword: string) => {
|
||||
if (!keyword || keyword.trim().length < 1) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
// 在前端进行字符串匹配
|
||||
const results = stockDataService.searchAssets(keyword, 10);
|
||||
setSearchResults(results);
|
||||
} catch (error: any) {
|
||||
console.error('搜索失败', error);
|
||||
setSearchResults([]);
|
||||
messageApi.error(error.message || '搜索失败');
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300),
|
||||
[messageApi]
|
||||
);
|
||||
|
||||
// 搜索资产
|
||||
const handleSearch = (keyword: string) => {
|
||||
setSearchKeyword(keyword);
|
||||
debouncedSearch(keyword);
|
||||
};
|
||||
|
||||
// 选择资产
|
||||
const handleSelectAsset = (asset: AssetSearchResult) => {
|
||||
setSelectedAsset(asset);
|
||||
setSearchKeyword(asset.name);
|
||||
setSearchResults([]);
|
||||
setShowManualInput(false);
|
||||
|
||||
// 统一市场代码:sh/sz/bj -> 'a' (A股)
|
||||
// 港股和美股直接使用
|
||||
const formMarket = asset.market === 'a' ? 'a' : asset.market;
|
||||
|
||||
// 自动填充表单
|
||||
form.setFieldsValue({
|
||||
assetType: 'stock', // 能匹配上的一定是股票
|
||||
market: formMarket,
|
||||
symbol: asset.symbol,
|
||||
name: asset.name,
|
||||
currency: asset.market === 'hk' ? 'HKD' : asset.market === 'us' ? 'USD' : 'CNY',
|
||||
});
|
||||
|
||||
setAssetType('stock');
|
||||
};
|
||||
|
||||
// 重新选择
|
||||
const handleResetSearch = () => {
|
||||
setSelectedAsset(null);
|
||||
setSearchKeyword('');
|
||||
setSearchResults([]);
|
||||
form.setFieldsValue({
|
||||
assetType: assetType || undefined,
|
||||
market: undefined,
|
||||
symbol: undefined,
|
||||
name: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 资产类型变化
|
||||
const handleAssetTypeChange = (value: string) => {
|
||||
setAssetType(value);
|
||||
form.setFieldsValue({ assetType: value });
|
||||
|
||||
// 如果手动修改了资产类型,清空搜索选择
|
||||
if (selectedAsset && value !== 'stock') {
|
||||
handleResetSearch();
|
||||
}
|
||||
|
||||
// 如果选择了现金或其他,隐藏搜索框
|
||||
if (value === 'cash' || value === 'other') {
|
||||
setShowManualInput(true);
|
||||
} else {
|
||||
setShowManualInput(false);
|
||||
}
|
||||
|
||||
// 如果搜索框有内容,重新搜索
|
||||
if (searchKeyword && value === 'stock') {
|
||||
handleSearch(searchKeyword);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取搜索框占位符
|
||||
const getSearchPlaceholder = () => {
|
||||
return '输入股票代码或名称搜索(如:600519 或 贵州茅台)';
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 如果市场是 'a'(A股),转换为 'sh'(默认使用上海市场)
|
||||
const marketValue = values.market === 'a' ? 'sh' : values.market;
|
||||
|
||||
// 现金类型:symbol 为空字符串,name 固定为"现金"
|
||||
// 其他类型:symbol 为空字符串
|
||||
const symbolValue =
|
||||
values.assetType === 'cash' || values.assetType === 'other'
|
||||
? ''
|
||||
: values.symbol || '';
|
||||
const nameValue = values.assetType === 'cash' ? '现金' : values.name || '';
|
||||
|
||||
const requestData: CreatePositionRequest = {
|
||||
// 其他类型不需要 brokerId 和 market
|
||||
brokerId: values.assetType === 'other' ? undefined : values.brokerId,
|
||||
assetType: values.assetType,
|
||||
symbol: symbolValue,
|
||||
name: nameValue,
|
||||
market:
|
||||
values.assetType === 'other' || values.assetType === 'cash'
|
||||
? undefined
|
||||
: marketValue,
|
||||
// 现金类型不需要 shares 和 costPrice,使用默认值
|
||||
shares: values.assetType === 'cash' ? 1 : values.shares,
|
||||
costPrice:
|
||||
values.assetType === 'cash' ? values.currentPrice || 0 : values.costPrice,
|
||||
currentPrice: values.currentPrice,
|
||||
currency: values.currency || 'CNY',
|
||||
autoPriceUpdate: values.autoPriceUpdate || false,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const response = await positionService.createPosition(requestData);
|
||||
if (response.code === 0) {
|
||||
messageApi.success('创建持仓成功');
|
||||
form.resetFields();
|
||||
setSelectedAsset(null);
|
||||
setSearchKeyword('');
|
||||
setAssetType('');
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
messageApi.error(response.message || '创建持仓失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('创建持仓失败:', error);
|
||||
messageApi.error('创建持仓失败,请重试!');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
setSelectedAsset(null);
|
||||
setSearchKeyword('');
|
||||
setSearchResults([]);
|
||||
setAssetType('');
|
||||
setShowManualInput(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// 市场选项
|
||||
const marketOptions = [
|
||||
{ value: 'a', label: 'A股' },
|
||||
{ value: 'hk', label: '港股' },
|
||||
{ value: 'us', label: '美股' },
|
||||
{ value: 'jp', label: '日股' },
|
||||
{ value: 'kr', label: '韩国股市' },
|
||||
{ value: 'eu', label: '欧洲市场' },
|
||||
{ value: 'sea', label: '东南亚市场' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
// 获取市场显示名称
|
||||
const getMarketDisplayName = (market: string) => {
|
||||
// 统一市场代码映射
|
||||
if (market === 'a' || market === 'sh' || market === 'sz' || market === 'bj') {
|
||||
return 'A股';
|
||||
}
|
||||
const option = marketOptions.find((opt) => opt.value === market);
|
||||
return option ? option.label : market;
|
||||
};
|
||||
|
||||
// 根据资产类型获取代码标签
|
||||
const getCodeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'stock':
|
||||
return '股票代码';
|
||||
case 'fund':
|
||||
return '基金代码';
|
||||
case 'bond':
|
||||
return '债券代码';
|
||||
default:
|
||||
return '资产代码';
|
||||
}
|
||||
};
|
||||
|
||||
// 根据资产类型获取名称标签
|
||||
const getNameLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'stock':
|
||||
return '股票名称';
|
||||
case 'fund':
|
||||
return '基金名称';
|
||||
case 'bond':
|
||||
return '债券名称';
|
||||
default:
|
||||
return '资产名称';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新建持仓"
|
||||
open={open}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 18 }}
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{
|
||||
currency: 'CNY',
|
||||
autoPriceUpdate: false,
|
||||
}}
|
||||
style={{
|
||||
marginTop: 10,
|
||||
maxWidth: '480px',
|
||||
margin: '10px auto 0',
|
||||
}}
|
||||
>
|
||||
{/* 搜索框(现金和其他类型时隐藏) */}
|
||||
{!showManualInput && (
|
||||
<Form.Item label="搜索资产">
|
||||
<AutoComplete
|
||||
value={searchKeyword}
|
||||
options={searchResults.map((asset) => ({
|
||||
value: `${asset.symbol} - ${asset.name}`,
|
||||
label: (
|
||||
<div>
|
||||
<div>
|
||||
<strong>{asset.symbol}</strong> - {asset.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{getMarketDisplayName(asset.market)} - 股票
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
asset: asset,
|
||||
}))}
|
||||
onChange={handleSearch}
|
||||
onSelect={(_: any, option: any) => handleSelectAsset(option.asset)}
|
||||
placeholder={getSearchPlaceholder()}
|
||||
allowClear
|
||||
disabled={!!selectedAsset}
|
||||
notFoundContent={
|
||||
searchKeyword && !isSearching ? (
|
||||
<div>
|
||||
<div>未找到匹配的资产</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
// 聚焦到资产名称输入框
|
||||
setTimeout(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
点击手动输入
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
showSearch={true}
|
||||
/>
|
||||
{selectedAsset && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color="blue">{selectedAsset.name}</Tag>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleResetSearch}
|
||||
>
|
||||
重新选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 资产类型 */}
|
||||
<Form.Item
|
||||
name="assetType"
|
||||
label="资产类型"
|
||||
rules={[{ required: true, message: '请选择资产类型' }]}
|
||||
>
|
||||
<Select
|
||||
onChange={handleAssetTypeChange}
|
||||
disabled={!!selectedAsset}
|
||||
placeholder="选择资产类型"
|
||||
>
|
||||
<Select.Option value="stock">股票</Select.Option>
|
||||
<Select.Option value="fund">基金</Select.Option>
|
||||
<Select.Option value="bond">债券</Select.Option>
|
||||
<Select.Option value="cash">现金</Select.Option>
|
||||
<Select.Option value="other">其他</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* 其他字段(有动画效果,只有在选择资产类型或选中资产后才显示) */}
|
||||
<div
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.5s ease-in-out, opacity 0.5s ease-in-out',
|
||||
maxHeight: assetType || selectedAsset ? '3000px' : '0',
|
||||
opacity: assetType || selectedAsset ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{/* 市场和券商(股票/基金/债券显示) */}
|
||||
{(assetType === 'stock' || assetType === 'fund' || assetType === 'bond') && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="symbol"
|
||||
label={getCodeLabel(assetType)}
|
||||
rules={[
|
||||
{ required: true, message: `请输入${getCodeLabel(assetType)}` },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={`如:${assetType === 'stock' ? '600519、00700、AAPL' : assetType === 'fund' ? '000001' : '100001'}`}
|
||||
disabled={!!selectedAsset}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={getNameLabel(assetType)}
|
||||
rules={[
|
||||
{ required: true, message: `请输入${getNameLabel(assetType)}` },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
ref={nameInputRef as any}
|
||||
placeholder={`如:${assetType === 'stock' ? '贵州茅台' : assetType === 'fund' ? '华夏成长' : '国债'}`}
|
||||
disabled={!!selectedAsset}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="market" label="市场">
|
||||
<Select disabled={!!selectedAsset} placeholder="选择市场">
|
||||
{marketOptions.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="brokerId" label="券商">
|
||||
<Select placeholder="选择券商">
|
||||
{brokers.map((broker) => (
|
||||
<Select.Option
|
||||
key={broker.brokerId}
|
||||
value={broker.brokerId}
|
||||
>
|
||||
{broker.brokerName}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 券商(现金显示) */}
|
||||
{assetType === 'cash' && (
|
||||
<Form.Item name="brokerId" label="券商">
|
||||
<Select placeholder="选择券商">
|
||||
{brokers.map((broker) => (
|
||||
<Select.Option key={broker.brokerId} value={broker.brokerId}>
|
||||
{broker.brokerName}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 其他类型:只显示名称、成本价、数量、最新价 */}
|
||||
{assetType === 'other' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="资产名称"
|
||||
rules={[{ required: true, message: '请输入资产名称' }]}
|
||||
>
|
||||
<Input ref={nameInputRef as any} placeholder="如:其他资产" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="costPrice"
|
||||
label="成本价"
|
||||
rules={[
|
||||
{ required: true, message: '请输入成本价' },
|
||||
{ type: 'number', min: 0.0001, message: '成本价必须大于0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
prefix="¥"
|
||||
precision={2}
|
||||
placeholder="成本价"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="shares"
|
||||
label="数量"
|
||||
rules={[
|
||||
{ required: true, message: '请输入数量' },
|
||||
{ type: 'number', min: 0.0001, message: '数量必须大于0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
precision={4}
|
||||
placeholder="输入数量"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="currentPrice" label="最新价">
|
||||
<InputNumber
|
||||
prefix="¥"
|
||||
precision={2}
|
||||
placeholder="输入最新价"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 价格和数量(现金和其他类型不显示成本价和持股数量) */}
|
||||
{assetType && assetType !== 'cash' && assetType !== 'other' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="costPrice"
|
||||
label="成本价"
|
||||
rules={[
|
||||
{ required: true, message: '请输入成本价' },
|
||||
{ type: 'number', min: 0.0001, message: '成本价必须大于0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
prefix="¥"
|
||||
precision={2}
|
||||
placeholder="成本价"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="shares"
|
||||
label="持股数量"
|
||||
rules={[
|
||||
{ required: true, message: '请输入持股数量' },
|
||||
{ type: 'number', min: 0.0001, message: '持股数量必须大于0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
precision={4}
|
||||
placeholder="输入持股数量"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 最新价/现金余额(其他类型已在上面单独处理) */}
|
||||
{assetType && assetType !== 'other' && (
|
||||
<Form.Item
|
||||
name="currentPrice"
|
||||
label={assetType === 'cash' ? '现金余额' : '最新价'}
|
||||
rules={
|
||||
assetType === 'cash'
|
||||
? [
|
||||
{ required: true, message: '请输入现金余额' },
|
||||
{ type: 'number', min: 0, message: '现金余额不能小于0' },
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<InputNumber
|
||||
prefix="¥"
|
||||
precision={2}
|
||||
placeholder={assetType === 'cash' ? '输入现金余额' : '输入最新价'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 其他选项
|
||||
<Form.Item name="currency" label="货币类型">
|
||||
<Select>
|
||||
<Select.Option value="CNY">人民币</Select.Option>
|
||||
<Select.Option value="HKD">港币</Select.Option>
|
||||
<Select.Option value="USD">美元</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>*/}
|
||||
|
||||
{assetType && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="autoPriceUpdate"
|
||||
label="自动更新价格"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Alert
|
||||
description={
|
||||
<div>
|
||||
<p>• 可以通过搜索框快速选择资产,系统会自动填充相关信息</p>
|
||||
<p>• 成本价和最新价请使用人民币计价</p>
|
||||
<p>
|
||||
• 如果持有港股/美股,请将港币/美元价格转换为人民币后输入
|
||||
</p>
|
||||
<p>• 系统会自动更新价格(如果开启自动更新)</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12 }}>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePositionModal;
|
||||
@@ -0,0 +1,73 @@
|
||||
/* 我的持仓容器 */
|
||||
.position-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 标题和按钮区域 */
|
||||
.position-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.position-list-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.position-list-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加资产按钮 - 完全圆形 */
|
||||
.position-add-btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding: 0 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
border: 1px solid #8b5cf6 !important;
|
||||
background: transparent !important;
|
||||
color: #8b5cf6 !important;
|
||||
border-radius: 50% !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.position-add-btn:hover,
|
||||
.position-add-btn:focus {
|
||||
background: #8b5cf6 !important;
|
||||
color: #fff !important;
|
||||
border-color: #8b5cf6 !important;
|
||||
}
|
||||
|
||||
.position-add-btn:active {
|
||||
background: #7c3aed !important;
|
||||
border-color: #7c3aed !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.position-add-btn .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.position-add-btn {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
min-width: 36px !important;
|
||||
}
|
||||
|
||||
.position-add-btn .anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Button, App, Spin } from 'antd';
|
||||
import { PlusOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { positionService } from '@/services/position';
|
||||
import type { PositionResponse } from '@/types/position';
|
||||
import { useBrokerStore } from '@/stores/broker';
|
||||
import { useMarketStore } from '@/stores/market';
|
||||
import CreatePositionModal from './CreatePositionModal';
|
||||
import '../AssetsPage.css';
|
||||
import './PositionList.css';
|
||||
|
||||
const PositionList = () => {
|
||||
const { message: messageApi } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [positions, setPositions] = useState<PositionResponse[]>([]);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const getBrokerName = useBrokerStore((state) => state.getBrokerName);
|
||||
const getMarketName = useMarketStore((state) => state.getMarketName);
|
||||
|
||||
// 加载持仓数据
|
||||
const loadPositions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const positionData = await positionService.getPositionsByUserId();
|
||||
if (positionData && positionData.code === 0) {
|
||||
const positionList = positionData.data;
|
||||
setPositions(positionList || []);
|
||||
} else {
|
||||
setPositions([]);
|
||||
messageApi.error(positionData.message || '加载持仓数据失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '加载持仓数据失败');
|
||||
setPositions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPositions();
|
||||
}, []);
|
||||
|
||||
// 格式化价格显示(根据涨跌显示颜色)
|
||||
const formatPrice = (currentPrice?: number, previousPrice?: number) => {
|
||||
if (!currentPrice) return { text: '--', color: '#1f2937' };
|
||||
const price = currentPrice.toFixed(2);
|
||||
if (!previousPrice) return { text: price, color: '#1f2937' };
|
||||
if (currentPrice > previousPrice) {
|
||||
return { text: price, color: '#ef4444' }; // 红色(上涨)
|
||||
} else if (currentPrice < previousPrice) {
|
||||
return { text: price, color: '#10b981' }; // 绿色(下跌)
|
||||
}
|
||||
return { text: price, color: '#1f2937' };
|
||||
};
|
||||
|
||||
// 格式化盈亏显示
|
||||
const formatProfit = (profit: number, profitPercent: number) => {
|
||||
const isPositive = profit >= 0;
|
||||
const profitText = `${isPositive ? '+' : ''}${Math.abs(profit).toLocaleString()}`;
|
||||
const percentText = `${isPositive ? '+' : ''}${profitPercent.toFixed(2)}%`;
|
||||
const color = isPositive ? '#ef4444' : '#10b981';
|
||||
return { profitText, percentText, color };
|
||||
};
|
||||
|
||||
// 格式化市值颜色(根据盈亏状态)
|
||||
const formatMarketValueColor = (marketValue: number, costValue: number) => {
|
||||
if (marketValue > costValue) {
|
||||
return '#ef4444'; // 盈利显示红色
|
||||
} else if (marketValue < costValue) {
|
||||
return '#10b981'; // 亏损显示绿色
|
||||
}
|
||||
return '#1f2937'; // 持平显示灰色
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="position-list-container">
|
||||
<div className="position-list-header">
|
||||
<h2 className="position-list-title">我的持仓</h2>
|
||||
<Button
|
||||
type="default"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
className="position-add-btn"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
<div className="positions-grid">
|
||||
{positions &&
|
||||
positions.map((position) => {
|
||||
const priceInfo = formatPrice(
|
||||
position.currentPrice,
|
||||
position.previousPrice
|
||||
);
|
||||
const profitInfo = formatProfit(
|
||||
position.profit,
|
||||
position.profitPercent
|
||||
);
|
||||
const marketValueColor = formatMarketValueColor(
|
||||
position.marketValue,
|
||||
position.costValue
|
||||
);
|
||||
const brokerName = position.brokerId
|
||||
? getBrokerName(position.brokerId)
|
||||
: '';
|
||||
const marketText = getMarketName(position.market);
|
||||
|
||||
return (
|
||||
<Card key={position.positionId} className="position-card">
|
||||
<div className="position-content">
|
||||
{/* 左侧:基本信息 */}
|
||||
<div className="position-left">
|
||||
<div className="position-name">{position.name}</div>
|
||||
<div className="position-meta-info">
|
||||
<span className="position-symbol">
|
||||
{position.symbol}
|
||||
</span>
|
||||
{marketText && (
|
||||
<span className="meta-tag">{marketText}</span>
|
||||
)}
|
||||
{brokerName && (
|
||||
<span className="meta-tag">{brokerName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="position-holding-info">
|
||||
<span>
|
||||
持股 {position.shares.toLocaleString()} 股
|
||||
</span>
|
||||
</div>
|
||||
<div className="position-holding-days">
|
||||
<span>持有 {position.holdingDays} 天</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:价格和盈亏信息 */}
|
||||
<div className="position-right">
|
||||
<div
|
||||
className="position-current-price"
|
||||
style={{ color: priceInfo.color }}
|
||||
>
|
||||
{priceInfo.text}
|
||||
</div>
|
||||
<div
|
||||
className="position-market-value"
|
||||
style={{ color: marketValueColor }}
|
||||
>
|
||||
<span className="position-value-text">
|
||||
{position.marketValue.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{/* 累计收益和累计收益率 */}
|
||||
<div
|
||||
className="position-profit"
|
||||
style={{ color: profitInfo.color }}
|
||||
>
|
||||
{position.assetType !== 'cash' ? (
|
||||
<>
|
||||
<span className="position-profit-amount">
|
||||
{profitInfo.profitText}
|
||||
</span>
|
||||
<span className="position-profit-percent">
|
||||
{profitInfo.percentText}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="position-profit-amount"
|
||||
style={{ opacity: 0 }}
|
||||
>
|
||||
--
|
||||
</span>
|
||||
<span
|
||||
className="position-profit-percent"
|
||||
style={{ opacity: 0 }}
|
||||
>
|
||||
--
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分割线和更新按钮 */}
|
||||
<div className="position-footer">
|
||||
<button className="position-update-btn">
|
||||
<span>更新持仓</span>
|
||||
<RightOutlined className="position-update-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
{/* 新建持仓弹窗 */}
|
||||
<CreatePositionModal
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onSuccess={loadPositions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionList;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AssetsPage';
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, Form, Input, Select, Upload, message } from 'antd';
|
||||
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import { brokerService } from '@/services/broker';
|
||||
import { storageService } from '@/services/storage';
|
||||
import type { Broker, CreateBrokerRequest } from '@/types/broker';
|
||||
import { REGION_OPTIONS } from '@/types/broker';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
|
||||
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 [uploading, setUploading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const isEdit = !!editingBroker;
|
||||
|
||||
// 当编辑数据变化时,更新表单
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (editingBroker) {
|
||||
form.setFieldsValue({
|
||||
brokerCode: editingBroker.brokerCode,
|
||||
brokerName: editingBroker.brokerName,
|
||||
region: editingBroker.region,
|
||||
brokerImage: editingBroker.brokerImage,
|
||||
});
|
||||
// 如果有图片,设置文件列表
|
||||
if (editingBroker.brokerImage) {
|
||||
setFileList([
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'broker-logo',
|
||||
status: 'done',
|
||||
url: editingBroker.brokerImage,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
} else {
|
||||
form.resetFields();
|
||||
setFileList([]);
|
||||
}
|
||||
}
|
||||
}, [visible, editingBroker, form]);
|
||||
|
||||
// 自定义上传函数
|
||||
const customRequest: UploadProps['customRequest'] = async (options) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const uploadFile = file as RcFile;
|
||||
const response = await storageService.uploadFile({
|
||||
file: uploadFile,
|
||||
folder: 'broker',
|
||||
});
|
||||
|
||||
// 更新表单字段
|
||||
form.setFieldValue('brokerImage', response.url);
|
||||
|
||||
// 更新文件列表
|
||||
setFileList([
|
||||
{
|
||||
uid: response.path,
|
||||
name: response.filename,
|
||||
status: 'done',
|
||||
url: response.url,
|
||||
},
|
||||
]);
|
||||
|
||||
message.success('图片上传成功');
|
||||
onSuccess?.(response);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '图片上传失败');
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传配置
|
||||
const uploadProps: UploadProps = {
|
||||
name: 'file',
|
||||
listType: 'picture-card',
|
||||
maxCount: 1,
|
||||
fileList,
|
||||
accept: 'image/*',
|
||||
customRequest,
|
||||
beforeUpload: (file) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||
if (!isLt5M) {
|
||||
message.error('图片大小不能超过 5MB!');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onRemove: () => {
|
||||
setFileList([]);
|
||||
form.setFieldValue('brokerImage', '');
|
||||
return true;
|
||||
},
|
||||
onChange: ({ fileList: newFileList }) => {
|
||||
setFileList(newFileList);
|
||||
},
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
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: 500, message: '图片地址不能超过500个字符' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="图片URL(上传后自动填充)"
|
||||
allowClear
|
||||
readOnly
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上传Logo">
|
||||
<Upload {...uploadProps}>
|
||||
{fileList.length >= 1 ? null : (
|
||||
<div>
|
||||
{uploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||
<div style={{ marginTop: 8 }}>上传</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
||||
支持 JPG、PNG、GIF、WebP 格式,大小不超过 5MB
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrokerFormModal;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './BrokerPage';
|
||||
@@ -0,0 +1,28 @@
|
||||
.stock-daily-price-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stock-daily-price-search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stock-daily-price-search-form .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.stock-daily-price-page .ant-table {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stock-daily-price-page .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.stock-daily-price-search-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Card,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
App as AntdApp,
|
||||
Tag,
|
||||
DatePicker,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { stockDailyPriceService } from '@/services/stock-daily-price';
|
||||
import type { StockDailyPrice, QueryStockDailyPriceRequest } from '@/types/stock-daily-price';
|
||||
import { MARKET_OPTIONS, getMarketText } from '@/types/stock-daily-price';
|
||||
import dayjs from 'dayjs';
|
||||
import './StockDailyPricePage.css';
|
||||
|
||||
const { Option } = Select;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const StockDailyPricePage = () => {
|
||||
const { message: messageApi } = AntdApp.useApp();
|
||||
const [prices, setPrices] = useState<StockDailyPrice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
const formRef = useRef<QueryStockDailyPriceRequest>({});
|
||||
|
||||
// 初始化:默认查询最近7天
|
||||
useEffect(() => {
|
||||
const endDate = dayjs();
|
||||
const startDate = endDate.subtract(6, 'day'); // 最近7天(包含今天)
|
||||
form.setFieldsValue({
|
||||
dateRange: [startDate, endDate],
|
||||
});
|
||||
formRef.current = {
|
||||
startDate: startDate.format('YYYY-MM-DD'),
|
||||
endDate: endDate.format('YYYY-MM-DD'),
|
||||
};
|
||||
loadData(formRef.current, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (params?: QueryStockDailyPriceRequest, resetPage = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentPage = resetPage ? 1 : pagination.current;
|
||||
const pageSize = pagination.pageSize;
|
||||
|
||||
const queryParams: QueryStockDailyPriceRequest = {
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
sortBy: 'tradeDate',
|
||||
sortOrder: 'DESC',
|
||||
...formRef.current,
|
||||
...params,
|
||||
};
|
||||
|
||||
const response = await stockDailyPriceService.getStockDailyPriceList(queryParams);
|
||||
|
||||
setPrices(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(() => {
|
||||
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();
|
||||
const queryParams: QueryStockDailyPriceRequest = {
|
||||
stockCode: values.stockCode || undefined,
|
||||
stockName: values.stockName || undefined,
|
||||
market: values.market || undefined,
|
||||
};
|
||||
|
||||
// 处理日期范围
|
||||
if (values.dateRange && values.dateRange.length === 2) {
|
||||
queryParams.startDate = values.dateRange[0].format('YYYY-MM-DD');
|
||||
queryParams.endDate = values.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
formRef.current = queryParams;
|
||||
loadData(queryParams, true);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
// 重置为默认的最近7天
|
||||
const endDate = dayjs();
|
||||
const startDate = endDate.subtract(6, 'day');
|
||||
form.setFieldsValue({
|
||||
dateRange: [startDate, endDate],
|
||||
});
|
||||
formRef.current = {
|
||||
startDate: startDate.format('YYYY-MM-DD'),
|
||||
endDate: endDate.format('YYYY-MM-DD'),
|
||||
};
|
||||
loadData(formRef.current, true);
|
||||
};
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price?: number) => {
|
||||
if (price === null || price === undefined) return '-';
|
||||
return price.toFixed(2);
|
||||
};
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount?: number) => {
|
||||
if (amount === null || amount === undefined) return '-';
|
||||
if (amount >= 100000000) {
|
||||
return `${(amount / 100000000).toFixed(2)}亿`;
|
||||
}
|
||||
if (amount >= 10000) {
|
||||
return `${(amount / 10000).toFixed(2)}万`;
|
||||
}
|
||||
return amount.toFixed(2);
|
||||
};
|
||||
|
||||
// 格式化成交量
|
||||
const formatVolume = (volume?: number) => {
|
||||
if (volume === null || volume === undefined) return '-';
|
||||
if (volume >= 10000) {
|
||||
return `${(volume / 10000).toFixed(2)}万手`;
|
||||
}
|
||||
return `${volume}手`;
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<StockDailyPrice> = [
|
||||
{
|
||||
title: '股票代码',
|
||||
dataIndex: 'stockCode',
|
||||
key: 'stockCode',
|
||||
width: 120,
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
title: '股票名称',
|
||||
dataIndex: 'stockName',
|
||||
key: 'stockName',
|
||||
width: 150,
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
dataIndex: 'market',
|
||||
key: 'market',
|
||||
width: 100,
|
||||
render: (market: string) => <Tag color="blue">{getMarketText(market)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '交易日期',
|
||||
dataIndex: 'tradeDate',
|
||||
key: 'tradeDate',
|
||||
width: 120,
|
||||
render: (date: Date) => dayjs(date).format('YYYY-MM-DD'),
|
||||
},
|
||||
{
|
||||
title: '开盘价',
|
||||
dataIndex: 'openPrice',
|
||||
key: 'openPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatPrice,
|
||||
},
|
||||
{
|
||||
title: '收盘价',
|
||||
dataIndex: 'closePrice',
|
||||
key: 'closePrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatPrice,
|
||||
},
|
||||
{
|
||||
title: '最高价',
|
||||
dataIndex: 'highPrice',
|
||||
key: 'highPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatPrice,
|
||||
},
|
||||
{
|
||||
title: '最低价',
|
||||
dataIndex: 'lowPrice',
|
||||
key: 'lowPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatPrice,
|
||||
},
|
||||
{
|
||||
title: '涨跌额',
|
||||
dataIndex: 'changeAmount',
|
||||
key: 'changeAmount',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (amount?: number) => {
|
||||
if (amount === null || amount === undefined) return '-';
|
||||
const color = amount >= 0 ? '#ff4d4f' : '#52c41a';
|
||||
return <span style={{ color }}>{formatPrice(amount)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '涨跌幅',
|
||||
dataIndex: 'changePercent',
|
||||
key: 'changePercent',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (percent?: number) => {
|
||||
if (percent === null || percent === undefined) return '-';
|
||||
const color = percent >= 0 ? '#ff4d4f' : '#52c41a';
|
||||
return (
|
||||
<span style={{ color }}>
|
||||
{percent >= 0 ? '+' : ''}
|
||||
{percent.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成交量',
|
||||
dataIndex: 'volume',
|
||||
key: 'volume',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: formatVolume,
|
||||
},
|
||||
{
|
||||
title: '成交额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: formatAmount,
|
||||
},
|
||||
{
|
||||
title: '换手率',
|
||||
dataIndex: 'turnoverRate',
|
||||
key: 'turnoverRate',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (rate?: number) => {
|
||||
if (rate === null || rate === undefined) return '-';
|
||||
return `${rate.toFixed(2)}%`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '市盈率',
|
||||
dataIndex: 'peRatio',
|
||||
key: 'peRatio',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatPrice,
|
||||
},
|
||||
{
|
||||
title: '市净率',
|
||||
dataIndex: 'pbRatio',
|
||||
key: 'pbRatio',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatPrice,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="stock-daily-price-page">
|
||||
<Card>
|
||||
{/* 查询表单 */}
|
||||
<Form form={form} layout="inline" className="stock-daily-price-search-form">
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="stockCode" label="股票代码">
|
||||
<Input
|
||||
placeholder="请输入股票代码"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="stockName" label="股票名称">
|
||||
<Input
|
||||
placeholder="请输入股票名称"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="market" label="市场">
|
||||
<Select
|
||||
placeholder="请选择市场"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{MARKET_OPTIONS.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="dateRange" label="日期范围" layout="horizontal">
|
||||
<RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||
<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={prices}
|
||||
rowKey="id"
|
||||
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: 1600 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockDailyPricePage;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './StockDailyPricePage';
|
||||
@@ -0,0 +1,28 @@
|
||||
.stock-info-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stock-info-search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stock-info-search-form .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.stock-info-page .ant-table {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stock-info-page .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.stock-info-search-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Card,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
App as AntdApp,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { stockInfoService } from '@/services/stock-info';
|
||||
import type { StockInfo, QueryStockInfoRequest } from '@/types/stock-info';
|
||||
import { MARKET_OPTIONS, getMarketText, STATUS_OPTIONS, getStatusText } from '@/types/stock-info';
|
||||
import dayjs from 'dayjs';
|
||||
import './StockInfoPage.css';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const StockInfoPage = () => {
|
||||
const { message: messageApi } = AntdApp.useApp();
|
||||
const [stocks, setStocks] = useState<StockInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
const formRef = useRef<QueryStockInfoRequest>({});
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (params?: QueryStockInfoRequest, resetPage = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentPage = resetPage ? 1 : pagination.current;
|
||||
const pageSize = pagination.pageSize;
|
||||
|
||||
const queryParams: QueryStockInfoRequest = {
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
...formRef.current,
|
||||
...params,
|
||||
};
|
||||
|
||||
const response = await stockInfoService.getStockInfoList(queryParams);
|
||||
|
||||
setStocks(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 = {
|
||||
stockCode: values.stockCode || undefined,
|
||||
stockName: values.stockName || undefined,
|
||||
market: values.market || undefined,
|
||||
};
|
||||
loadData(formRef.current, true);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
formRef.current = {};
|
||||
loadData({}, true);
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<StockInfo> = [
|
||||
{
|
||||
title: '股票代码',
|
||||
dataIndex: 'stockCode',
|
||||
key: 'stockCode',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '股票名称',
|
||||
dataIndex: 'stockName',
|
||||
key: 'stockName',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
dataIndex: 'market',
|
||||
key: 'market',
|
||||
width: 100,
|
||||
render: (market: string) => <Tag color="blue">{getMarketText(market)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '公司全称',
|
||||
dataIndex: 'fullName',
|
||||
key: 'fullName',
|
||||
width: 300,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '所属行业',
|
||||
dataIndex: 'industry',
|
||||
key: 'industry',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '上市日期',
|
||||
dataIndex: 'listingDate',
|
||||
key: 'listingDate',
|
||||
width: 120,
|
||||
render: (date: Date) => (date ? dayjs(date).format('YYYY-MM-DD') : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
active: 'success',
|
||||
suspended: 'warning',
|
||||
delisted: 'error',
|
||||
};
|
||||
return <Tag color={colorMap[status] || 'default'}>{getStatusText(status)}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (date: Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="stock-info-page">
|
||||
<Card>
|
||||
{/* 查询表单 */}
|
||||
<Form form={form} layout="inline" className="stock-info-search-form">
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="stockCode" label="股票代码">
|
||||
<Input
|
||||
placeholder="请输入股票代码"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="stockName" label="股票名称">
|
||||
<Input
|
||||
placeholder="请输入股票名称"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="market" label="市场">
|
||||
<Select
|
||||
placeholder="请选择市场"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{MARKET_OPTIONS.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item>
|
||||
<Space style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={stocks}
|
||||
rowKey="id"
|
||||
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: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockInfoPage;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './StockInfoPage';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,15 @@
|
||||
.user-info-content {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-field-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5715;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Tabs,
|
||||
Card,
|
||||
Avatar,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Upload,
|
||||
Image,
|
||||
Space,
|
||||
App as AntdApp,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import { UserOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { authService } from '@/services/auth';
|
||||
import { userService } from '@/services/user';
|
||||
import { storageService } from '@/services/storage';
|
||||
import type { UserInfo } from '@/types/user';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
import dayjs from 'dayjs';
|
||||
import './UserInfoPage.css';
|
||||
|
||||
/**
|
||||
* 计算使用天数
|
||||
* @param createdAt 创建时间
|
||||
* @returns 使用天数字符串,如 "365天" 或 "1年30天"
|
||||
*/
|
||||
const calculateUsageDays = (createdAt: Date | string): string => {
|
||||
const created = dayjs(createdAt);
|
||||
const now = dayjs();
|
||||
const days = now.diff(created, 'day');
|
||||
|
||||
if (days < 365) {
|
||||
return `${days}天`;
|
||||
}
|
||||
|
||||
const years = Math.floor(days / 365);
|
||||
const remainingDays = days % 365;
|
||||
return `${years}年${remainingDays}天`;
|
||||
};
|
||||
|
||||
const UserInfoPage = () => {
|
||||
const { message: messageApi } = AntdApp.useApp();
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [avatarFileList, setAvatarFileList] = useState<UploadFile[]>([]);
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [infoForm] = Form.useForm();
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 加载用户信息
|
||||
const loadUserInfo = async () => {
|
||||
// 防止重复请求
|
||||
if (isLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = authService.getUser();
|
||||
if (!currentUser) {
|
||||
messageApi.error('未找到用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const userData = await userService.getUserById(currentUser.userId);
|
||||
console.log('userData:', userData);
|
||||
setUser(userData);
|
||||
infoForm.setFieldsValue({
|
||||
nickname: userData.nickname || '',
|
||||
phone: userData.phone || '',
|
||||
email: userData.email || '',
|
||||
});
|
||||
// 设置头像文件列表
|
||||
if (userData.avatarUrl) {
|
||||
setAvatarFileList([
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'avatar',
|
||||
status: 'done',
|
||||
url: userData.avatarUrl,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '加载用户信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUserInfo();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 上传头像
|
||||
const handleAvatarUpload = async (
|
||||
file: RcFile,
|
||||
onSuccess?: (response: any) => void,
|
||||
onError?: (error: any) => void
|
||||
) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const response = await storageService.uploadAvatar(file);
|
||||
// 更新用户头像
|
||||
if (user) {
|
||||
await userService.updateUser(user.userId, {
|
||||
avatarUrl: response.url,
|
||||
});
|
||||
messageApi.success('头像上传成功');
|
||||
await loadUserInfo();
|
||||
onSuccess?.(response);
|
||||
}
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '头像上传失败');
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 头像上传配置
|
||||
const avatarUploadProps = {
|
||||
name: 'file',
|
||||
listType: 'picture' as const,
|
||||
maxCount: 1,
|
||||
fileList: avatarFileList,
|
||||
accept: 'image/*',
|
||||
customRequest: async (options: any) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
await handleAvatarUpload(file as RcFile, onSuccess, onError);
|
||||
},
|
||||
beforeUpload: (file: File) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
messageApi.error('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
messageApi.error('图片大小不能超过 2MB!');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onRemove: () => {
|
||||
setAvatarFileList([]);
|
||||
},
|
||||
onChange: ({ fileList: newFileList }: { fileList: UploadFile[] }) => {
|
||||
setAvatarFileList(newFileList);
|
||||
},
|
||||
};
|
||||
|
||||
// 进入编辑模式
|
||||
const handleEdit = () => {
|
||||
if (user) {
|
||||
infoForm.setFieldsValue({
|
||||
nickname: user.nickname || '',
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
});
|
||||
setEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEdit = () => {
|
||||
setEditing(false);
|
||||
infoForm.resetFields();
|
||||
};
|
||||
|
||||
// 处理更新按钮点击(先验证表单,再显示确认框)
|
||||
const handleUpdateClick = async () => {
|
||||
try {
|
||||
// 先验证表单
|
||||
const values = await infoForm.validateFields();
|
||||
if (!user) return;
|
||||
|
||||
// 验证通过,显示确认框
|
||||
Modal.confirm({
|
||||
title: '确认更新',
|
||||
content: '确定要更新个人信息吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userService.updateUser(user.userId, {
|
||||
nickname: values.nickname || undefined,
|
||||
phone: values.phone || undefined,
|
||||
email: values.email || undefined,
|
||||
});
|
||||
|
||||
messageApi.success('个人信息更新成功');
|
||||
setEditing(false);
|
||||
await loadUserInfo();
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '更新失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// 验证失败,不显示确认框
|
||||
if (error.errorFields) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 修改密码
|
||||
const handleChangePassword = async () => {
|
||||
try {
|
||||
const values = await passwordForm.validateFields();
|
||||
if (!user) return;
|
||||
|
||||
await userService.changePassword(user.userId, {
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
|
||||
messageApi.success('密码修改成功');
|
||||
passwordForm.resetFields();
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
return;
|
||||
}
|
||||
messageApi.error(error.message || '密码修改失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <Card loading={loading}>加载中...</Card>;
|
||||
}
|
||||
|
||||
// Tab配置
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{
|
||||
key: 'info',
|
||||
label: '个人信息',
|
||||
children: (
|
||||
<div className="user-info-content">
|
||||
<Form
|
||||
form={infoForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 18 }}
|
||||
style={{ maxWidth: 600 }}
|
||||
>
|
||||
{/* 头像 */}
|
||||
<Form.Item wrapperCol={{ span: 18, offset: 6 }}>
|
||||
<div className="avatar-wrapper">
|
||||
{user.avatarUrl ? (
|
||||
<Image
|
||||
src={user.avatarUrl}
|
||||
alt="头像"
|
||||
width={100}
|
||||
height={100}
|
||||
style={{ borderRadius: 8 }}
|
||||
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f0f0f0'/%3E%3Cpath d='M50 33c9.4 0 17 7.6 17 17s-7.6 17-17 17-17-7.6-17-17 7.6-17 17-17zm0 40c11 0 33.5 5.5 33.5 16.5v8H16.5v-8C16.5 78.5 39 73 50 73z' fill='%23999'/%3E%3C/svg%3E"
|
||||
/>
|
||||
) : (
|
||||
<Avatar size={100} icon={<UserOutlined />} />
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<Upload {...avatarUploadProps}>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploading}
|
||||
size="small"
|
||||
>
|
||||
上传头像
|
||||
</Button>
|
||||
</Upload>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: 14, color: 'rgba(0, 0, 0, 0.65)' }}
|
||||
>
|
||||
头像
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
{/* 昵称 */}
|
||||
<Form.Item
|
||||
label="昵称"
|
||||
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
|
||||
>
|
||||
{editing ? (
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
noStyle
|
||||
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
|
||||
>
|
||||
<Input placeholder="请输入昵称" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span className="info-field-text">{user.nickname || '-'}</span>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{/* 用户名(不可编辑) */}
|
||||
<Form.Item label="用户名">
|
||||
<span className="info-field-text">{user.username}</span>
|
||||
</Form.Item>
|
||||
|
||||
{/* 电话 */}
|
||||
<Form.Item
|
||||
label="电话"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^[0-9+\-() ]+$/,
|
||||
message: '电话号码格式不正确',
|
||||
},
|
||||
{ max: 20, message: '电话不能超过20个字符' },
|
||||
]}
|
||||
>
|
||||
{editing ? (
|
||||
<Form.Item
|
||||
name="phone"
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
pattern: /^[0-9+\-() ]+$/,
|
||||
message: '电话号码格式不正确',
|
||||
},
|
||||
{ max: 20, message: '电话不能超过20个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入电话" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span className="info-field-text">{user.phone || '-'}</span>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{/* 邮箱 */}
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
{ max: 100, message: '邮箱不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
{editing ? (
|
||||
<Form.Item
|
||||
name="email"
|
||||
noStyle
|
||||
rules={[
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
{ max: 100, message: '邮箱不能超过100个字符' },
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span className="info-field-text">{user.email}</span>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{/* 注册时间(不可编辑) */}
|
||||
<Form.Item label="注册时间">
|
||||
<span className="info-field-text">
|
||||
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
|
||||
{/* 使用天数(不可编辑) */}
|
||||
<Form.Item label="使用天数">
|
||||
<span className="info-field-text">
|
||||
{calculateUsageDays(user.createdAt)}
|
||||
</span>
|
||||
</Form.Item>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Form.Item wrapperCol={{ offset: 6, span: 18 }} style={{ marginTop: 24 }}>
|
||||
{editing ? (
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleUpdateClick}>
|
||||
更新个人信息
|
||||
</Button>
|
||||
<Button onClick={handleCancelEdit}>取消</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Button type="primary" onClick={handleEdit}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: '修改密码',
|
||||
children: (
|
||||
<Form
|
||||
form={passwordForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 18 }}
|
||||
style={{ maxWidth: 600 }}
|
||||
onFinish={handleChangePassword}
|
||||
>
|
||||
<Form.Item
|
||||
name="oldPassword"
|
||||
label="原密码"
|
||||
rules={[{ required: true, message: '请输入原密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入原密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度至少6位' },
|
||||
{ max: 100, message: '密码长度不能超过100位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认密码"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请再次输入新密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
修改密码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="user-info-page">
|
||||
<Card>
|
||||
<Tabs defaultActiveKey="info" items={tabItems} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfoPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
.user-page .user-search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-page .user-search-form .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user