diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 91c64e6..46cfa3f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,7 @@ import { StorageModule } from './modules/storage/storage.module'; import { StockInfoModule } from './modules/stock-info/stock-info.module'; import { StockDailyPriceModule } from './modules/stock-daily-price/stock-daily-price.module'; import { PositionModule } from './modules/position/position.module'; +import { PositionChangeModule } from './modules/position-change/position-change.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { PositionModule } from './modules/position/position.module'; StockInfoModule, StockDailyPriceModule, PositionModule, + PositionChangeModule, ], controllers: [], providers: [], diff --git a/apps/api/src/modules/position-change/dto/create-position-change.dto.ts b/apps/api/src/modules/position-change/dto/create-position-change.dto.ts new file mode 100644 index 0000000..ddd1a78 --- /dev/null +++ b/apps/api/src/modules/position-change/dto/create-position-change.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/position-change/dto/paginated-response.dto.ts b/apps/api/src/modules/position-change/dto/paginated-response.dto.ts new file mode 100644 index 0000000..72d46fc --- /dev/null +++ b/apps/api/src/modules/position-change/dto/paginated-response.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/position-change/dto/query-position-change.dto.ts b/apps/api/src/modules/position-change/dto/query-position-change.dto.ts new file mode 100644 index 0000000..87c0719 --- /dev/null +++ b/apps/api/src/modules/position-change/dto/query-position-change.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/position-change/dto/update-position-change.dto.ts b/apps/api/src/modules/position-change/dto/update-position-change.dto.ts new file mode 100644 index 0000000..3a49bd3 --- /dev/null +++ b/apps/api/src/modules/position-change/dto/update-position-change.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/position-change/position-change.controller.ts b/apps/api/src/modules/position-change/position-change.controller.ts new file mode 100644 index 0000000..9911e2b --- /dev/null +++ b/apps/api/src/modules/position-change/position-change.controller.ts @@ -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 { + 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 { + 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 { + return this.positionChangeService.update( + +id, + req.user.userId, + updatePositionChangeDto, + ); + } +} diff --git a/apps/api/src/modules/position-change/position-change.entity.ts b/apps/api/src/modules/position-change/position-change.entity.ts new file mode 100644 index 0000000..94e254a --- /dev/null +++ b/apps/api/src/modules/position-change/position-change.entity.ts @@ -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; +} diff --git a/apps/api/src/modules/position-change/position-change.module.ts b/apps/api/src/modules/position-change/position-change.module.ts new file mode 100644 index 0000000..cc4caa9 --- /dev/null +++ b/apps/api/src/modules/position-change/position-change.module.ts @@ -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 {} diff --git a/apps/api/src/modules/position-change/position-change.service.ts b/apps/api/src/modules/position-change/position-change.service.ts new file mode 100644 index 0000000..5cfffbf --- /dev/null +++ b/apps/api/src/modules/position-change/position-change.service.ts @@ -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, + @InjectRepository(Position) + private readonly positionRepository: Repository, + ) {} + + /** + * 分页查询单个持仓的所有变更记录 + */ + 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 { + // 验证持仓是否属于当前用户 + 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 { + // 查找变更记录 + 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); + } +} diff --git a/apps/api/src/modules/position/position.entity.ts b/apps/api/src/modules/position/position.entity.ts index f86a387..fae4893 100644 --- a/apps/api/src/modules/position/position.entity.ts +++ b/apps/api/src/modules/position/position.entity.ts @@ -20,7 +20,14 @@ export class Position { description: '用户ID', 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() userId: number; @@ -28,7 +35,14 @@ export class Position { description: '券商ID', 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() brokerId: number; @@ -80,6 +94,10 @@ export class Position { precision: 18, scale: 4, default: 0, + transformer: { + to: (value: number) => value, + from: (value: string) => (value ? parseFloat(value) : 0), + }, }) shares: number; @@ -92,6 +110,10 @@ export class Position { type: 'decimal', precision: 18, scale: 4, + transformer: { + to: (value: number) => value, + from: (value: string) => (value ? parseFloat(value) : null), + }, }) costPrice: number; @@ -105,9 +127,30 @@ export class Position { precision: 18, scale: 4, nullable: true, + transformer: { + to: (value: number | undefined) => value, + from: (value: string | null) => (value ? parseFloat(value) : null), + }, }) currentPrice?: number; + @ApiPropertyOptional({ + description: '上一次的价格(用于对比显示红绿色)', + example: 1800.0, + }) + @Column({ + name: 'previous_price', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + transformer: { + to: (value: number | undefined) => value, + from: (value: string | null) => (value ? parseFloat(value) : null), + }, + }) + previousPrice?: number; + @ApiProperty({ description: '货币类型', example: 'CNY', @@ -133,6 +176,10 @@ export class Position { precision: 10, scale: 6, default: 1, + transformer: { + to: (value: number | undefined) => value, + from: (value: string | null) => (value ? parseFloat(value) : 1), + }, }) exchangeRate?: number; diff --git a/apps/api/src/modules/position/position.service.ts b/apps/api/src/modules/position/position.service.ts index f05c95b..4cb2381 100644 --- a/apps/api/src/modules/position/position.service.ts +++ b/apps/api/src/modules/position/position.service.ts @@ -5,7 +5,7 @@ import { Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, IsNull, FindOptionsWhere } from 'typeorm'; import { Position } from './position.entity'; import { CreatePositionDto } from './dto/create-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; @@ -35,10 +35,9 @@ export class PositionService { // 计算每个持仓的额外字段 const result: PositionResponseDto[] = positions.map((position) => { - const costValue = - Number(position.shares) * Number(position.costPrice); + const costValue = position.shares * position.costPrice; const marketValue = position.currentPrice - ? Number(position.shares) * Number(position.currentPrice) + ? position.shares * position.currentPrice : 0; const profit = marketValue - costValue; const profitPercent = @@ -75,8 +74,7 @@ export class PositionService { let totalAsset = 0; for (const position of positions) { if (position.currentPrice) { - const marketValue = - Number(position.shares) * Number(position.currentPrice); + const marketValue = position.shares * position.currentPrice; totalAsset += marketValue; } } @@ -92,14 +90,22 @@ export class PositionService { createPositionDto: CreatePositionDto, ): Promise { // 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓 + const whereCondition: FindOptionsWhere = { + 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({ - where: { - userId, - brokerId: createPositionDto.brokerId, - symbol: createPositionDto.symbol, - market: createPositionDto.market || null, - assetType: createPositionDto.assetType, - }, + where: whereCondition, }); if (existing) { @@ -146,6 +152,13 @@ export class PositionService { position.costPrice = updatePositionDto.costPrice; } if (updatePositionDto.currentPrice !== undefined) { + // 更新价格时,将当前价格保存到 previous_price + if ( + position.currentPrice !== null && + position.currentPrice !== undefined + ) { + position.previousPrice = position.currentPrice; + } position.currentPrice = updatePositionDto.currentPrice; } if (updatePositionDto.currency !== undefined) { diff --git a/apps/web/package.json b/apps/web/package.json index b455971..d2eae39 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,8 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.11.0", - "styled-components": "^6.1.19" + "styled-components": "^6.1.19", + "zustand": "^5.0.10" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/apps/web/src/layouts/MainLayout.css b/apps/web/src/layouts/MainLayout.css index 6e721fa..5473dff 100644 --- a/apps/web/src/layouts/MainLayout.css +++ b/apps/web/src/layouts/MainLayout.css @@ -150,7 +150,7 @@ /* 内容区域 */ .main-content { padding: 24px; - background: #f9fafb; + background: #f3f4f6; min-height: calc(100vh - 64px); } diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 780bec9..597d070 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -10,6 +10,7 @@ import { import type { MenuProps } from 'antd'; import { authService } from '@/services/auth'; import type { UserInfo } from '@/types/user'; +import { useBrokerStore } from '@/stores/broker'; import SidebarMenu from './SidebarMenu'; import ErrorBoundary from '@/components/ErrorBoundary'; import { getPageInfo } from './menuConfig'; @@ -31,6 +32,11 @@ const MainLayout = () => { setUser(currentUser); }, []); + // 初始化券商数据 + useEffect(() => { + useBrokerStore.getState().fetchBrokers(); + }, []); + // 根据路由获取页面标题 const pageInfo = useMemo(() => { return getPageInfo(location.pathname); diff --git a/apps/web/src/layouts/menuConfig.tsx b/apps/web/src/layouts/menuConfig.tsx index 2aa1ae7..2ffb4b1 100644 --- a/apps/web/src/layouts/menuConfig.tsx +++ b/apps/web/src/layouts/menuConfig.tsx @@ -64,15 +64,15 @@ export const routeMenuConfig: RouteMenuConfig[] = [ subtitle: '回顾过去是为了更好应对将来', group: 'main', }, - { - path: '/user-info', - key: '/user-info', - icon: , - label: '个人资料', - title: '个人资料', - subtitle: '查看和编辑个人信息', - group: 'main', - }, + // { + // path: '/user-info', + // key: '/user-info', + // icon: , + // label: '个人资料', + // title: '个人资料', + // subtitle: '查看和编辑个人信息', + // group: 'main', + // }, { path: '/user', key: '/user', diff --git a/apps/web/src/pages/AssetsPage.css b/apps/web/src/pages/AssetsPage.css deleted file mode 100644 index 4492b06..0000000 --- a/apps/web/src/pages/AssetsPage.css +++ /dev/null @@ -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%; - } -} diff --git a/apps/web/src/pages/assets/AssetsPage.css b/apps/web/src/pages/assets/AssetsPage.css new file mode 100644 index 0000000..40c51d0 --- /dev/null +++ b/apps/web/src/pages/assets/AssetsPage.css @@ -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; + } +} diff --git a/apps/web/src/pages/AssetsPage.tsx b/apps/web/src/pages/assets/AssetsPage.tsx similarity index 51% rename from apps/web/src/pages/AssetsPage.tsx rename to apps/web/src/pages/assets/AssetsPage.tsx index 9d3920d..206672d 100644 --- a/apps/web/src/pages/AssetsPage.tsx +++ b/apps/web/src/pages/assets/AssetsPage.tsx @@ -1,9 +1,10 @@ -import { Card, Row, Col, Statistic, Button } from 'antd'; -import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +import { Card, Row, Col, Statistic } from 'antd'; +import { ArrowUpOutlined } from '@ant-design/icons'; +import PositionList from './components/PositionList'; import './AssetsPage.css'; const AssetsPage = () => { - // 写死的数据 + // 写死的数据(占位) const stats = { totalAssets: 1234567, totalProfit: 234567, @@ -11,36 +12,6 @@ const AssetsPage = () => { recordDays: 300, }; - const positions = [ - { - name: '贵州茅台', - code: '600519', - market: '上海', - broker: '华泰证券', - value: 456789, - profit: 56789, - profitRate: 14.2, - }, - { - name: '腾讯控股', - code: '00700', - market: '香港', - broker: '富途证券', - value: 345678, - profit: 45678, - profitRate: 15.2, - }, - { - name: '苹果公司', - code: 'AAPL', - market: '美股', - broker: '盈透证券', - value: 432100, - profit: -12100, - profitRate: -2.7, - }, - ]; - return (
{/* 统计卡片 */} @@ -104,42 +75,7 @@ const AssetsPage = () => { {/* 持仓列表 */} - - + 添加资产 - - } - > -
- {positions.map((position, index) => ( -
-
-
{position.name}
-
- {position.code} · {position.market} · {position.broker} -
-
-
-
- ¥{position.value.toLocaleString()} -
-
= 0 ? 'positive' : 'negative' - }`} - > - {position.profit >= 0 ? '+' : ''}¥ - {Math.abs(position.profit).toLocaleString()} ( - {position.profitRate >= 0 ? '+' : ''} - {position.profitRate}%) -
-
-
- ))} -
-
+
); }; diff --git a/apps/web/src/pages/assets/components/PositionList.css b/apps/web/src/pages/assets/components/PositionList.css new file mode 100644 index 0000000..813f88e --- /dev/null +++ b/apps/web/src/pages/assets/components/PositionList.css @@ -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; + } +} diff --git a/apps/web/src/pages/assets/components/PositionList.tsx b/apps/web/src/pages/assets/components/PositionList.tsx new file mode 100644 index 0000000..f82b2f9 --- /dev/null +++ b/apps/web/src/pages/assets/components/PositionList.tsx @@ -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([]); + 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 ( +
+
+

我的持仓

+
+ +
+ {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 ( + +
+ {/* 左侧:基本信息 */} +
+
{position.name}
+
+ + {position.symbol} + + {marketText && ( + {marketText} + )} + {brokerName && ( + {brokerName} + )} +
+
+ + 持股 {position.shares.toLocaleString()} 股 + +
+
+ 持有 {position.holdingDays} 天 +
+
+ + {/* 右侧:价格和盈亏信息 */} +
+
+ {priceInfo.text} +
+
+ + {position.marketValue.toLocaleString()} + +
+
+ + {profitInfo.profitText} + + + {profitInfo.percentText} + +
+
+
+ + {/* 分割线和更新按钮 */} +
+ +
+
+ ); + })} +
+
+
+ ); +}; + +export default PositionList; diff --git a/apps/web/src/pages/assets/index.ts b/apps/web/src/pages/assets/index.ts new file mode 100644 index 0000000..8a0706f --- /dev/null +++ b/apps/web/src/pages/assets/index.ts @@ -0,0 +1 @@ +export { default } from './AssetsPage'; diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 1b1772f..5e2f23d 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -4,7 +4,7 @@ import MainLayout from '../layouts/MainLayout'; import ProtectedRoute from '../components/ProtectedRoute'; import ErrorPage from '../components/ErrorPage'; import LoginPage from '../pages/LoginPage'; -const AssetsPage = lazy(() => import('../pages/AssetsPage')); +const AssetsPage = lazy(() => import('../pages/assets')); const PlansPage = lazy(() => import('../pages/PlansPage')); const ReviewPage = lazy(() => import('../pages/ReviewPage')); const BrokerPage = lazy(() => import('../pages/broker')); diff --git a/apps/web/src/services/position.ts b/apps/web/src/services/position.ts new file mode 100644 index 0000000..c0bc2b1 --- /dev/null +++ b/apps/web/src/services/position.ts @@ -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> { + return await api.get>('/position'); + } + + /** + * 创建持仓 + */ + async createPosition(data: CreatePositionRequest): Promise> { + return await api.post>('/position', data); + } + + /** + * 更新持仓 + */ + async updatePosition( + id: number, + data: UpdatePositionRequest + ): Promise> { + return await api.patch>(`/position/${id}`, data); + } + + /** + * 删除持仓 + */ + async deletePosition(id: number): Promise { + await api.delete(`/position/${id}`); + } +} + +export const positionService = new PositionService(); diff --git a/apps/web/src/stores/broker.ts b/apps/web/src/stores/broker.ts new file mode 100644 index 0000000..39d9266 --- /dev/null +++ b/apps/web/src/stores/broker.ts @@ -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; + getBrokerName: (brokerId: number) => string; +} + +export const useBrokerStore = create((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 || ''; + }, +})); diff --git a/apps/web/src/stores/market.ts b/apps/web/src/stores/market.ts new file mode 100644 index 0000000..889e0cb --- /dev/null +++ b/apps/web/src/stores/market.ts @@ -0,0 +1,50 @@ +import { create } from 'zustand'; + +export interface MarketItem { + code: string; + name: string; +} + +interface MarketStore { + marketMap: Record; + getMarketName: (marketCode?: string) => string; + getAllMarkets: () => MarketItem[]; +} + +// 市场简称和名称的映射 +const MARKET_MAP: Record = { + sh: '上海', + sz: '深圳', + bj: '北京', + hk: '香港', + us: '美股', + jp: '日股', + kr: '韩国股市', + eu: '欧洲市场', + sea: '东南亚', + other: '其他', +}; + +export const useMarketStore = create((_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, + })); + }, +})); diff --git a/apps/web/src/types/common.ts b/apps/web/src/types/common.ts index af037c1..d08d84b 100644 --- a/apps/web/src/types/common.ts +++ b/apps/web/src/types/common.ts @@ -4,6 +4,6 @@ export interface ApiResponse { code: number; message: string; - data: T; + data?: T; timestamp?: string; } diff --git a/apps/web/src/types/position.ts b/apps/web/src/types/position.ts new file mode 100644 index 0000000..8165b7c --- /dev/null +++ b/apps/web/src/types/position.ts @@ -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; +} diff --git a/package.json b/package.json index 68d5c89..72b6692 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,5 @@ "keywords": [], "author": "", "license": "ISC", - "packageManager": "pnpm@10.20.0" + "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2de5f46..c57ff60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ importers: styled-components: specifier: ^6.1.19 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: '@eslint/js': specifier: ^9.39.1 @@ -4852,6 +4855,24 @@ packages: zod@4.3.4: 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: '@angular-devkit/core@19.2.15(chokidar@4.0.3)': @@ -10093,3 +10114,8 @@ snapshots: 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