feat: 开发我的持仓列表
This commit is contained in:
@@ -9,6 +9,7 @@ import { StorageModule } from './modules/storage/storage.module';
|
|||||||
import { StockInfoModule } from './modules/stock-info/stock-info.module';
|
import { StockInfoModule } from './modules/stock-info/stock-info.module';
|
||||||
import { StockDailyPriceModule } from './modules/stock-daily-price/stock-daily-price.module';
|
import { StockDailyPriceModule } from './modules/stock-daily-price/stock-daily-price.module';
|
||||||
import { PositionModule } from './modules/position/position.module';
|
import { PositionModule } from './modules/position/position.module';
|
||||||
|
import { PositionChangeModule } from './modules/position-change/position-change.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,6 +29,7 @@ import { PositionModule } from './modules/position/position.module';
|
|||||||
StockInfoModule,
|
StockInfoModule,
|
||||||
StockDailyPriceModule,
|
StockDailyPriceModule,
|
||||||
PositionModule,
|
PositionModule,
|
||||||
|
PositionChangeModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreatePositionChangeDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '持仓ID',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
positionId: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更日期',
|
||||||
|
example: '2024-01-01',
|
||||||
|
})
|
||||||
|
@IsDateString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
changeDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更类型',
|
||||||
|
example: 'buy',
|
||||||
|
enum: ['buy', 'sell', 'auto'],
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(['buy', 'sell', 'auto'])
|
||||||
|
changeType: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更前份额/数量',
|
||||||
|
example: 100,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
beforeShares: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更前成本价',
|
||||||
|
example: 1600.0,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0001)
|
||||||
|
beforeCostPrice: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更后份额/数量',
|
||||||
|
example: 150,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
afterShares: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更后成本价',
|
||||||
|
example: 1650.0,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0001)
|
||||||
|
afterCostPrice: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '备注/思考',
|
||||||
|
example: '加仓买入,看好长期走势',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { PositionChange } from '../position-change.entity';
|
||||||
|
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持仓变更记录分页响应数据
|
||||||
|
*/
|
||||||
|
export class PaginatedPositionChangeData {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '持仓变更记录列表',
|
||||||
|
type: [PositionChange],
|
||||||
|
})
|
||||||
|
list: PositionChange[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { IsOptional, IsNumber, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class QueryPositionChangeDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '页码',
|
||||||
|
example: 1,
|
||||||
|
minimum: 1,
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '每页数量',
|
||||||
|
example: 10,
|
||||||
|
minimum: 1,
|
||||||
|
default: 10,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdatePositionChangeDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '备注/思考',
|
||||||
|
example: '加仓买入,看好长期走势',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiParam,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { PositionChangeService } from './position-change.service';
|
||||||
|
import { CreatePositionChangeDto } from './dto/create-position-change.dto';
|
||||||
|
import { UpdatePositionChangeDto } from './dto/update-position-change.dto';
|
||||||
|
import { QueryPositionChangeDto } from './dto/query-position-change.dto';
|
||||||
|
import { PositionChange } from './position-change.entity';
|
||||||
|
import { PaginatedPositionChangeData } from './dto/paginated-response.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@ApiTags('position-change')
|
||||||
|
@Controller('position-change')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class PositionChangeController {
|
||||||
|
constructor(
|
||||||
|
private readonly positionChangeService: PositionChangeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询单个持仓的所有变更记录
|
||||||
|
*/
|
||||||
|
@Get('position/:positionId')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询单个持仓的所有变更记录',
|
||||||
|
description: '分页查询指定持仓的所有变更记录,按变更日期倒序',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'positionId', description: '持仓ID', type: Number })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: PaginatedPositionChangeData,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 404, description: '持仓不存在' })
|
||||||
|
findAllByPositionId(
|
||||||
|
@Request() req: { user: User },
|
||||||
|
@Param('positionId') positionId: string,
|
||||||
|
@Query() queryDto: QueryPositionChangeDto,
|
||||||
|
): Promise<PaginatedPositionChangeData> {
|
||||||
|
return this.positionChangeService.findAllByPositionId(
|
||||||
|
+positionId,
|
||||||
|
req.user.userId,
|
||||||
|
queryDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建持仓变更记录
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '创建持仓变更记录',
|
||||||
|
description: '为指定持仓创建新的变更记录',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: '创建成功',
|
||||||
|
type: PositionChange,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||||
|
@ApiResponse({ status: 404, description: '持仓不存在' })
|
||||||
|
create(
|
||||||
|
@Request() req: { user: User },
|
||||||
|
@Body() createPositionChangeDto: CreatePositionChangeDto,
|
||||||
|
): Promise<PositionChange> {
|
||||||
|
return this.positionChangeService.create(
|
||||||
|
req.user.userId,
|
||||||
|
createPositionChangeDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新持仓变更记录的备注/思考
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '更新持仓变更记录的备注',
|
||||||
|
description: '更新指定变更记录的备注/思考内容',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '变更记录ID', type: Number })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '更新成功',
|
||||||
|
type: PositionChange,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: '变更记录不存在' })
|
||||||
|
@ApiResponse({ status: 403, description: '无权访问' })
|
||||||
|
update(
|
||||||
|
@Request() req: { user: User },
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() updatePositionChangeDto: UpdatePositionChangeDto,
|
||||||
|
): Promise<PositionChange> {
|
||||||
|
return this.positionChangeService.update(
|
||||||
|
+id,
|
||||||
|
req.user.userId,
|
||||||
|
updatePositionChangeDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
apps/api/src/modules/position-change/position-change.entity.ts
Normal file
105
apps/api/src/modules/position-change/position-change.entity.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
@Entity('position_changes')
|
||||||
|
export class PositionChange {
|
||||||
|
@ApiProperty({ description: '变更记录ID', example: 1 })
|
||||||
|
@PrimaryGeneratedColumn({ name: 'change_id' })
|
||||||
|
changeId: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '持仓ID',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@Column({ name: 'position_id', type: 'bigint' })
|
||||||
|
@Index()
|
||||||
|
positionId: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更日期',
|
||||||
|
example: '2024-01-01',
|
||||||
|
})
|
||||||
|
@Column({ name: 'change_date', type: 'date' })
|
||||||
|
@Index()
|
||||||
|
changeDate: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更类型',
|
||||||
|
example: 'buy',
|
||||||
|
enum: ['buy', 'sell', 'auto'],
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
name: 'change_type',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
})
|
||||||
|
changeType: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更前份额/数量',
|
||||||
|
example: 100,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
name: 'before_shares',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
})
|
||||||
|
beforeShares: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更前成本价',
|
||||||
|
example: 1600.0,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
name: 'before_cost_price',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
})
|
||||||
|
beforeCostPrice: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更后份额/数量',
|
||||||
|
example: 150,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
name: 'after_shares',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
})
|
||||||
|
afterShares: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '变更后成本价',
|
||||||
|
example: 1650.0,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
name: 'after_cost_price',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
})
|
||||||
|
afterCostPrice: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '备注/思考',
|
||||||
|
example: '加仓买入,看好长期走势',
|
||||||
|
})
|
||||||
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '创建时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { PositionChangeService } from './position-change.service';
|
||||||
|
import { PositionChangeController } from './position-change.controller';
|
||||||
|
import { PositionChange } from './position-change.entity';
|
||||||
|
import { Position } from '../position/position.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([PositionChange, Position])],
|
||||||
|
controllers: [PositionChangeController],
|
||||||
|
providers: [PositionChangeService],
|
||||||
|
exports: [PositionChangeService],
|
||||||
|
})
|
||||||
|
export class PositionChangeModule {}
|
||||||
149
apps/api/src/modules/position-change/position-change.service.ts
Normal file
149
apps/api/src/modules/position-change/position-change.service.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { PositionChange } from './position-change.entity';
|
||||||
|
import { CreatePositionChangeDto } from './dto/create-position-change.dto';
|
||||||
|
import { UpdatePositionChangeDto } from './dto/update-position-change.dto';
|
||||||
|
import { QueryPositionChangeDto } from './dto/query-position-change.dto';
|
||||||
|
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||||
|
import { Position } from '../position/position.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PositionChangeService {
|
||||||
|
private readonly logger = new Logger(PositionChangeService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(PositionChange)
|
||||||
|
private readonly positionChangeRepository: Repository<PositionChange>,
|
||||||
|
@InjectRepository(Position)
|
||||||
|
private readonly positionRepository: Repository<Position>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询单个持仓的所有变更记录
|
||||||
|
*/
|
||||||
|
async findAllByPositionId(
|
||||||
|
positionId: number,
|
||||||
|
userId: number,
|
||||||
|
queryDto: QueryPositionChangeDto,
|
||||||
|
): Promise<{
|
||||||
|
list: PositionChange[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}> {
|
||||||
|
// 验证持仓是否属于当前用户
|
||||||
|
const position = await this.positionRepository.findOne({
|
||||||
|
where: { positionId, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new NotFoundException(`持仓不存在:ID ${positionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
|
const page = queryDto.page || 1;
|
||||||
|
const limit = queryDto.limit || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// 查询总数
|
||||||
|
const total = await this.positionChangeRepository.count({
|
||||||
|
where: { positionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询分页数据(按变更日期和创建时间倒序)
|
||||||
|
const list = await this.positionChangeRepository.find({
|
||||||
|
where: { positionId },
|
||||||
|
order: {
|
||||||
|
changeDate: 'DESC',
|
||||||
|
createdAt: 'DESC',
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算总页数
|
||||||
|
const total_page = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
total_page,
|
||||||
|
page_size: limit,
|
||||||
|
current_page: page,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建持仓变更记录
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
userId: number,
|
||||||
|
createPositionChangeDto: CreatePositionChangeDto,
|
||||||
|
): Promise<PositionChange> {
|
||||||
|
// 验证持仓是否属于当前用户
|
||||||
|
const position = await this.positionRepository.findOne({
|
||||||
|
where: {
|
||||||
|
positionId: createPositionChangeDto.positionId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`持仓不存在:ID ${createPositionChangeDto.positionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建变更记录
|
||||||
|
const positionChange = this.positionChangeRepository.create({
|
||||||
|
...createPositionChangeDto,
|
||||||
|
changeDate: new Date(createPositionChangeDto.changeDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.positionChangeRepository.save(positionChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新持仓变更记录的备注/思考
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
changeId: number,
|
||||||
|
userId: number,
|
||||||
|
updatePositionChangeDto: UpdatePositionChangeDto,
|
||||||
|
): Promise<PositionChange> {
|
||||||
|
// 查找变更记录
|
||||||
|
const positionChange = await this.positionChangeRepository.findOne({
|
||||||
|
where: { changeId },
|
||||||
|
relations: [], // 不加载关联,后面手动验证
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!positionChange) {
|
||||||
|
throw new NotFoundException(`变更记录不存在:ID ${changeId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证持仓是否属于当前用户
|
||||||
|
const position = await this.positionRepository.findOne({
|
||||||
|
where: {
|
||||||
|
positionId: positionChange.positionId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new ForbiddenException('无权访问该持仓的变更记录');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只更新备注字段
|
||||||
|
if (updatePositionChangeDto.notes !== undefined) {
|
||||||
|
positionChange.notes = updatePositionChangeDto.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.positionChangeRepository.save(positionChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,14 @@ export class Position {
|
|||||||
description: '用户ID',
|
description: '用户ID',
|
||||||
example: 1,
|
example: 1,
|
||||||
})
|
})
|
||||||
@Column({ name: 'user_id', type: 'bigint' })
|
@Column({
|
||||||
|
name: 'user_id',
|
||||||
|
type: 'bigint',
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => (value ? parseInt(value, 10) : null),
|
||||||
|
},
|
||||||
|
})
|
||||||
@Index()
|
@Index()
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|
||||||
@@ -28,7 +35,14 @@ export class Position {
|
|||||||
description: '券商ID',
|
description: '券商ID',
|
||||||
example: 1,
|
example: 1,
|
||||||
})
|
})
|
||||||
@Column({ name: 'broker_id', type: 'bigint' })
|
@Column({
|
||||||
|
name: 'broker_id',
|
||||||
|
type: 'bigint',
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => (value ? parseInt(value, 10) : null),
|
||||||
|
},
|
||||||
|
})
|
||||||
@Index()
|
@Index()
|
||||||
brokerId: number;
|
brokerId: number;
|
||||||
|
|
||||||
@@ -80,6 +94,10 @@ export class Position {
|
|||||||
precision: 18,
|
precision: 18,
|
||||||
scale: 4,
|
scale: 4,
|
||||||
default: 0,
|
default: 0,
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => (value ? parseFloat(value) : 0),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
shares: number;
|
shares: number;
|
||||||
|
|
||||||
@@ -92,6 +110,10 @@ export class Position {
|
|||||||
type: 'decimal',
|
type: 'decimal',
|
||||||
precision: 18,
|
precision: 18,
|
||||||
scale: 4,
|
scale: 4,
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => (value ? parseFloat(value) : null),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
costPrice: number;
|
costPrice: number;
|
||||||
|
|
||||||
@@ -105,9 +127,30 @@ export class Position {
|
|||||||
precision: 18,
|
precision: 18,
|
||||||
scale: 4,
|
scale: 4,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
transformer: {
|
||||||
|
to: (value: number | undefined) => value,
|
||||||
|
from: (value: string | null) => (value ? parseFloat(value) : null),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
currentPrice?: number;
|
currentPrice?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '上一次的价格(用于对比显示红绿色)',
|
||||||
|
example: 1800.0,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
name: 'previous_price',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
nullable: true,
|
||||||
|
transformer: {
|
||||||
|
to: (value: number | undefined) => value,
|
||||||
|
from: (value: string | null) => (value ? parseFloat(value) : null),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
previousPrice?: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '货币类型',
|
description: '货币类型',
|
||||||
example: 'CNY',
|
example: 'CNY',
|
||||||
@@ -133,6 +176,10 @@ export class Position {
|
|||||||
precision: 10,
|
precision: 10,
|
||||||
scale: 6,
|
scale: 6,
|
||||||
default: 1,
|
default: 1,
|
||||||
|
transformer: {
|
||||||
|
to: (value: number | undefined) => value,
|
||||||
|
from: (value: string | null) => (value ? parseFloat(value) : 1),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
exchangeRate?: number;
|
exchangeRate?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, IsNull, FindOptionsWhere } from 'typeorm';
|
||||||
import { Position } from './position.entity';
|
import { Position } from './position.entity';
|
||||||
import { CreatePositionDto } from './dto/create-position.dto';
|
import { CreatePositionDto } from './dto/create-position.dto';
|
||||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||||
@@ -35,10 +35,9 @@ export class PositionService {
|
|||||||
|
|
||||||
// 计算每个持仓的额外字段
|
// 计算每个持仓的额外字段
|
||||||
const result: PositionResponseDto[] = positions.map((position) => {
|
const result: PositionResponseDto[] = positions.map((position) => {
|
||||||
const costValue =
|
const costValue = position.shares * position.costPrice;
|
||||||
Number(position.shares) * Number(position.costPrice);
|
|
||||||
const marketValue = position.currentPrice
|
const marketValue = position.currentPrice
|
||||||
? Number(position.shares) * Number(position.currentPrice)
|
? position.shares * position.currentPrice
|
||||||
: 0;
|
: 0;
|
||||||
const profit = marketValue - costValue;
|
const profit = marketValue - costValue;
|
||||||
const profitPercent =
|
const profitPercent =
|
||||||
@@ -75,8 +74,7 @@ export class PositionService {
|
|||||||
let totalAsset = 0;
|
let totalAsset = 0;
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
if (position.currentPrice) {
|
if (position.currentPrice) {
|
||||||
const marketValue =
|
const marketValue = position.shares * position.currentPrice;
|
||||||
Number(position.shares) * Number(position.currentPrice);
|
|
||||||
totalAsset += marketValue;
|
totalAsset += marketValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,14 +90,22 @@ export class PositionService {
|
|||||||
createPositionDto: CreatePositionDto,
|
createPositionDto: CreatePositionDto,
|
||||||
): Promise<Position> {
|
): Promise<Position> {
|
||||||
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
|
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
|
||||||
|
const whereCondition: FindOptionsWhere<Position> = {
|
||||||
|
userId,
|
||||||
|
brokerId: createPositionDto.brokerId,
|
||||||
|
symbol: createPositionDto.symbol,
|
||||||
|
assetType: createPositionDto.assetType,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 market 字段:如果为 undefined 或空字符串,查询 null 值
|
||||||
|
if (createPositionDto.market) {
|
||||||
|
whereCondition.market = createPositionDto.market;
|
||||||
|
} else {
|
||||||
|
whereCondition.market = IsNull();
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.positionRepository.findOne({
|
const existing = await this.positionRepository.findOne({
|
||||||
where: {
|
where: whereCondition,
|
||||||
userId,
|
|
||||||
brokerId: createPositionDto.brokerId,
|
|
||||||
symbol: createPositionDto.symbol,
|
|
||||||
market: createPositionDto.market || null,
|
|
||||||
assetType: createPositionDto.assetType,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -146,6 +152,13 @@ export class PositionService {
|
|||||||
position.costPrice = updatePositionDto.costPrice;
|
position.costPrice = updatePositionDto.costPrice;
|
||||||
}
|
}
|
||||||
if (updatePositionDto.currentPrice !== undefined) {
|
if (updatePositionDto.currentPrice !== undefined) {
|
||||||
|
// 更新价格时,将当前价格保存到 previous_price
|
||||||
|
if (
|
||||||
|
position.currentPrice !== null &&
|
||||||
|
position.currentPrice !== undefined
|
||||||
|
) {
|
||||||
|
position.previousPrice = position.currentPrice;
|
||||||
|
}
|
||||||
position.currentPrice = updatePositionDto.currentPrice;
|
position.currentPrice = updatePositionDto.currentPrice;
|
||||||
}
|
}
|
||||||
if (updatePositionDto.currency !== undefined) {
|
if (updatePositionDto.currency !== undefined) {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^7.11.0",
|
||||||
"styled-components": "^6.1.19"
|
"styled-components": "^6.1.19",
|
||||||
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
/* 内容区域 */
|
/* 内容区域 */
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: #f9fafb;
|
background: #f3f4f6;
|
||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100vh - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { authService } from '@/services/auth';
|
import { authService } from '@/services/auth';
|
||||||
import type { UserInfo } from '@/types/user';
|
import type { UserInfo } from '@/types/user';
|
||||||
|
import { useBrokerStore } from '@/stores/broker';
|
||||||
import SidebarMenu from './SidebarMenu';
|
import SidebarMenu from './SidebarMenu';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { getPageInfo } from './menuConfig';
|
import { getPageInfo } from './menuConfig';
|
||||||
@@ -31,6 +32,11 @@ const MainLayout = () => {
|
|||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 初始化券商数据
|
||||||
|
useEffect(() => {
|
||||||
|
useBrokerStore.getState().fetchBrokers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 根据路由获取页面标题
|
// 根据路由获取页面标题
|
||||||
const pageInfo = useMemo(() => {
|
const pageInfo = useMemo(() => {
|
||||||
return getPageInfo(location.pathname);
|
return getPageInfo(location.pathname);
|
||||||
|
|||||||
@@ -64,15 +64,15 @@ export const routeMenuConfig: RouteMenuConfig[] = [
|
|||||||
subtitle: '回顾过去是为了更好应对将来',
|
subtitle: '回顾过去是为了更好应对将来',
|
||||||
group: 'main',
|
group: 'main',
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
path: '/user-info',
|
// path: '/user-info',
|
||||||
key: '/user-info',
|
// key: '/user-info',
|
||||||
icon: <UserOutlined />,
|
// icon: <UserOutlined />,
|
||||||
label: '个人资料',
|
// label: '个人资料',
|
||||||
title: '个人资料',
|
// title: '个人资料',
|
||||||
subtitle: '查看和编辑个人信息',
|
// subtitle: '查看和编辑个人信息',
|
||||||
group: 'main',
|
// group: 'main',
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
path: '/user',
|
path: '/user',
|
||||||
key: '/user',
|
key: '/user',
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
.assets-page {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-row {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change.positive {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change.negative {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-placeholder {
|
|
||||||
height: 400px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.positions-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-item:hover {
|
|
||||||
box-shadow:
|
|
||||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
|
||||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-code {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-stats {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-value {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-profit {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-profit.positive {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-profit.negative {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.position-item {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position-stats {
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
201
apps/web/src/pages/assets/AssetsPage.css
Normal file
201
apps/web/src/pages/assets/AssetsPage.css
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/* 持仓网格布局 - 响应式 */
|
||||||
|
.positions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.positions-grid {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.positions-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 持仓卡片 */
|
||||||
|
.position-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-card:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 持仓卡片内容区域 */
|
||||||
|
.position-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧:基本信息 */
|
||||||
|
.position-left {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-meta-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-symbol {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-tag {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-holding-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-holding-days {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧:价格和盈亏信息 */
|
||||||
|
.position-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-current-price {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-market-value {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-value-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-profit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-profit-amount {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-profit-percent {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.position-name {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-current-price {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-value-text {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-profit-amount {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-profit-percent {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分割线和更新按钮 */
|
||||||
|
.position-footer {
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-update-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
color: #8b5cf6;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-update-btn:hover {
|
||||||
|
color: rgba(139, 92, 246, 0.8);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-update-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.position-update-btn {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-update-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Card, Row, Col, Statistic, Button } from 'antd';
|
import { Card, Row, Col, Statistic } from 'antd';
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined } from '@ant-design/icons';
|
||||||
|
import PositionList from './components/PositionList';
|
||||||
import './AssetsPage.css';
|
import './AssetsPage.css';
|
||||||
|
|
||||||
const AssetsPage = () => {
|
const AssetsPage = () => {
|
||||||
// 写死的数据
|
// 写死的数据(占位)
|
||||||
const stats = {
|
const stats = {
|
||||||
totalAssets: 1234567,
|
totalAssets: 1234567,
|
||||||
totalProfit: 234567,
|
totalProfit: 234567,
|
||||||
@@ -11,36 +12,6 @@ const AssetsPage = () => {
|
|||||||
recordDays: 300,
|
recordDays: 300,
|
||||||
};
|
};
|
||||||
|
|
||||||
const positions = [
|
|
||||||
{
|
|
||||||
name: '贵州茅台',
|
|
||||||
code: '600519',
|
|
||||||
market: '上海',
|
|
||||||
broker: '华泰证券',
|
|
||||||
value: 456789,
|
|
||||||
profit: 56789,
|
|
||||||
profitRate: 14.2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '腾讯控股',
|
|
||||||
code: '00700',
|
|
||||||
market: '香港',
|
|
||||||
broker: '富途证券',
|
|
||||||
value: 345678,
|
|
||||||
profit: 45678,
|
|
||||||
profitRate: 15.2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '苹果公司',
|
|
||||||
code: 'AAPL',
|
|
||||||
market: '美股',
|
|
||||||
broker: '盈透证券',
|
|
||||||
value: 432100,
|
|
||||||
profit: -12100,
|
|
||||||
profitRate: -2.7,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="assets-page">
|
<div className="assets-page">
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
@@ -104,42 +75,7 @@ const AssetsPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 持仓列表 */}
|
{/* 持仓列表 */}
|
||||||
<Card
|
<PositionList />
|
||||||
title="我的持仓"
|
|
||||||
extra={
|
|
||||||
<Button type="primary" size="small">
|
|
||||||
+ 添加资产
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="positions-list">
|
|
||||||
{positions.map((position, index) => (
|
|
||||||
<div key={index} className="position-item">
|
|
||||||
<div className="position-info">
|
|
||||||
<div className="position-name">{position.name}</div>
|
|
||||||
<div className="position-code">
|
|
||||||
{position.code} · {position.market} · {position.broker}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="position-stats">
|
|
||||||
<div className="position-value">
|
|
||||||
¥{position.value.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`position-profit ${
|
|
||||||
position.profit >= 0 ? 'positive' : 'negative'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{position.profit >= 0 ? '+' : ''}¥
|
|
||||||
{Math.abs(position.profit).toLocaleString()} (
|
|
||||||
{position.profitRate >= 0 ? '+' : ''}
|
|
||||||
{position.profitRate}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
73
apps/web/src/pages/assets/components/PositionList.css
Normal file
73
apps/web/src/pages/assets/components/PositionList.css
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/* 我的持仓容器 */
|
||||||
|
.position-list-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题和按钮区域 */
|
||||||
|
.position-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-list-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.position-list-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加资产按钮 - 完全圆形 */
|
||||||
|
.position-add-btn {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
min-width: 32px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
border: 1px solid #8b5cf6 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: #8b5cf6 !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-add-btn:hover,
|
||||||
|
.position-add-btn:focus {
|
||||||
|
background: #8b5cf6 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: #8b5cf6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-add-btn:active {
|
||||||
|
background: #7c3aed !important;
|
||||||
|
border-color: #7c3aed !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-add-btn .anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.position-add-btn {
|
||||||
|
width: 36px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
min-width: 36px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-add-btn .anticon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
apps/web/src/pages/assets/components/PositionList.tsx
Normal file
160
apps/web/src/pages/assets/components/PositionList.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, Button, App, Spin } from 'antd';
|
||||||
|
import { PlusOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
import { positionService } from '@/services/position';
|
||||||
|
import type { PositionResponse } from '@/types/position';
|
||||||
|
import { useBrokerStore } from '@/stores/broker';
|
||||||
|
import { useMarketStore } from '@/stores/market';
|
||||||
|
import '../AssetsPage.css';
|
||||||
|
import './PositionList.css';
|
||||||
|
|
||||||
|
const PositionList = () => {
|
||||||
|
const { message: messageApi } = App.useApp();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [positions, setPositions] = useState<PositionResponse[]>([]);
|
||||||
|
const getBrokerName = useBrokerStore((state) => state.getBrokerName);
|
||||||
|
const getMarketName = useMarketStore((state) => state.getMarketName);
|
||||||
|
|
||||||
|
// 加载持仓数据
|
||||||
|
const loadPositions = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const positionData = await positionService.getPositionsByUserId();
|
||||||
|
if (positionData && positionData.code === 0) {
|
||||||
|
const positionList = positionData.data;
|
||||||
|
setPositions(positionList || []);
|
||||||
|
} else {
|
||||||
|
setPositions([]);
|
||||||
|
messageApi.error(positionData.message || '加载持仓数据失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
messageApi.error(error.message || '加载持仓数据失败');
|
||||||
|
setPositions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPositions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 格式化价格显示(根据涨跌显示颜色)
|
||||||
|
const formatPrice = (currentPrice?: number, previousPrice?: number) => {
|
||||||
|
if (!currentPrice) return { text: '--', color: '#1f2937' };
|
||||||
|
const price = currentPrice.toFixed(2);
|
||||||
|
if (!previousPrice) return { text: price, color: '#1f2937' };
|
||||||
|
if (currentPrice > previousPrice) {
|
||||||
|
return { text: price, color: '#ef4444' }; // 红色(上涨)
|
||||||
|
} else if (currentPrice < previousPrice) {
|
||||||
|
return { text: price, color: '#10b981' }; // 绿色(下跌)
|
||||||
|
}
|
||||||
|
return { text: price, color: '#1f2937' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化盈亏显示
|
||||||
|
const formatProfit = (profit: number, profitPercent: number) => {
|
||||||
|
const isPositive = profit >= 0;
|
||||||
|
const profitText = `${isPositive ? '+' : ''}${Math.abs(profit).toLocaleString()}`;
|
||||||
|
const percentText = `${isPositive ? '+' : ''}${profitPercent.toFixed(2)}%`;
|
||||||
|
const color = isPositive ? '#ef4444' : '#10b981';
|
||||||
|
return { profitText, percentText, color };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="position-list-container">
|
||||||
|
<div className="position-list-header">
|
||||||
|
<h2 className="position-list-title">我的持仓</h2>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
shape="circle"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="position-add-btn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<div className="positions-grid">
|
||||||
|
{positions &&
|
||||||
|
positions.map((position) => {
|
||||||
|
const priceInfo = formatPrice(
|
||||||
|
position.currentPrice,
|
||||||
|
position.previousPrice
|
||||||
|
);
|
||||||
|
const profitInfo = formatProfit(
|
||||||
|
position.profit,
|
||||||
|
position.profitPercent
|
||||||
|
);
|
||||||
|
const brokerName = getBrokerName(position.brokerId);
|
||||||
|
const marketText = getMarketName(position.market);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={position.positionId} className="position-card">
|
||||||
|
<div className="position-content">
|
||||||
|
{/* 左侧:基本信息 */}
|
||||||
|
<div className="position-left">
|
||||||
|
<div className="position-name">{position.name}</div>
|
||||||
|
<div className="position-meta-info">
|
||||||
|
<span className="position-symbol">
|
||||||
|
{position.symbol}
|
||||||
|
</span>
|
||||||
|
{marketText && (
|
||||||
|
<span className="meta-tag">{marketText}</span>
|
||||||
|
)}
|
||||||
|
{brokerName && (
|
||||||
|
<span className="meta-tag">{brokerName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="position-holding-info">
|
||||||
|
<span>
|
||||||
|
持股 {position.shares.toLocaleString()} 股
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="position-holding-days">
|
||||||
|
<span>持有 {position.holdingDays} 天</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:价格和盈亏信息 */}
|
||||||
|
<div className="position-right">
|
||||||
|
<div
|
||||||
|
className="position-current-price"
|
||||||
|
style={{ color: priceInfo.color }}
|
||||||
|
>
|
||||||
|
{priceInfo.text}
|
||||||
|
</div>
|
||||||
|
<div className="position-market-value">
|
||||||
|
<span className="position-value-text">
|
||||||
|
{position.marketValue.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="position-profit"
|
||||||
|
style={{ color: profitInfo.color }}
|
||||||
|
>
|
||||||
|
<span className="position-profit-amount">
|
||||||
|
{profitInfo.profitText}
|
||||||
|
</span>
|
||||||
|
<span className="position-profit-percent">
|
||||||
|
{profitInfo.percentText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分割线和更新按钮 */}
|
||||||
|
<div className="position-footer">
|
||||||
|
<button className="position-update-btn">
|
||||||
|
<span>更新持仓</span>
|
||||||
|
<RightOutlined className="position-update-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PositionList;
|
||||||
1
apps/web/src/pages/assets/index.ts
Normal file
1
apps/web/src/pages/assets/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './AssetsPage';
|
||||||
@@ -4,7 +4,7 @@ import MainLayout from '../layouts/MainLayout';
|
|||||||
import ProtectedRoute from '../components/ProtectedRoute';
|
import ProtectedRoute from '../components/ProtectedRoute';
|
||||||
import ErrorPage from '../components/ErrorPage';
|
import ErrorPage from '../components/ErrorPage';
|
||||||
import LoginPage from '../pages/LoginPage';
|
import LoginPage from '../pages/LoginPage';
|
||||||
const AssetsPage = lazy(() => import('../pages/AssetsPage'));
|
const AssetsPage = lazy(() => import('../pages/assets'));
|
||||||
const PlansPage = lazy(() => import('../pages/PlansPage'));
|
const PlansPage = lazy(() => import('../pages/PlansPage'));
|
||||||
const ReviewPage = lazy(() => import('../pages/ReviewPage'));
|
const ReviewPage = lazy(() => import('../pages/ReviewPage'));
|
||||||
const BrokerPage = lazy(() => import('../pages/broker'));
|
const BrokerPage = lazy(() => import('../pages/broker'));
|
||||||
|
|||||||
45
apps/web/src/services/position.ts
Normal file
45
apps/web/src/services/position.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type { ApiResponse } from '@/types/common';
|
||||||
|
import type {
|
||||||
|
PositionResponse,
|
||||||
|
CreatePositionRequest,
|
||||||
|
UpdatePositionRequest,
|
||||||
|
} from '@/types/position';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持仓服务
|
||||||
|
*/
|
||||||
|
class PositionService {
|
||||||
|
/**
|
||||||
|
* 查询用户的所有持仓(不分页)
|
||||||
|
*/
|
||||||
|
async getPositionsByUserId(): Promise<ApiResponse<PositionResponse[]>> {
|
||||||
|
return await api.get<ApiResponse<PositionResponse[]>>('/position');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建持仓
|
||||||
|
*/
|
||||||
|
async createPosition(data: CreatePositionRequest): Promise<ApiResponse<PositionResponse>> {
|
||||||
|
return await api.post<ApiResponse<PositionResponse>>('/position', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新持仓
|
||||||
|
*/
|
||||||
|
async updatePosition(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePositionRequest
|
||||||
|
): Promise<ApiResponse<PositionResponse>> {
|
||||||
|
return await api.patch<ApiResponse<PositionResponse>>(`/position/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除持仓
|
||||||
|
*/
|
||||||
|
async deletePosition(id: number): Promise<void> {
|
||||||
|
await api.delete(`/position/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const positionService = new PositionService();
|
||||||
54
apps/web/src/stores/broker.ts
Normal file
54
apps/web/src/stores/broker.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { brokerService } from '@/services/broker';
|
||||||
|
import type { Broker } from '@/types/broker';
|
||||||
|
|
||||||
|
interface BrokerStore {
|
||||||
|
brokers: Broker[];
|
||||||
|
loading: boolean;
|
||||||
|
initialized: boolean;
|
||||||
|
fetchBrokers: () => Promise<void>;
|
||||||
|
getBrokerName: (brokerId: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBrokerStore = create<BrokerStore>((set, get) => ({
|
||||||
|
brokers: [],
|
||||||
|
loading: false,
|
||||||
|
initialized: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取券商列表
|
||||||
|
*/
|
||||||
|
fetchBrokers: async () => {
|
||||||
|
const { initialized } = get();
|
||||||
|
// 如果已经初始化过,不重复加载
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const response = await brokerService.getBrokerList({
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
brokers: response.list || [],
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取券商列表失败:', error);
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过券商ID获取券商名称
|
||||||
|
*/
|
||||||
|
getBrokerName: (brokerId: number) => {
|
||||||
|
const { brokers } = get();
|
||||||
|
const broker = brokers.find((b) => b.brokerId === brokerId);
|
||||||
|
return broker?.brokerName || '';
|
||||||
|
},
|
||||||
|
}));
|
||||||
50
apps/web/src/stores/market.ts
Normal file
50
apps/web/src/stores/market.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface MarketItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketStore {
|
||||||
|
marketMap: Record<string, string>;
|
||||||
|
getMarketName: (marketCode?: string) => string;
|
||||||
|
getAllMarkets: () => MarketItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 市场简称和名称的映射
|
||||||
|
const MARKET_MAP: Record<string, string> = {
|
||||||
|
sh: '上海',
|
||||||
|
sz: '深圳',
|
||||||
|
bj: '北京',
|
||||||
|
hk: '香港',
|
||||||
|
us: '美股',
|
||||||
|
jp: '日股',
|
||||||
|
kr: '韩国股市',
|
||||||
|
eu: '欧洲市场',
|
||||||
|
sea: '东南亚',
|
||||||
|
other: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMarketStore = create<MarketStore>((_set, get) => ({
|
||||||
|
marketMap: MARKET_MAP,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过市场代码获取市场名称
|
||||||
|
*/
|
||||||
|
getMarketName: (marketCode?: string) => {
|
||||||
|
if (!marketCode) return '';
|
||||||
|
const { marketMap } = get();
|
||||||
|
return marketMap[marketCode] || marketCode;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有市场列表
|
||||||
|
*/
|
||||||
|
getAllMarkets: () => {
|
||||||
|
const { marketMap } = get();
|
||||||
|
return Object.entries(marketMap).map(([code, name]) => ({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: T;
|
data?: T;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
82
apps/web/src/types/position.ts
Normal file
82
apps/web/src/types/position.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 分页信息
|
||||||
|
*/
|
||||||
|
export interface PaginationInfo {
|
||||||
|
total: number;
|
||||||
|
total_page: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持仓信息
|
||||||
|
*/
|
||||||
|
export interface Position {
|
||||||
|
positionId: number;
|
||||||
|
userId: number;
|
||||||
|
brokerId: number;
|
||||||
|
assetType: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
market?: string;
|
||||||
|
shares: number;
|
||||||
|
costPrice: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
previousPrice?: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
autoPriceUpdate: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持仓响应数据(包含计算字段)
|
||||||
|
*/
|
||||||
|
export interface PositionResponse extends Position {
|
||||||
|
costValue: number; // 持仓成本
|
||||||
|
marketValue: number; // 持仓市值
|
||||||
|
profit: number; // 持仓盈亏
|
||||||
|
profitPercent: number; // 持仓盈利比例(%)
|
||||||
|
holdingDays: number; // 持仓天数
|
||||||
|
assetPercent: number; // 占用户总资产百分比(%)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页响应
|
||||||
|
*/
|
||||||
|
export interface PaginatedPositionResponse {
|
||||||
|
list: PositionResponse[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建持仓请求
|
||||||
|
*/
|
||||||
|
export interface CreatePositionRequest {
|
||||||
|
brokerId: number;
|
||||||
|
assetType: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
market?: string;
|
||||||
|
shares: number;
|
||||||
|
costPrice: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
autoPriceUpdate?: boolean;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新持仓请求
|
||||||
|
*/
|
||||||
|
export interface UpdatePositionRequest {
|
||||||
|
brokerId?: number;
|
||||||
|
costPrice?: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
@@ -25,5 +25,5 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.20.0"
|
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
|
||||||
}
|
}
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -194,6 +194,9 @@ importers:
|
|||||||
styled-components:
|
styled-components:
|
||||||
specifier: ^6.1.19
|
specifier: ^6.1.19
|
||||||
version: 6.1.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 6.1.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.10
|
||||||
|
version: 5.0.10(@types/react@19.2.7)(react@19.2.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
@@ -4852,6 +4855,24 @@ packages:
|
|||||||
zod@4.3.4:
|
zod@4.3.4:
|
||||||
resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==}
|
resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==}
|
||||||
|
|
||||||
|
zustand@5.0.10:
|
||||||
|
resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=18.0.0'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@angular-devkit/core@19.2.15(chokidar@4.0.3)':
|
'@angular-devkit/core@19.2.15(chokidar@4.0.3)':
|
||||||
@@ -10093,3 +10114,8 @@ snapshots:
|
|||||||
zod: 4.3.4
|
zod: 4.3.4
|
||||||
|
|
||||||
zod@4.3.4: {}
|
zod@4.3.4: {}
|
||||||
|
|
||||||
|
zustand@5.0.10(@types/react@19.2.7)(react@19.2.3):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.3
|
||||||
|
|||||||
Reference in New Issue
Block a user