feat: 开发持仓、股票信息相关接口
This commit is contained in:
@@ -6,6 +6,9 @@ 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,6 +25,9 @@ import { StorageModule } from './modules/storage/storage.module';
|
||||
UserModule,
|
||||
AuthModule,
|
||||
StorageModule,
|
||||
StockInfoModule,
|
||||
StockDailyPriceModule,
|
||||
PositionModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
||||
133
apps/api/src/modules/position/dto/create-position.dto.ts
Normal file
133
apps/api/src/modules/position/dto/create-position.dto.ts
Normal file
@@ -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 {
|
||||
@ApiProperty({
|
||||
description: '券商ID',
|
||||
example: 1,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
brokerId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产类型',
|
||||
example: 'stock',
|
||||
enum: ['stock', 'fund', 'cash', 'bond', 'other'],
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['stock', 'fund', 'cash', 'bond', 'other'])
|
||||
assetType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产代码(股票代码、基金代码等)',
|
||||
example: '600519',
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@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;
|
||||
}
|
||||
40
apps/api/src/modules/position/dto/position-response.dto.ts
Normal file
40
apps/api/src/modules/position/dto/position-response.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
73
apps/api/src/modules/position/dto/update-position.dto.ts
Normal file
73
apps/api/src/modules/position/dto/update-position.dto.ts
Normal file
@@ -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 字段不允许更新
|
||||
}
|
||||
124
apps/api/src/modules/position/position.controller.ts
Normal file
124
apps/api/src/modules/position/position.controller.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
} 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 { 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()
|
||||
@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);
|
||||
}
|
||||
}
|
||||
179
apps/api/src/modules/position/position.entity.ts
Normal file
179
apps/api/src/modules/position/position.entity.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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' })
|
||||
@Index()
|
||||
userId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '券商ID',
|
||||
example: 1,
|
||||
})
|
||||
@Column({ name: 'broker_id', type: 'bigint' })
|
||||
@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,
|
||||
})
|
||||
shares: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '成本价(每股/每份)',
|
||||
example: 1600.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'cost_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
})
|
||||
costPrice: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最新市场价(系统自动更新)',
|
||||
example: 1850.0,
|
||||
})
|
||||
@Column({
|
||||
name: 'current_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
})
|
||||
currentPrice?: 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,
|
||||
})
|
||||
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;
|
||||
}
|
||||
13
apps/api/src/modules/position/position.module.ts
Normal file
13
apps/api/src/modules/position/position.module.ts
Normal file
@@ -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 {}
|
||||
180
apps/api/src/modules/position/position.service.ts
Normal file
180
apps/api/src/modules/position/position.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class PositionService {
|
||||
private readonly logger = new Logger(PositionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Position)
|
||||
private readonly positionRepository: Repository<Position>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询用户所有持仓(包含计算字段)
|
||||
*/
|
||||
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 =
|
||||
Number(position.shares) * Number(position.costPrice);
|
||||
const marketValue = position.currentPrice
|
||||
? Number(position.shares) * Number(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 =
|
||||
Number(position.shares) * Number(position.currentPrice);
|
||||
totalAsset += marketValue;
|
||||
}
|
||||
}
|
||||
|
||||
return totalAsset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建持仓
|
||||
*/
|
||||
async create(
|
||||
userId: number,
|
||||
createPositionDto: CreatePositionDto,
|
||||
): Promise<Position> {
|
||||
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
|
||||
const existing = await this.positionRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
brokerId: createPositionDto.brokerId,
|
||||
symbol: createPositionDto.symbol,
|
||||
market: createPositionDto.market || null,
|
||||
assetType: createPositionDto.assetType,
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
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[];
|
||||
}
|
||||
80
apps/api/src/modules/stock-info/dto/create-stock-info.dto.ts
Normal file
80
apps/api/src/modules/stock-info/dto/create-stock-info.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
72
apps/api/src/modules/stock-info/dto/query-stock-info.dto.ts
Normal file
72
apps/api/src/modules/stock-info/dto/query-stock-info.dto.ts
Normal file
@@ -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) {}
|
||||
260
apps/api/src/modules/stock-info/stock-info.controller.ts
Normal file
260
apps/api/src/modules/stock-info/stock-info.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
97
apps/api/src/modules/stock-info/stock-info.entity.ts
Normal file
97
apps/api/src/modules/stock-info/stock-info.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
13
apps/api/src/modules/stock-info/stock-info.module.ts
Normal file
13
apps/api/src/modules/stock-info/stock-info.module.ts
Normal file
@@ -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 {}
|
||||
367
apps/api/src/modules/stock-info/stock-info.service.ts
Normal file
367
apps/api/src/modules/stock-info/stock-info.service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,7 @@ export class StorageController {
|
||||
/**
|
||||
* 删除文件(需要管理员权限)
|
||||
*/
|
||||
@Delete(':path(*)')
|
||||
@Delete('*path')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@@ -239,6 +239,10 @@ export class StorageController {
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
async delete(@Param('path') filePath: string) {
|
||||
await this.storageService.delete(filePath);
|
||||
// 移除路径开头的 /,如果有的话
|
||||
const cleanPath = filePath.startsWith('/')
|
||||
? filePath.substring(1)
|
||||
: filePath;
|
||||
await this.storageService.delete(cleanPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export class UserSeeder implements OnModuleInit {
|
||||
nickname: userData.nickname,
|
||||
role: userData.role,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -239,13 +239,13 @@ export class UserSeeder implements OnModuleInit {
|
||||
|
||||
return this.userRepository.create({
|
||||
username: mockUser.username,
|
||||
passwordHash,
|
||||
passwordHash,
|
||||
email: mockUser.email,
|
||||
nickname: mockUser.nickname,
|
||||
phone: mockUser.phone,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
});
|
||||
status: 'active',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -193,11 +193,18 @@ export class UserService {
|
||||
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,
|
||||
list: listWithoutPassword,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
@@ -247,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,7 @@ const MainLayout = () => {
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人资料',
|
||||
disabled: true, // 暂时禁用
|
||||
onClick: () => navigate('/user-info'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
DashboardOutlined,
|
||||
BankOutlined,
|
||||
UserOutlined,
|
||||
StockOutlined,
|
||||
LineChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -62,6 +64,15 @@ export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
subtitle: '回顾过去是为了更好应对将来',
|
||||
group: 'main',
|
||||
},
|
||||
{
|
||||
path: '/user-info',
|
||||
key: '/user-info',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人资料',
|
||||
title: '个人资料',
|
||||
subtitle: '查看和编辑个人信息',
|
||||
group: 'main',
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
key: '/user',
|
||||
@@ -82,6 +93,26 @@ export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
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',
|
||||
|
||||
28
apps/web/src/pages/stock-daily-price/StockDailyPricePage.css
Normal file
28
apps/web/src/pages/stock-daily-price/StockDailyPricePage.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
384
apps/web/src/pages/stock-daily-price/StockDailyPricePage.tsx
Normal file
384
apps/web/src/pages/stock-daily-price/StockDailyPricePage.tsx
Normal file
@@ -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;
|
||||
1
apps/web/src/pages/stock-daily-price/index.ts
Normal file
1
apps/web/src/pages/stock-daily-price/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './StockDailyPricePage';
|
||||
28
apps/web/src/pages/stock-info/StockInfoPage.css
Normal file
28
apps/web/src/pages/stock-info/StockInfoPage.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
250
apps/web/src/pages/stock-info/StockInfoPage.tsx
Normal file
250
apps/web/src/pages/stock-info/StockInfoPage.tsx
Normal file
@@ -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;
|
||||
1
apps/web/src/pages/stock-info/index.ts
Normal file
1
apps/web/src/pages/stock-info/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './StockInfoPage';
|
||||
15
apps/web/src/pages/user/UserInfoPage.css
Normal file
15
apps/web/src/pages/user/UserInfoPage.css
Normal file
@@ -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);
|
||||
}
|
||||
479
apps/web/src/pages/user/UserInfoPage.tsx
Normal file
479
apps/web/src/pages/user/UserInfoPage.tsx
Normal file
@@ -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;
|
||||
@@ -1 +1,2 @@
|
||||
export { default } from './UserPage';
|
||||
export { default as UserPage } from './UserPage';
|
||||
export { default as UserInfoPage } from './UserInfoPage';
|
||||
|
||||
@@ -8,7 +8,10 @@ const AssetsPage = lazy(() => import('../pages/AssetsPage'));
|
||||
const PlansPage = lazy(() => import('../pages/PlansPage'));
|
||||
const ReviewPage = lazy(() => import('../pages/ReviewPage'));
|
||||
const BrokerPage = lazy(() => import('../pages/broker'));
|
||||
const UserPage = lazy(() => import('@/pages/user'));
|
||||
const UserPage = lazy(() => import('@/pages/user/UserPage'));
|
||||
const UserInfoPage = lazy(() => import('@/pages/user/UserInfoPage'));
|
||||
const StockInfoPage = lazy(() => import('@/pages/stock-info'));
|
||||
const StockDailyPricePage = lazy(() => import('@/pages/stock-daily-price'));
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -49,6 +52,18 @@ export const router = createBrowserRouter([
|
||||
path: 'user',
|
||||
element: <UserPage />,
|
||||
},
|
||||
{
|
||||
path: 'user-info',
|
||||
element: <UserInfoPage />,
|
||||
},
|
||||
{
|
||||
path: 'stock-info',
|
||||
element: <StockInfoPage />,
|
||||
},
|
||||
{
|
||||
path: 'stock-daily-price',
|
||||
element: <StockDailyPricePage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
37
apps/web/src/services/stock-daily-price.ts
Normal file
37
apps/web/src/services/stock-daily-price.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { api } from './api';
|
||||
import type {
|
||||
StockDailyPrice,
|
||||
QueryStockDailyPriceRequest,
|
||||
PaginatedStockDailyPriceResponse,
|
||||
ApiResponse,
|
||||
} from '@/types/stock-daily-price';
|
||||
|
||||
/**
|
||||
* 股票每日价格服务
|
||||
*/
|
||||
class StockDailyPriceService {
|
||||
/**
|
||||
* 查询股票每日价格列表(分页)
|
||||
*/
|
||||
async getStockDailyPriceList(
|
||||
params: QueryStockDailyPriceRequest
|
||||
): Promise<PaginatedStockDailyPriceResponse> {
|
||||
const response = await api.get<ApiResponse<PaginatedStockDailyPriceResponse>>(
|
||||
'/stock-daily-price',
|
||||
{ params }
|
||||
);
|
||||
if ('list' in response && 'pagination' in response) {
|
||||
return response as PaginatedStockDailyPriceResponse;
|
||||
}
|
||||
return (response as ApiResponse<PaginatedStockDailyPriceResponse>).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查询股票每日价格
|
||||
*/
|
||||
async getStockDailyPriceById(id: number): Promise<StockDailyPrice> {
|
||||
return await api.get<StockDailyPrice>(`/stock-daily-price/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const stockDailyPriceService = new StockDailyPriceService();
|
||||
34
apps/web/src/services/stock-info.ts
Normal file
34
apps/web/src/services/stock-info.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { api } from './api';
|
||||
import type {
|
||||
StockInfo,
|
||||
QueryStockInfoRequest,
|
||||
PaginatedStockInfoResponse,
|
||||
ApiResponse,
|
||||
} from '@/types/stock-info';
|
||||
|
||||
/**
|
||||
* 股票信息服务
|
||||
*/
|
||||
class StockInfoService {
|
||||
/**
|
||||
* 查询股票信息列表(分页)
|
||||
*/
|
||||
async getStockInfoList(params: QueryStockInfoRequest): Promise<PaginatedStockInfoResponse> {
|
||||
const response = await api.get<ApiResponse<PaginatedStockInfoResponse>>('/stock-info', {
|
||||
params,
|
||||
});
|
||||
if ('list' in response && 'pagination' in response) {
|
||||
return response as PaginatedStockInfoResponse;
|
||||
}
|
||||
return (response as ApiResponse<PaginatedStockInfoResponse>).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查询股票信息
|
||||
*/
|
||||
async getStockInfoById(id: number): Promise<StockInfo> {
|
||||
return await api.get<StockInfo>(`/stock-info/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const stockInfoService = new StockInfoService();
|
||||
@@ -25,7 +25,11 @@ class UserService {
|
||||
* 根据ID查询用户
|
||||
*/
|
||||
async getUserById(id: number): Promise<User> {
|
||||
return await api.get<User>(`/user/${id}`);
|
||||
const response = await api.get<ApiResponse<User>>(`/user/${id}`);
|
||||
if (response.code === 0 && response.data) {
|
||||
return response.data as User;
|
||||
}
|
||||
throw new Error(response.message || '获取用户信息失败');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +45,31 @@ class UserService {
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
await api.delete(`/user/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
async updateUser(
|
||||
id: number,
|
||||
data: {
|
||||
nickname?: string;
|
||||
phone?: string;
|
||||
avatarUrl?: string;
|
||||
email?: string;
|
||||
}
|
||||
): Promise<User> {
|
||||
return await api.patch<User>(`/user/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
async changePassword(
|
||||
id: number,
|
||||
data: { oldPassword: string; newPassword: string }
|
||||
): Promise<void> {
|
||||
await api.patch(`/user/${id}/password`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
|
||||
79
apps/web/src/types/stock-daily-price.ts
Normal file
79
apps/web/src/types/stock-daily-price.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 股票每日价格接口
|
||||
*/
|
||||
export interface StockDailyPrice {
|
||||
id: number;
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
market: string;
|
||||
tradeDate: Date;
|
||||
openPrice?: number;
|
||||
closePrice: number;
|
||||
highPrice?: number;
|
||||
lowPrice?: number;
|
||||
volume?: number;
|
||||
amount?: number;
|
||||
changeAmount?: number;
|
||||
changePercent?: number;
|
||||
turnoverRate?: number;
|
||||
peRatio?: number;
|
||||
pbRatio?: number;
|
||||
marketCap?: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询股票每日价格请求参数
|
||||
*/
|
||||
export interface QueryStockDailyPriceRequest {
|
||||
stockCode?: string;
|
||||
stockName?: string;
|
||||
market?: string;
|
||||
tradeDate?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应数据
|
||||
*/
|
||||
export interface PaginatedStockDailyPriceResponse {
|
||||
list: StockDailyPrice[];
|
||||
pagination: {
|
||||
total: number;
|
||||
total_page: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 响应格式
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场选项
|
||||
*/
|
||||
export const MARKET_OPTIONS = [
|
||||
{ label: '上海', value: 'sh' },
|
||||
{ label: '深圳', value: 'sz' },
|
||||
{ label: '北京', value: 'bj' },
|
||||
{ label: '香港', value: 'hk' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 获取市场显示文本
|
||||
*/
|
||||
export const getMarketText = (market: string): string => {
|
||||
const option = MARKET_OPTIONS.find((opt) => opt.value === market);
|
||||
return option ? option.label : market;
|
||||
};
|
||||
85
apps/web/src/types/stock-info.ts
Normal file
85
apps/web/src/types/stock-info.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 股票基本信息接口
|
||||
*/
|
||||
export interface StockInfo {
|
||||
id: number;
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
market: string;
|
||||
fullName?: string;
|
||||
industry?: string;
|
||||
listingDate?: Date;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询股票信息请求参数
|
||||
*/
|
||||
export interface QueryStockInfoRequest {
|
||||
stockCode?: string;
|
||||
stockName?: string;
|
||||
market?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应数据
|
||||
*/
|
||||
export interface PaginatedStockInfoResponse {
|
||||
list: StockInfo[];
|
||||
pagination: {
|
||||
total: number;
|
||||
total_page: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 响应格式
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场选项
|
||||
*/
|
||||
export const MARKET_OPTIONS = [
|
||||
{ label: '上海', value: 'sh' },
|
||||
{ label: '深圳', value: 'sz' },
|
||||
{ label: '北京', value: 'bj' },
|
||||
{ label: '香港', value: 'hk' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 状态选项
|
||||
*/
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '停牌', value: 'suspended' },
|
||||
{ label: '退市', value: 'delisted' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 获取市场显示文本
|
||||
*/
|
||||
export const getMarketText = (market: string): string => {
|
||||
const option = MARKET_OPTIONS.find((opt) => opt.value === market);
|
||||
return option ? option.label : market;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态显示文本
|
||||
*/
|
||||
export const getStatusText = (status: string): string => {
|
||||
const option = STATUS_OPTIONS.find((opt) => opt.value === status);
|
||||
return option ? option.label : status;
|
||||
};
|
||||
Reference in New Issue
Block a user