Compare commits

..

10 Commits

Author SHA1 Message Date
R524809 1f7fded275 tq[MESSAGE]:
[TYPE]:
[JIRA]:
2026-02-11 16:05:45 +08:00
R524809 571465cfbb feat: 添加设计文件packages 2026-02-11 16:01:42 +08:00
R524809 161781cbbd feat: 添加登录页 2026-02-11 15:39:13 +08:00
R524809 aa313b7605 feat: 更新持仓 2026-01-16 18:01:41 +08:00
R524809 b7972153cc feat: 开发我的持仓列表 2026-01-13 16:46:18 +08:00
R524809 838a021ce5 feat: 开发持仓、股票信息相关接口 2026-01-12 17:38:55 +08:00
R524809 67e4dc6382 feat: 新增图片资源上传和管理服务 2026-01-07 17:09:00 +08:00
R524809 457ba6d765 feat: 完成券商和用户管理 2026-01-07 16:21:16 +08:00
R524809 712f66b725 feat: 优化Header 2026-01-06 17:14:03 +08:00
R524809 76c22429ad feat: 完善登录等接口鉴权 2026-01-06 10:49:19 +08:00
147 changed files with 27851 additions and 466 deletions
+8 -2
View File
@@ -1,5 +1,5 @@
# 开发环境配置 # 开发环境配置
PORT=3200 PORT=3201
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
@@ -11,8 +11,14 @@ DB_DATABASE=vest_mind_dev
JWT_SECRET=vest_thinking_key JWT_SECRET=vest_thinking_key
JWT_EXPIRES_IN=7d JWT_EXPIRES_IN=7d
# 资源上传配置
STORAGE_TYPE=local
ADMIN_USERNAME=joey ADMIN_USERNAME=joey
ADMIN_PASSWORD=joey5628 ADMIN_PASSWORD=joey5628
ADMIN_EMAIL=zhangyi5628@126.com ADMIN_EMAIL=zhangyi5628@126.com
ADMIN_NICKNAME=思考的Joey ADMIN_NICKNAME=思考的Joey
ADMIN_ROLE=super_admin ADMIN_ROLE=super_admin
STORAGE_PATH=./uploads # 存储路径(默认:./uploads
STORAGE_BASE_URL=http://localhost:3201/uploads # 访问URL(默认:http://localhost:3201/uploads
+1 -1
View File
@@ -9,7 +9,7 @@
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"dev": "nest start --watch", "dev": "nest start --watch",
"start": "nest start", "start": "nest start --watch",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
+10
View File
@@ -5,6 +5,11 @@ import { CoreModule } from './core/core.module';
import { BrokerModule } from './modules/broker/broker.module'; import { BrokerModule } from './modules/broker/broker.module';
import { UserModule } from './modules/user/user.module'; import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { StorageModule } from './modules/storage/storage.module';
import { StockInfoModule } from './modules/stock-info/stock-info.module';
import { StockDailyPriceModule } from './modules/stock-daily-price/stock-daily-price.module';
import { PositionModule } from './modules/position/position.module';
import { PositionChangeModule } from './modules/position-change/position-change.module';
@Module({ @Module({
imports: [ imports: [
@@ -20,6 +25,11 @@ import { AuthModule } from './modules/auth/auth.module';
BrokerModule, BrokerModule,
UserModule, UserModule,
AuthModule, AuthModule,
StorageModule,
StockInfoModule,
StockDailyPriceModule,
PositionModule,
PositionChangeModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+29
View File
@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 分页信息
*/
export class PaginationInfo {
@ApiProperty({ description: '总记录数', example: 46 })
total: number;
@ApiProperty({ description: '总页数', example: 5 })
total_page: number;
@ApiProperty({ description: '每页数量', example: 10 })
page_size: number;
@ApiProperty({ description: '当前页码', example: 1 })
current_page: number;
}
/**
* 通用分页响应数据
*/
export class PaginatedData<T> {
@ApiProperty({ description: '数据列表' })
list: T[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}
+34 -10
View File
@@ -6,34 +6,58 @@ import helmet from 'helmet';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() { async function bootstrap() {
const configService = new ConfigService(); const configService = new ConfigService();
// const isProduction = configService.get('NODE_ENV') === 'production'; // const isProduction = configService.get('NODE_ENV') === 'production';
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bodyParser: false, // 禁用默认 bodyParser,使用自定义配置 bodyParser: false, // 禁用默认 bodyParser,使用自定义配置
}); });
// 安全头设置(必须在其他中间件之前) // 配置 CORS(必须在其他中间件之前)
app.use(helmet()); app.enableCors({
origin: true, // 允许所有域名
credentials: true, // 允许携带凭证
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
// 配置静态文件服务(需要在 CORS 之后,以便静态文件也能应用 CORS)
const storagePath =
configService.get<string>('STORAGE_PATH') || './uploads';
app.useStaticAssets(join(process.cwd(), storagePath), {
prefix: '/uploads/',
setHeaders: (res) => {
// 为静态文件添加 CORS 头
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
res.setHeader('Access-Control-Allow-Origin', '*');
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
},
});
// 安全头设置(必须在 CORS 之后)
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' }, // 允许跨域资源访问
}),
);
// 速率限制 // 速率限制
app.use( app.use(
rateLimit({ rateLimit({
windowMs: 60 * 1000, // 1 分钟 windowMs: 60 * 1000, // 1 分钟
max: 100, // 限制每个 IP 在 windowMs 时间内最多 10000 个请求 max: 100, // 限制每个 IP 在 windowMs 时间内最多 100 个请求
}), }),
); );
// 请求体解析(设置大小限制为 10mb) // 请求体解析(设置大小限制为 10mb)
app.use(bodyParser.json({ limit: '10mb' })); app.use(bodyParser.json({ limit: '10mb' }));
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true })); app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
app.enableCors();
/* app.enableCors({
// 允许的域名
origin: ['http://localhost:3200'],
}); */
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
// 响应压缩(在 helmet 之后) // 响应压缩(在 helmet 之后)
app.use(compression()); app.use(compression());
@@ -0,0 +1,67 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { User } from '../../user/user.entity';
/**
* 资源所有者或管理员权限 Guard
*
* 用途:检查用户是否有权限访问资源
* - 管理员(admin、super_admin)可以访问任何资源
* - 普通用户只能访问自己的资源(通过比较 userId)
*
* 使用场景:
* - 查询用户信息:管理员可以查询任何用户,普通用户只能查询自己
* - 更新用户信息:管理员可以更新任何用户,普通用户只能更新自己
* - 删除用户:管理员可以删除任何用户,普通用户只能删除自己
*
* 使用示例:
* @Get(':id')
* @UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
* findOneById(@Param('id') id: string) { ... }
*/
@Injectable()
export class OwnerOrAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{
user?: User;
params: { id?: string };
}>();
const user = request.user;
if (!user) {
throw new ForbiddenException('未授权访问');
}
// 获取请求的资源ID(从路由参数中)
const requestedId = request.params?.id;
if (!requestedId) {
// 如果没有提供资源ID,只允许管理员访问
const isAdmin =
user.role === 'admin' || user.role === 'super_admin';
if (!isAdmin) {
throw new ForbiddenException('权限不足,需要管理员权限');
}
return true;
}
const requestedUserId = +requestedId;
// 检查权限:管理员可以访问任何资源,普通用户只能访问自己的资源
const isAdmin = user.role === 'admin' || user.role === 'super_admin';
const isOwner = user.userId === requestedUserId;
if (!isAdmin && !isOwner) {
throw new ForbiddenException(
'权限不足,只能访问自己的资源或需要管理员权限',
);
}
return true;
}
}
@@ -9,14 +9,25 @@ import {
Query, Query,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import { BrokerService } from './broker.service'; import { BrokerService } from './broker.service';
import { CreateBrokerDto } from './dto/create-broker.dto'; import { CreateBrokerDto } from './dto/create-broker.dto';
import { UpdateBrokerDto } from './dto/update-broker.dto'; import { UpdateBrokerDto } from './dto/update-broker.dto';
import { QueryBrokerDto } from './dto/query-broker.dto'; import { QueryBrokerDto } from './dto/query-broker.dto';
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto'; import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
import { PaginatedBrokerData } from './dto/paginated-response.dto';
import { Broker } from './broker.entity'; import { Broker } from './broker.entity';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('broker') @ApiTags('broker')
@Controller('broker') @Controller('broker')
@@ -27,6 +38,9 @@ export class BrokerController {
* 单独创建 broker * 单独创建 broker
*/ */
@Post() @Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '创建券商', description: '创建单个券商信息' }) @ApiOperation({ summary: '创建券商', description: '创建单个券商信息' })
@ApiResponse({ @ApiResponse({
@@ -44,6 +58,9 @@ export class BrokerController {
* 批量创建 broker * 批量创建 broker
*/ */
@Post('batch') @Post('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ @ApiOperation({
summary: '批量创建券商', summary: '批量创建券商',
@@ -63,9 +80,9 @@ export class BrokerController {
} }
/** /**
* 查询 broker(支持多种查询条件) * 查询 broker(支持多种查询条件和分页
* 支持按 broker_id、broker_code、broker_name、region 查询 * 支持按 broker_id、broker_code、broker_name、region 查询
* 返回一个或多个 broker * 返回分页数据
*/ */
@Get() @Get()
@ApiOperation({ @ApiOperation({
@@ -75,9 +92,9 @@ export class BrokerController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: '查询成功', description: '查询成功',
type: [Broker], type: PaginatedBrokerData,
}) })
findAll(@Query() queryDto: QueryBrokerDto): Promise<Broker[]> { findAll(@Query() queryDto: QueryBrokerDto): Promise<PaginatedBrokerData> {
return this.brokerService.findAll(queryDto); return this.brokerService.findAll(queryDto);
} }
@@ -104,6 +121,9 @@ export class BrokerController {
* 更新 broker * 更新 broker
*/ */
@Patch(':id') @Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: '更新券商', summary: '更新券商',
description: '更新券商的部分或全部信息', description: '更新券商的部分或全部信息',
@@ -127,6 +147,9 @@ export class BrokerController {
* 删除 broker * 删除 broker
*/ */
@Delete(':id') @Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ @ApiOperation({
summary: '删除券商', summary: '删除券商',
+2 -1
View File
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BrokerService } from './broker.service'; import { BrokerService } from './broker.service';
import { BrokerController } from './broker.controller'; import { BrokerController } from './broker.controller';
import { Broker } from './broker.entity'; import { Broker } from './broker.entity';
import { StorageModule } from '../storage/storage.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Broker])], imports: [TypeOrmModule.forFeature([Broker]), StorageModule],
controllers: [BrokerController], controllers: [BrokerController],
providers: [BrokerService], providers: [BrokerService],
exports: [BrokerService], exports: [BrokerService],
+77 -7
View File
@@ -2,6 +2,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ConflictException, ConflictException,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm'; import { Repository, FindOptionsWhere } from 'typeorm';
@@ -10,12 +11,17 @@ import { CreateBrokerDto } from './dto/create-broker.dto';
import { UpdateBrokerDto } from './dto/update-broker.dto'; import { UpdateBrokerDto } from './dto/update-broker.dto';
import { QueryBrokerDto } from './dto/query-broker.dto'; import { QueryBrokerDto } from './dto/query-broker.dto';
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto'; import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
import { StorageService } from '../storage/storage.service';
@Injectable() @Injectable()
export class BrokerService { export class BrokerService {
private readonly logger = new Logger(BrokerService.name);
constructor( constructor(
@InjectRepository(Broker) @InjectRepository(Broker)
private readonly brokerRepository: Repository<Broker>, private readonly brokerRepository: Repository<Broker>,
private readonly storageService: StorageService,
) {} ) {}
/** /**
@@ -109,9 +115,12 @@ export class BrokerService {
} }
/** /**
* 查询 broker(支持多种查询条件) * 查询 broker(支持多种查询条件和分页
*/ */
async findAll(queryDto: QueryBrokerDto): Promise<Broker[]> { async findAll(queryDto: QueryBrokerDto): Promise<{
list: Broker[];
pagination: PaginationInfo;
}> {
const where: FindOptionsWhere<Broker> = {}; const where: FindOptionsWhere<Broker> = {};
if (queryDto.brokerId) { if (queryDto.brokerId) {
@@ -134,13 +143,53 @@ export class BrokerService {
where.isActive = queryDto.isActive; where.isActive = queryDto.isActive;
} }
return this.brokerRepository.find({ // 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
// 排序字段映射
const sortBy = queryDto.sortBy || 'createdAt';
const sortOrder = queryDto.sortOrder || 'DESC';
// 构建排序对象
const order: Record<string, 'ASC' | 'DESC'> = {};
if (sortBy === 'createdAt') {
order.createdAt = sortOrder;
} else if (sortBy === 'sortOrder') {
order.sortOrder = sortOrder;
} else {
order.createdAt = 'DESC';
}
// 添加默认排序
if (sortBy !== 'sortOrder') {
order.sortOrder = 'ASC';
}
order.brokerId = 'ASC';
// 查询总数
const total = await this.brokerRepository.count({ where });
// 查询分页数据
const list = await this.brokerRepository.find({
where, where,
order: { order,
sortOrder: 'ASC', skip,
brokerId: 'ASC', take: limit,
},
}); });
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
} }
/** /**
@@ -246,9 +295,30 @@ export class BrokerService {
/** /**
* 删除 broker * 删除 broker
* 同时删除券商Logo图片
*/ */
async remove(id: number): Promise<void> { async remove(id: number): Promise<void> {
const broker = await this.findOne(id); const broker = await this.findOne(id);
// 删除券商Logo图片
if (broker.brokerImage) {
try {
const imagePath = this.storageService.extractStoragePath(
broker.brokerImage,
);
if (imagePath) {
await this.storageService.delete(imagePath);
this.logger.log(`已删除券商Logo: ${imagePath}`);
}
} catch (error) {
// 图片删除失败不影响券商删除操作
this.logger.warn(
`删除券商Logo失败: ${broker.brokerImage}`,
error,
);
}
}
await this.brokerRepository.remove(broker); await this.brokerRepository.remove(broker);
} }
} }
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Broker } from '../broker.entity';
import { PaginationInfo } from '@/common/dto/pagination.dto';
/**
* 券商分页响应数据
*/
export class PaginatedBrokerData {
@ApiProperty({ description: '券商列表', type: [Broker] })
list: Broker[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}
@@ -0,0 +1,84 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
IsDateString,
IsIn,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePositionChangeDto {
@ApiProperty({
description: '持仓ID',
example: 1,
})
@Type(() => Number)
@IsNumber()
@IsNotEmpty()
positionId: number;
@ApiProperty({
description: '变更日期',
example: '2024-01-01',
})
@IsDateString()
@IsNotEmpty()
changeDate: string;
@ApiProperty({
description: '变更类型',
example: 'buy',
enum: ['buy', 'sell', 'auto'],
})
@IsString()
@IsNotEmpty()
@IsIn(['buy', 'sell', 'auto'])
changeType: string;
@ApiProperty({
description: '变更前份额/数量',
example: 100,
})
@Type(() => Number)
@IsNumber()
@Min(0)
beforeShares: number;
@ApiProperty({
description: '变更前成本价',
example: 1600.0,
})
@Type(() => Number)
@IsNumber()
@Min(0.0001)
beforeCostPrice: number;
@ApiProperty({
description: '变更后份额/数量',
example: 150,
})
@Type(() => Number)
@IsNumber()
@Min(0)
afterShares: number;
@ApiProperty({
description: '变更后成本价',
example: 1650.0,
})
@Type(() => Number)
@IsNumber()
@Min(0.0001)
afterCostPrice: number;
@ApiPropertyOptional({
description: '备注/思考',
example: '加仓买入,看好长期走势',
})
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { PositionChange } from '../position-change.entity';
import { PaginationInfo } from '@/common/dto/pagination.dto';
/**
* 持仓变更记录分页响应数据
*/
export class PaginatedPositionChangeData {
@ApiProperty({
description: '持仓变更记录列表',
type: [PositionChange],
})
list: PositionChange[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}
@@ -0,0 +1,29 @@
import { IsOptional, IsNumber, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class QueryPositionChangeDto {
@ApiPropertyOptional({
description: '页码',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量',
example: 10,
minimum: 1,
default: 10,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number = 10;
}
@@ -0,0 +1,12 @@
import { IsOptional, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdatePositionChangeDto {
@ApiPropertyOptional({
description: '备注/思考',
example: '加仓买入,看好长期走势',
})
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,120 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Query,
HttpCode,
HttpStatus,
UseGuards,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import { PositionChangeService } from './position-change.service';
import { CreatePositionChangeDto } from './dto/create-position-change.dto';
import { UpdatePositionChangeDto } from './dto/update-position-change.dto';
import { QueryPositionChangeDto } from './dto/query-position-change.dto';
import { PositionChange } from './position-change.entity';
import { PaginatedPositionChangeData } from './dto/paginated-response.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { User } from '../user/user.entity';
@ApiTags('position-change')
@Controller('position-change')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class PositionChangeController {
constructor(
private readonly positionChangeService: PositionChangeService,
) {}
/**
* 分页查询单个持仓的所有变更记录
*/
@Get('position/:positionId')
@ApiOperation({
summary: '查询单个持仓的所有变更记录',
description: '分页查询指定持仓的所有变更记录,按变更日期倒序',
})
@ApiParam({ name: 'positionId', description: '持仓ID', type: Number })
@ApiResponse({
status: 200,
description: '查询成功',
type: PaginatedPositionChangeData,
})
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 404, description: '持仓不存在' })
findAllByPositionId(
@Request() req: { user: User },
@Param('positionId') positionId: string,
@Query() queryDto: QueryPositionChangeDto,
): Promise<PaginatedPositionChangeData> {
return this.positionChangeService.findAllByPositionId(
+positionId,
req.user.userId,
queryDto,
);
}
/**
* 创建持仓变更记录
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '创建持仓变更记录',
description: '为指定持仓创建新的变更记录',
})
@ApiResponse({
status: 201,
description: '创建成功',
type: PositionChange,
})
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 404, description: '持仓不存在' })
create(
@Request() req: { user: User },
@Body() createPositionChangeDto: CreatePositionChangeDto,
): Promise<PositionChange> {
return this.positionChangeService.create(
req.user.userId,
createPositionChangeDto,
);
}
/**
* 更新持仓变更记录的备注/思考
*/
@Patch(':id')
@ApiOperation({
summary: '更新持仓变更记录的备注',
description: '更新指定变更记录的备注/思考内容',
})
@ApiParam({ name: 'id', description: '变更记录ID', type: Number })
@ApiResponse({
status: 200,
description: '更新成功',
type: PositionChange,
})
@ApiResponse({ status: 404, description: '变更记录不存在' })
@ApiResponse({ status: 403, description: '无权访问' })
update(
@Request() req: { user: User },
@Param('id') id: string,
@Body() updatePositionChangeDto: UpdatePositionChangeDto,
): Promise<PositionChange> {
return this.positionChangeService.update(
+id,
req.user.userId,
updatePositionChangeDto,
);
}
}
@@ -0,0 +1,105 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@Entity('position_changes')
export class PositionChange {
@ApiProperty({ description: '变更记录ID', example: 1 })
@PrimaryGeneratedColumn({ name: 'change_id' })
changeId: number;
@ApiProperty({
description: '持仓ID',
example: 1,
})
@Column({ name: 'position_id', type: 'bigint' })
@Index()
positionId: number;
@ApiProperty({
description: '变更日期',
example: '2024-01-01',
})
@Column({ name: 'change_date', type: 'date' })
@Index()
changeDate: Date;
@ApiProperty({
description: '变更类型',
example: 'buy',
enum: ['buy', 'sell', 'auto'],
})
@Column({
name: 'change_type',
type: 'varchar',
length: 20,
})
changeType: string;
@ApiProperty({
description: '变更前份额/数量',
example: 100,
})
@Column({
name: 'before_shares',
type: 'decimal',
precision: 18,
scale: 4,
})
beforeShares: number;
@ApiProperty({
description: '变更前成本价',
example: 1600.0,
})
@Column({
name: 'before_cost_price',
type: 'decimal',
precision: 18,
scale: 4,
})
beforeCostPrice: number;
@ApiProperty({
description: '变更后份额/数量',
example: 150,
})
@Column({
name: 'after_shares',
type: 'decimal',
precision: 18,
scale: 4,
})
afterShares: number;
@ApiProperty({
description: '变更后成本价',
example: 1650.0,
})
@Column({
name: 'after_cost_price',
type: 'decimal',
precision: 18,
scale: 4,
})
afterCostPrice: number;
@ApiPropertyOptional({
description: '备注/思考',
example: '加仓买入,看好长期走势',
})
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
@ApiProperty({
description: '创建时间',
example: '2024-01-01T00:00:00.000Z',
})
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PositionChangeService } from './position-change.service';
import { PositionChangeController } from './position-change.controller';
import { PositionChange } from './position-change.entity';
import { Position } from '../position/position.entity';
@Module({
imports: [TypeOrmModule.forFeature([PositionChange, Position])],
controllers: [PositionChangeController],
providers: [PositionChangeService],
exports: [PositionChangeService],
})
export class PositionChangeModule {}
@@ -0,0 +1,149 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PositionChange } from './position-change.entity';
import { CreatePositionChangeDto } from './dto/create-position-change.dto';
import { UpdatePositionChangeDto } from './dto/update-position-change.dto';
import { QueryPositionChangeDto } from './dto/query-position-change.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
import { Position } from '../position/position.entity';
@Injectable()
export class PositionChangeService {
private readonly logger = new Logger(PositionChangeService.name);
constructor(
@InjectRepository(PositionChange)
private readonly positionChangeRepository: Repository<PositionChange>,
@InjectRepository(Position)
private readonly positionRepository: Repository<Position>,
) {}
/**
* 分页查询单个持仓的所有变更记录
*/
async findAllByPositionId(
positionId: number,
userId: number,
queryDto: QueryPositionChangeDto,
): Promise<{
list: PositionChange[];
pagination: PaginationInfo;
}> {
// 验证持仓是否属于当前用户
const position = await this.positionRepository.findOne({
where: { positionId, userId },
});
if (!position) {
throw new NotFoundException(`持仓不存在:ID ${positionId}`);
}
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
// 查询总数
const total = await this.positionChangeRepository.count({
where: { positionId },
});
// 查询分页数据(按变更日期和创建时间倒序)
const list = await this.positionChangeRepository.find({
where: { positionId },
order: {
changeDate: 'DESC',
createdAt: 'DESC',
},
skip,
take: limit,
});
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/**
* 创建持仓变更记录
*/
async create(
userId: number,
createPositionChangeDto: CreatePositionChangeDto,
): Promise<PositionChange> {
// 验证持仓是否属于当前用户
const position = await this.positionRepository.findOne({
where: {
positionId: createPositionChangeDto.positionId,
userId,
},
});
if (!position) {
throw new NotFoundException(
`持仓不存在:ID ${createPositionChangeDto.positionId}`,
);
}
// 创建变更记录
const positionChange = this.positionChangeRepository.create({
...createPositionChangeDto,
changeDate: new Date(createPositionChangeDto.changeDate),
});
return this.positionChangeRepository.save(positionChange);
}
/**
* 更新持仓变更记录的备注/思考
*/
async update(
changeId: number,
userId: number,
updatePositionChangeDto: UpdatePositionChangeDto,
): Promise<PositionChange> {
// 查找变更记录
const positionChange = await this.positionChangeRepository.findOne({
where: { changeId },
relations: [], // 不加载关联,后面手动验证
});
if (!positionChange) {
throw new NotFoundException(`变更记录不存在:ID ${changeId}`);
}
// 验证持仓是否属于当前用户
const position = await this.positionRepository.findOne({
where: {
positionId: positionChange.positionId,
userId,
},
});
if (!position) {
throw new ForbiddenException('无权访问该持仓的变更记录');
}
// 只更新备注字段
if (updatePositionChangeDto.notes !== undefined) {
positionChange.notes = updatePositionChangeDto.notes;
}
return this.positionChangeRepository.save(positionChange);
}
}
@@ -0,0 +1,133 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
IsBoolean,
MaxLength,
IsIn,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePositionDto {
@ApiPropertyOptional({
description: '券商ID(可选)',
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
brokerId?: number;
@ApiProperty({
description: '资产类型',
example: 'stock',
enum: ['stock', 'fund', 'cash', 'bond', 'other'],
})
@IsString()
@IsNotEmpty()
@IsIn(['stock', 'fund', 'cash', 'bond', 'other'])
assetType: string;
@ApiPropertyOptional({
description: '资产代码(股票代码、基金代码等),现金和其他类型可为空',
example: '600519',
maxLength: 50,
})
@IsOptional()
@IsString()
@MaxLength(50)
symbol?: string;
@ApiProperty({
description: '资产名称,现金类型固定为"现金"',
example: '贵州茅台',
maxLength: 100,
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiPropertyOptional({
description: '市场(A股/港股/美股等)',
example: 'sh',
maxLength: 20,
})
@IsOptional()
@IsString()
@MaxLength(20)
market?: string;
@ApiProperty({
description: '持仓份额/数量',
example: 100,
})
@Type(() => Number)
@IsNumber()
@Min(0)
shares: number;
@ApiProperty({
description: '成本价(每股/每份)',
example: 1600.0,
})
@Type(() => Number)
@IsNumber()
@Min(0.0001)
costPrice: number;
@ApiPropertyOptional({
description: '最新市场价(系统自动更新)',
example: 1850.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
currentPrice?: number;
@ApiPropertyOptional({
description: '货币类型',
example: 'CNY',
default: 'CNY',
maxLength: 10,
})
@IsOptional()
@IsString()
@MaxLength(10)
currency?: string;
@ApiPropertyOptional({
description: '汇率(用于多货币)',
example: 1.0,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
exchangeRate?: number;
@ApiPropertyOptional({
description: '是否自动更新价格(付费用户功能)',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
autoPriceUpdate?: boolean;
@ApiPropertyOptional({
description: '状态',
example: 'active',
enum: ['active', 'suspended', 'delisted'],
default: 'active',
})
@IsOptional()
@IsString()
@IsIn(['active', 'suspended', 'delisted'])
status?: string;
}
@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Position } from '../position.entity';
export class PositionResponseDto extends Position {
@ApiProperty({
description: '持仓成本(持仓份额 × 成本价)',
example: 160000.0,
})
costValue: number;
@ApiProperty({
description: '持仓市值(持仓份额 × 最新市场价)',
example: 185000.0,
})
marketValue: number;
@ApiProperty({
description: '持仓盈亏(持仓市值 - 持仓成本)',
example: 25000.0,
})
profit: number;
@ApiProperty({
description: '持仓盈利比例(%',
example: 15.625,
})
profitPercent: number;
@ApiProperty({
description: '持仓天数(当前时间 - 创建时间)',
example: 365,
})
holdingDays: number;
@ApiProperty({
description: '当前持仓占用户总资产的百分比(%)',
example: 25.5,
})
assetPercent: number;
}
@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class SearchAssetDto {
@ApiProperty({
description: '搜索关键词(股票代码或名称)',
example: '600519',
maxLength: 50,
})
@IsString()
@IsNotEmpty()
@MaxLength(50)
keyword: string;
@ApiProperty({
description: '资产类型过滤(可选)',
example: 'stock',
enum: ['stock', 'fund', 'bond'],
required: false,
})
@IsOptional()
@IsString()
assetType?: string;
@ApiProperty({
description: '返回结果数量限制',
example: 10,
default: 10,
required: false,
})
@IsOptional()
limit?: number;
}
export class AssetSearchResult {
@ApiProperty({ description: '股票代码', example: '600519' })
symbol: string;
@ApiProperty({ description: '股票名称', example: '贵州茅台' })
name: string;
@ApiProperty({ description: '市场代码', example: 'sh' })
market: string;
@ApiProperty({ description: '资产类型', example: 'stock' })
assetType: string;
}
@@ -0,0 +1,73 @@
import {
IsString,
IsOptional,
IsNumber,
IsIn,
Min,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdatePositionDto {
@ApiPropertyOptional({
description: '券商ID',
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
brokerId?: number;
@ApiPropertyOptional({
description: '成本价(每股/每份)',
example: 1600.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.0001)
costPrice?: number;
@ApiPropertyOptional({
description: '最新市场价(系统自动更新)',
example: 1850.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
currentPrice?: number;
@ApiPropertyOptional({
description: '货币类型',
example: 'CNY',
maxLength: 10,
})
@IsOptional()
@IsString()
@MaxLength(10)
currency?: string;
@ApiPropertyOptional({
description: '汇率(用于多货币)',
example: 1.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
exchangeRate?: number;
@ApiPropertyOptional({
description: '状态',
example: 'active',
enum: ['active', 'suspended', 'delisted'],
})
@IsOptional()
@IsString()
@IsIn(['active', 'suspended', 'delisted'])
status?: string;
// 注意:assetType, symbol, name, market 字段不允许更新
}
@@ -0,0 +1,168 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
HttpCode,
HttpStatus,
UseGuards,
Request,
Query,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { PositionService } from './position.service';
import { CreatePositionDto } from './dto/create-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto';
import { PositionResponseDto } from './dto/position-response.dto';
import { AssetSearchResult } from './dto/search-asset.dto';
import { Position } from './position.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { User } from '../user/user.entity';
@ApiTags('position')
@Controller('position')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class PositionController {
constructor(private readonly positionService: PositionService) {}
/**
* 搜索资产(股票代码或名称)
*/
@Get('search')
@ApiOperation({
summary: '搜索资产',
description: '根据关键词搜索股票代码或名称,支持字符串匹配',
})
@ApiQuery({
name: 'keyword',
description: '搜索关键词(股票代码或名称)',
example: '600519',
required: true,
})
@ApiQuery({
name: 'assetType',
description: '资产类型过滤(可选)',
example: 'stock',
enum: ['stock', 'fund', 'bond'],
required: false,
})
@ApiQuery({
name: 'limit',
description: '返回结果数量限制',
example: 10,
required: false,
})
@ApiResponse({
status: 200,
description: '搜索成功',
type: [AssetSearchResult],
})
@ApiResponse({ status: 400, description: '请求参数错误' })
async searchAssets(
@Query('keyword') keyword: string,
@Query('assetType') assetType?: string,
@Query('limit') limit?: number,
): Promise<AssetSearchResult[]> {
return this.positionService.searchAssets(keyword, assetType, limit);
}
/**
* 查询用户所有持仓(包含计算字段)
*/
@Get()
@ApiOperation({
summary: '查询用户所有持仓',
description:
'查询当前登录用户的所有持仓信息,包含计算字段:持仓成本、持仓市值、持仓盈亏、持仓盈利比例、持仓天数、占用户总资产百分比',
})
@ApiResponse({
status: 200,
description: '查询成功',
type: [PositionResponseDto],
})
@ApiResponse({ status: 401, description: '未授权' })
findAll(@Request() req: { user: User }): Promise<PositionResponseDto[]> {
return this.positionService.findAllByUserId(req.user.userId);
}
/**
* 创建持仓
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '创建持仓',
description: '为当前登录用户创建新的持仓记录',
})
@ApiResponse({
status: 201,
description: '创建成功',
type: Position,
})
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 409, description: '持仓已存在' })
create(
@Request() req: { user: User },
@Body() createPositionDto: CreatePositionDto,
): Promise<Position> {
return this.positionService.create(req.user.userId, createPositionDto);
}
/**
* 更新持仓
*/
@Patch(':id')
@ApiOperation({
summary: '更新持仓',
description:
'更新持仓信息,只能更新:成本价、最新市场价、货币类型、券商、状态、汇率。资产类型、资产代码和名称、市场不可修改。',
})
@ApiParam({ name: 'id', description: '持仓ID', type: Number })
@ApiResponse({
status: 200,
description: '更新成功',
type: Position,
})
@ApiResponse({ status: 404, description: '持仓不存在' })
update(
@Request() req: { user: User },
@Param('id') id: string,
@Body() updatePositionDto: UpdatePositionDto,
): Promise<Position> {
return this.positionService.update(
+id,
req.user.userId,
updatePositionDto,
);
}
/**
* 删除持仓
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: '删除持仓',
description: '删除指定的持仓记录',
})
@ApiParam({ name: 'id', description: '持仓ID', type: Number })
@ApiResponse({ status: 204, description: '删除成功' })
@ApiResponse({ status: 404, description: '持仓不存在' })
remove(
@Request() req: { user: User },
@Param('id') id: string,
): Promise<void> {
return this.positionService.remove(+id, req.user.userId);
}
}
@@ -0,0 +1,228 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@Entity('positions')
@Unique(['userId', 'brokerId', 'symbol', 'market', 'assetType'])
export class Position {
@ApiProperty({ description: '持仓ID', example: 1 })
@PrimaryGeneratedColumn({ name: 'position_id' })
positionId: number;
@ApiProperty({
description: '用户ID',
example: 1,
})
@Column({
name: 'user_id',
type: 'bigint',
transformer: {
to: (value: number) => value,
from: (value: string) => (value ? parseInt(value, 10) : null),
},
})
@Index()
userId: number;
@ApiPropertyOptional({
description: '券商ID(可选)',
example: 1,
})
@Column({
name: 'broker_id',
type: 'bigint',
nullable: true,
transformer: {
to: (value: number | null | undefined) => value ?? null,
from: (value: string | null) =>
value ? parseInt(value, 10) : null,
},
})
@Index()
brokerId?: number;
@ApiProperty({
description: '资产类型',
example: 'stock',
enum: ['stock', 'fund', 'cash', 'bond', 'other'],
})
@Column({
name: 'asset_type',
type: 'varchar',
length: 20,
})
@Index()
assetType: string;
@ApiProperty({
description: '资产代码(股票代码、基金代码等)',
example: '600519',
maxLength: 50,
})
@Column({ name: 'symbol', type: 'varchar', length: 50 })
@Index()
symbol: string;
@ApiProperty({
description: '资产名称',
example: '贵州茅台',
maxLength: 100,
})
@Column({ name: 'name', type: 'varchar', length: 100 })
name: string;
@ApiPropertyOptional({
description: '市场(A股/港股/美股等)',
example: 'sh',
maxLength: 20,
})
@Column({ name: 'market', type: 'varchar', length: 20, nullable: true })
market?: string;
@ApiProperty({
description: '持仓份额/数量',
example: 100,
})
@Column({
name: 'shares',
type: 'decimal',
precision: 18,
scale: 4,
default: 0,
transformer: {
to: (value: number) => value,
from: (value: string) => (value ? parseFloat(value) : 0),
},
})
shares: number;
@ApiProperty({
description: '成本价(每股/每份)',
example: 1600.0,
})
@Column({
name: 'cost_price',
type: 'decimal',
precision: 18,
scale: 4,
transformer: {
to: (value: number) => value,
from: (value: string) => (value ? parseFloat(value) : null),
},
})
costPrice: number;
@ApiPropertyOptional({
description: '最新市场价(系统自动更新)',
example: 1850.0,
})
@Column({
name: 'current_price',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true,
transformer: {
to: (value: number | undefined) => value,
from: (value: string | null) => (value ? parseFloat(value) : null),
},
})
currentPrice?: number;
@ApiPropertyOptional({
description: '上一次的价格(用于对比显示红绿色)',
example: 1800.0,
})
@Column({
name: 'previous_price',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true,
transformer: {
to: (value: number | undefined) => value,
from: (value: string | null) => (value ? parseFloat(value) : null),
},
})
previousPrice?: number;
@ApiProperty({
description: '货币类型',
example: 'CNY',
default: 'CNY',
maxLength: 10,
})
@Column({
name: 'currency',
type: 'varchar',
length: 10,
default: 'CNY',
})
currency: string;
@ApiPropertyOptional({
description: '汇率(用于多货币)',
example: 1.0,
default: 1,
})
@Column({
name: 'exchange_rate',
type: 'decimal',
precision: 10,
scale: 6,
default: 1,
transformer: {
to: (value: number | undefined) => value,
from: (value: string | null) => (value ? parseFloat(value) : 1),
},
})
exchangeRate?: number;
@ApiProperty({
description: '是否自动更新价格(付费用户功能)',
example: false,
default: false,
})
@Column({
name: 'auto_price_update',
type: 'boolean',
default: false,
})
autoPriceUpdate: boolean;
@ApiProperty({
description: '状态',
example: 'active',
enum: ['active', 'suspended', 'delisted'],
default: 'active',
})
@Column({
name: 'status',
type: 'varchar',
length: 20,
default: 'active',
})
@Index()
status: string;
@ApiProperty({
description: '创建时间',
example: '2024-01-01T00:00:00.000Z',
})
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ApiProperty({
description: '更新时间',
example: '2024-01-01T00:00:00.000Z',
})
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PositionService } from './position.service';
import { PositionController } from './position.controller';
import { Position } from './position.entity';
@Module({
imports: [TypeOrmModule.forFeature([Position])],
controllers: [PositionController],
providers: [PositionService],
exports: [PositionService],
})
export class PositionModule {}
@@ -0,0 +1,359 @@
import {
Injectable,
NotFoundException,
ConflictException,
Logger,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, FindOptionsWhere } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { readFileSync } from 'fs';
import { join } from 'path';
import { Position } from './position.entity';
import { CreatePositionDto } from './dto/create-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto';
import { PositionResponseDto } from './dto/position-response.dto';
import { AssetSearchResult } from './dto/search-asset.dto';
@Injectable()
export class PositionService {
private readonly logger = new Logger(PositionService.name);
private stockDataCache: Record<string, string> | null = null;
private stockDataCacheTime: number = 0;
private readonly CACHE_TTL = 60 * 60 * 1000; // 缓存1小时
constructor(
@InjectRepository(Position)
private readonly positionRepository: Repository<Position>,
private readonly configService: ConfigService,
) {}
/**
* 从HTTP地址加载股票数据
*/
private async loadStockData(): Promise<Record<string, string>> {
const now = Date.now();
// 如果缓存有效,直接返回
if (
this.stockDataCache &&
now - this.stockDataCacheTime < this.CACHE_TTL
) {
return this.stockDataCache;
}
try {
// 从本地文件系统读取(uploads目录已配置为静态文件服务)
// 获取uploads目录路径
const storagePath =
this.configService.get<string>('STORAGE_PATH') || './uploads';
const stockDataPath = join(
process.cwd(),
storagePath,
'stock',
'stock-data.json',
);
this.logger.log(`正在从 ${stockDataPath} 加载股票数据...`);
// 读取文件
const fileContent = readFileSync(stockDataPath, 'utf-8');
const data = JSON.parse(fileContent);
// 更新缓存
this.stockDataCache = data;
this.stockDataCacheTime = now;
this.logger.log(
`股票数据加载成功,共 ${Object.keys(data).length} 个市场`,
);
return data;
} catch (error) {
this.logger.error(
`加载股票数据失败: ${error.message}`,
error.stack,
);
throw new BadRequestException(`加载股票数据失败: ${error.message}`);
}
}
/**
* 搜索资产(股票代码或名称)
*/
async searchAssets(
keyword: string,
assetType?: string,
limit: number = 10,
): Promise<AssetSearchResult[]> {
if (!keyword || keyword.trim().length === 0) {
return [];
}
try {
const stockData = await this.loadStockData();
const results: AssetSearchResult[] = [];
const keywordLower = keyword.toLowerCase().trim();
// 市场映射:市场代码 -> 资产类型
const marketToAssetType: Record<string, string> = {
sh: 'stock',
sz: 'stock',
bj: 'stock',
hk: 'stock',
us: 'stock',
};
// 遍历所有市场
for (const [market, stockList] of Object.entries(stockData)) {
// 如果指定了资产类型,跳过不匹配的市场
if (assetType && marketToAssetType[market] !== assetType) {
continue;
}
// 解析股票列表(格式:代码_名称|代码_名称|...)
const stocks = stockList.split('|');
for (const stock of stocks) {
if (!stock || stock.trim().length === 0) {
continue;
}
const [symbol, ...nameParts] = stock.split('_');
const name = nameParts.join('_'); // 处理名称中可能包含下划线的情况
if (!symbol || !name) {
continue;
}
// 字符串匹配:代码或名称包含关键词(不区分大小写)
const symbolMatch = symbol
.toLowerCase()
.includes(keywordLower);
const nameMatch = name.toLowerCase().includes(keywordLower);
if (symbolMatch || nameMatch) {
// 计算匹配度(完全匹配 > 前缀匹配 > 包含匹配)
let score = 0;
if (symbol.toLowerCase() === keywordLower) {
score = 100; // 代码完全匹配
} else if (name.toLowerCase() === keywordLower) {
score = 90; // 名称完全匹配
} else if (
symbol.toLowerCase().startsWith(keywordLower)
) {
score = 80; // 代码前缀匹配
} else if (
name.toLowerCase().startsWith(keywordLower)
) {
score = 70; // 名称前缀匹配
} else {
score = symbolMatch ? 60 : 50; // 包含匹配
}
results.push({
symbol,
name,
market,
assetType: marketToAssetType[market] || 'stock',
score, // 用于排序(内部使用)
} as AssetSearchResult & { score: number });
}
}
}
// 按匹配度排序,然后限制结果数量
const sortedResults = (
results as (AssetSearchResult & { score: number })[]
)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ score, ...rest }) => rest as AssetSearchResult); // 移除score字段
return sortedResults;
} catch (error) {
this.logger.error(`搜索资产失败: ${error.message}`, error.stack);
throw new BadRequestException(`搜索资产失败: ${error.message}`);
}
}
/**
* 查询用户所有持仓(包含计算字段)
*/
async findAllByUserId(userId: number): Promise<PositionResponseDto[]> {
// 查询用户所有持仓
const positions = await this.positionRepository.find({
where: { userId },
order: { createdAt: 'DESC' },
});
// 计算用户总资产(所有active持仓的市值总和)
const totalAsset = await this.calculateUserTotalAsset(userId);
// 计算每个持仓的额外字段
const result: PositionResponseDto[] = positions.map((position) => {
const costValue = position.shares * position.costPrice;
const marketValue = position.currentPrice
? position.shares * position.currentPrice
: 0;
const profit = marketValue - costValue;
const profitPercent =
costValue > 0 ? (profit / costValue) * 100 : 0;
const holdingDays = Math.floor(
(Date.now() - position.createdAt.getTime()) /
(1000 * 60 * 60 * 24),
);
const assetPercent =
totalAsset > 0 ? (marketValue / totalAsset) * 100 : 0;
return {
...position,
costValue,
marketValue,
profit,
profitPercent,
holdingDays,
assetPercent,
};
});
return result;
}
/**
* 计算用户总资产(所有active持仓的市值总和)
*/
private async calculateUserTotalAsset(userId: number): Promise<number> {
const positions = await this.positionRepository.find({
where: { userId, status: 'active' },
});
let totalAsset = 0;
for (const position of positions) {
if (position.currentPrice) {
const marketValue = position.shares * position.currentPrice;
totalAsset += marketValue;
}
}
return totalAsset;
}
/**
* 创建持仓
*/
async create(
userId: number,
createPositionDto: CreatePositionDto,
): Promise<Position> {
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
const whereCondition: FindOptionsWhere<Position> = {
userId,
symbol: createPositionDto.symbol || '',
assetType: createPositionDto.assetType,
};
// 处理 brokerId 字段:如果为 undefined 或 null,查询 null 值
if (
createPositionDto.brokerId !== undefined &&
createPositionDto.brokerId !== null
) {
whereCondition.brokerId = createPositionDto.brokerId;
} else {
whereCondition.brokerId = IsNull();
}
// 处理 market 字段:如果为 undefined 或空字符串,查询 null 值
if (createPositionDto.market) {
whereCondition.market = createPositionDto.market;
} else {
whereCondition.market = IsNull();
}
const existing = await this.positionRepository.findOne({
where: whereCondition,
});
if (existing) {
throw new ConflictException(
`该持仓已存在:${createPositionDto.name} (${createPositionDto.symbol})`,
);
}
// 创建持仓
const position = this.positionRepository.create({
...createPositionDto,
userId,
currency: createPositionDto.currency || 'CNY',
exchangeRate: createPositionDto.exchangeRate || 1,
autoPriceUpdate: createPositionDto.autoPriceUpdate || false,
status: createPositionDto.status || 'active',
});
return this.positionRepository.save(position);
}
/**
* 更新持仓(只能更新允许的字段)
*/
async update(
positionId: number,
userId: number,
updatePositionDto: UpdatePositionDto,
): Promise<Position> {
// 查找持仓
const position = await this.positionRepository.findOne({
where: { positionId, userId },
});
if (!position) {
throw new NotFoundException(`持仓不存在:ID ${positionId}`);
}
// 只更新允许的字段
if (updatePositionDto.brokerId !== undefined) {
position.brokerId = updatePositionDto.brokerId;
}
if (updatePositionDto.costPrice !== undefined) {
position.costPrice = updatePositionDto.costPrice;
}
if (updatePositionDto.currentPrice !== undefined) {
// 更新价格时,将当前价格保存到 previous_price
if (
position.currentPrice !== null &&
position.currentPrice !== undefined
) {
position.previousPrice = position.currentPrice;
}
position.currentPrice = updatePositionDto.currentPrice;
}
if (updatePositionDto.currency !== undefined) {
position.currency = updatePositionDto.currency;
}
if (updatePositionDto.exchangeRate !== undefined) {
position.exchangeRate = updatePositionDto.exchangeRate;
}
if (updatePositionDto.status !== undefined) {
position.status = updatePositionDto.status;
}
// 注意:assetType, symbol, name, market 字段不允许更新
return this.positionRepository.save(position);
}
/**
* 删除持仓
*/
async remove(positionId: number, userId: number): Promise<void> {
const position = await this.positionRepository.findOne({
where: { positionId, userId },
});
if (!position) {
throw new NotFoundException(`持仓不存在:ID ${positionId}`);
}
await this.positionRepository.remove(position);
}
}
@@ -0,0 +1,29 @@
import { IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { CreateStockDailyPriceDto } from './create-stock-daily-price.dto';
export class BatchCreateStockDailyPriceDto {
@ApiProperty({
description: '股票每日价格列表',
type: [CreateStockDailyPriceDto],
example: [
{
stockCode: '600519',
stockName: '贵州茅台',
market: 'sh',
tradeDate: '2024-01-01',
openPrice: 1000.0,
closePrice: 1050.0,
highPrice: 1060.0,
lowPrice: 995.0,
volume: 1000000,
amount: 1050000000.0,
},
],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateStockDailyPriceDto)
prices: CreateStockDailyPriceDto[];
}
@@ -0,0 +1,177 @@
import {
IsString,
IsNotEmpty,
IsOptional,
MaxLength,
IsDateString,
IsNumber,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateStockDailyPriceDto {
@ApiProperty({
description: '股票代码',
example: '600519',
maxLength: 20,
})
@IsString()
@IsNotEmpty()
@MaxLength(20)
stockCode: string;
@ApiProperty({
description: '股票名称',
example: '贵州茅台',
maxLength: 100,
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
stockName: string;
@ApiProperty({
description: '市场标识',
example: 'sh',
maxLength: 20,
})
@IsString()
@IsNotEmpty()
@MaxLength(20)
market: string;
@ApiProperty({
description: '交易日期',
example: '2024-01-01',
})
@IsDateString()
@IsNotEmpty()
tradeDate: string;
@ApiPropertyOptional({
description: '开盘价',
example: 1000.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
openPrice?: number;
@ApiProperty({
description: '收盘价',
example: 1050.0,
})
@Type(() => Number)
@IsNumber()
@Min(0)
closePrice: number;
@ApiPropertyOptional({
description: '最高价',
example: 1060.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
highPrice?: number;
@ApiPropertyOptional({
description: '最低价',
example: 995.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
lowPrice?: number;
@ApiPropertyOptional({
description: '成交量(单位:手)',
example: 1000000,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
volume?: number;
@ApiPropertyOptional({
description: '成交额(单位:元)',
example: 1050000000.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
amount?: number;
@ApiPropertyOptional({
description: '涨跌额',
example: 50.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
changeAmount?: number;
@ApiPropertyOptional({
description: '涨跌幅(%',
example: 5.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
changePercent?: number;
@ApiPropertyOptional({
description: '60日涨跌幅(%',
example: 15.5,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
changePercent60day?: number;
@ApiPropertyOptional({
description: '年初至今涨跌幅(%',
example: 25.8,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
changePercentYtd?: number;
@ApiPropertyOptional({
description: '换手率(%',
example: 2.5,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
turnoverRate?: number;
@ApiPropertyOptional({
description: '市盈率',
example: 35.5,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
peRatio?: number;
@ApiPropertyOptional({
description: '市净率',
example: 8.5,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
pbRatio?: number;
@ApiPropertyOptional({
description: '总市值(单位:元)',
example: 2000000000000.0,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
marketCap?: number;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { StockDailyPrice } from '../stock-daily-price.entity';
import { PaginationInfo } from '@/common/dto/pagination.dto';
/**
* 股票每日价格分页响应数据
*/
export class PaginatedStockDailyPriceData {
@ApiProperty({
description: '股票每日价格列表',
type: [StockDailyPrice],
})
list: StockDailyPrice[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}
@@ -0,0 +1,102 @@
import {
IsOptional,
IsString,
IsNumber,
Min,
IsDateString,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class QueryStockDailyPriceDto {
@ApiPropertyOptional({
description: '股票代码',
example: '600519',
})
@IsOptional()
@IsString()
stockCode?: string;
@ApiPropertyOptional({
description: '股票名称(模糊查询)',
example: '茅台',
})
@IsOptional()
@IsString()
stockName?: string;
@ApiPropertyOptional({
description: '市场标识',
example: 'sh',
})
@IsOptional()
@IsString()
market?: string;
@ApiPropertyOptional({
description: '交易日期(模糊查询,支持日期范围)',
example: '2024-01',
})
@IsOptional()
@IsString()
tradeDate?: string;
@ApiPropertyOptional({
description: '起始日期',
example: '2024-01-01',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: '结束日期',
example: '2024-01-31',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: '页码',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量',
example: 10,
minimum: 1,
default: 10,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({
description: '排序字段',
example: 'tradeDate',
default: 'tradeDate',
})
@IsOptional()
@IsString()
sortBy?: string = 'tradeDate';
@ApiPropertyOptional({
description: '排序方向',
example: 'DESC',
enum: ['ASC', 'DESC'],
default: 'DESC',
})
@IsOptional()
@IsString()
sortOrder?: 'ASC' | 'DESC' = 'DESC';
}
@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateStockDailyPriceDto } from './create-stock-daily-price.dto';
export class UpdateStockDailyPriceDto extends PartialType(
CreateStockDailyPriceDto,
) {}
@@ -0,0 +1,259 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { StockDailyPriceService } from './stock-daily-price.service';
import { CreateStockDailyPriceDto } from './dto/create-stock-daily-price.dto';
import { UpdateStockDailyPriceDto } from './dto/update-stock-daily-price.dto';
import { QueryStockDailyPriceDto } from './dto/query-stock-daily-price.dto';
import { BatchCreateStockDailyPriceDto } from './dto/batch-create-stock-daily-price.dto';
import { PaginatedStockDailyPriceData } from './dto/paginated-response.dto';
import { StockDailyPrice } from './stock-daily-price.entity';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('stock-daily-price')
@Controller('stock-daily-price')
export class StockDailyPriceController {
constructor(
private readonly stockDailyPriceService: StockDailyPriceService,
) {}
/**
* 单独创建股票每日价格
*/
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '创建股票每日价格',
description: '创建单个股票的每日价格记录',
})
@ApiResponse({
status: 201,
description: '创建成功',
type: StockDailyPrice,
})
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 409, description: '价格记录已存在' })
create(
@Body() createStockDailyPriceDto: CreateStockDailyPriceDto,
): Promise<StockDailyPrice> {
return this.stockDailyPriceService.create(createStockDailyPriceDto);
}
/**
* 批量创建股票每日价格
*/
@Post('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '批量创建股票每日价格',
description: '一次性创建多个股票的每日价格记录',
})
@ApiResponse({
status: 201,
description: '批量创建成功',
type: [StockDailyPrice],
})
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 409, description: '存在重复的价格记录' })
batchCreate(
@Body() batchCreateStockDailyPriceDto: BatchCreateStockDailyPriceDto,
): Promise<StockDailyPrice[]> {
return this.stockDailyPriceService.batchCreate(
batchCreateStockDailyPriceDto,
);
}
/**
* 查询股票每日价格列表(支持分页和多种查询条件)
*/
@Get()
@ApiOperation({
summary: '查询股票每日价格列表',
description:
'支持分页查询,查询条件:股票代码、股票名称(模糊)、市场、日期(模糊)',
})
@ApiResponse({
status: 200,
description: '查询成功',
type: PaginatedStockDailyPriceData,
})
findAll(@Query() queryDto: QueryStockDailyPriceDto): Promise<{
list: StockDailyPrice[];
pagination: any;
}> {
return this.stockDailyPriceService.findAll(queryDto);
}
/**
* 根据 ID 查询单个股票每日价格
*/
@Get(':id')
@ApiOperation({
summary: '根据ID查询股票每日价格',
description: '根据价格记录ID查询单个股票每日价格',
})
@ApiParam({ name: 'id', description: '价格记录ID', type: Number })
@ApiResponse({
status: 200,
description: '查询成功',
type: StockDailyPrice,
})
@ApiResponse({ status: 404, description: '未找到价格记录' })
findOneById(@Param('id') id: string): Promise<StockDailyPrice> {
return this.stockDailyPriceService.findOneById(+id);
}
/**
* 根据股票代码、市场和日期查询单个股票每日价格
*/
@Get('code/:stockCode')
@ApiOperation({
summary: '根据股票代码查询股票每日价格',
description: '根据股票代码、市场和日期查询单个股票每日价格',
})
@ApiParam({ name: 'stockCode', description: '股票代码', type: String })
@ApiQuery({ name: 'market', description: '市场标识', required: true })
@ApiQuery({ name: 'tradeDate', description: '交易日期', required: true })
@ApiResponse({
status: 200,
description: '查询成功',
type: StockDailyPrice,
})
@ApiResponse({ status: 404, description: '未找到价格记录' })
findOneByCode(
@Param('stockCode') stockCode: string,
@Query('market') market: string,
@Query('tradeDate') tradeDate: string,
): Promise<StockDailyPrice> {
return this.stockDailyPriceService.findOneByCode(
stockCode,
market,
tradeDate,
);
}
/**
* 根据股票代码查询单只股票的所有价格记录
*/
@Get('stock/:stockCode')
@ApiOperation({
summary: '根据股票代码查询所有价格记录',
description: '根据股票代码和市场查询单只股票的所有价格记录',
})
@ApiParam({ name: 'stockCode', description: '股票代码', type: String })
@ApiQuery({ name: 'market', description: '市场标识', required: true })
@ApiQuery({ name: 'limit', description: '返回记录数限制', required: false })
@ApiResponse({
status: 200,
description: '查询成功',
type: [StockDailyPrice],
})
findByStockCode(
@Param('stockCode') stockCode: string,
@Query('market') market: string,
@Query('limit') limit?: string,
): Promise<StockDailyPrice[]> {
return this.stockDailyPriceService.findByStockCode(
stockCode,
market,
limit ? +limit : undefined,
);
}
/**
* 更新股票每日价格
*/
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '更新股票每日价格',
description: '根据ID更新股票每日价格记录',
})
@ApiParam({ name: 'id', description: '价格记录ID', type: Number })
@ApiResponse({
status: 200,
description: '更新成功',
type: StockDailyPrice,
})
@ApiResponse({ status: 404, description: '未找到价格记录' })
@ApiResponse({ status: 409, description: '价格记录冲突' })
update(
@Param('id') id: string,
@Body() updateStockDailyPriceDto: UpdateStockDailyPriceDto,
): Promise<StockDailyPrice> {
return this.stockDailyPriceService.update(
+id,
updateStockDailyPriceDto,
);
}
/**
* 批量更新股票每日价格
*/
@Patch('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '批量更新股票每日价格',
description: '批量更新股票每日价格记录',
})
@ApiResponse({
status: 200,
description: '更新成功',
type: [StockDailyPrice],
})
@ApiResponse({ status: 404, description: '未找到价格记录' })
batchUpdate(
@Body() updateDtos: UpdateStockDailyPriceDto[],
): Promise<StockDailyPrice[]> {
return this.stockDailyPriceService.batchUpdate(updateDtos);
}
/**
* 删除股票每日价格
*/
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: '删除股票每日价格',
description: '根据ID删除股票每日价格记录',
})
@ApiParam({ name: 'id', description: '价格记录ID', type: Number })
@ApiResponse({ status: 204, description: '删除成功' })
@ApiResponse({ status: 404, description: '未找到价格记录' })
remove(@Param('id') id: string): Promise<void> {
return this.stockDailyPriceService.remove(+id);
}
}
@@ -0,0 +1,234 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
Unique,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@Entity('stock_daily_price')
@Unique(['stockCode', 'market', 'tradeDate'])
export class StockDailyPrice {
@ApiProperty({ description: '主键ID', example: 1 })
@PrimaryGeneratedColumn({ name: 'id' })
id: number;
@ApiProperty({
description: '股票代码',
example: '600519',
maxLength: 20,
})
@Column({ name: 'stock_code', type: 'varchar', length: 20 })
@Index()
stockCode: string;
@ApiProperty({
description: '股票名称',
example: '贵州茅台',
maxLength: 100,
})
@Column({ name: 'stock_name', type: 'varchar', length: 100 })
@Index()
stockName: string;
@ApiProperty({
description: '市场标识',
example: 'sh',
maxLength: 20,
})
@Column({ name: 'market', type: 'varchar', length: 20 })
@Index()
market: string;
@ApiProperty({
description: '交易日期',
example: '2024-01-01',
})
@Column({ name: 'trade_date', type: 'date' })
@Index()
tradeDate: Date;
@ApiPropertyOptional({
description: '开盘价',
example: 1000.0,
})
@Column({
name: 'open_price',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true,
})
openPrice?: number;
@ApiProperty({
description: '收盘价',
example: 1050.0,
})
@Column({
name: 'close_price',
type: 'decimal',
precision: 18,
scale: 4,
})
closePrice: number;
@ApiPropertyOptional({
description: '最高价',
example: 1060.0,
})
@Column({
name: 'high_price',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true,
})
highPrice?: number;
@ApiPropertyOptional({
description: '最低价',
example: 995.0,
})
@Column({
name: 'low_price',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true,
})
lowPrice?: number;
@ApiPropertyOptional({
description: '成交量(单位:手)',
example: 1000000,
})
@Column({ name: 'volume', type: 'bigint', nullable: true })
volume?: number;
@ApiPropertyOptional({
description: '成交额(单位:元)',
example: 1050000000.0,
})
@Column({
name: 'amount',
type: 'decimal',
precision: 20,
scale: 2,
nullable: true,
})
amount?: number;
@ApiPropertyOptional({
description: '涨跌额',
example: 50.0,
})
@Column({
name: 'change_amount',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true,
})
changeAmount?: number;
@ApiPropertyOptional({
description: '涨跌幅(%',
example: 5.0,
})
@Column({
name: 'change_percent',
type: 'decimal',
precision: 10,
scale: 6,
nullable: true,
})
changePercent?: number;
@ApiPropertyOptional({
description: '60日涨跌幅(%',
example: 15.5,
})
@Column({
name: 'change_percent_60day',
type: 'decimal',
precision: 10,
scale: 6,
nullable: true,
})
changePercent60day?: number;
@ApiPropertyOptional({
description: '年初至今涨跌幅(%',
example: 25.8,
})
@Column({
name: 'change_percent_ytd',
type: 'decimal',
precision: 10,
scale: 6,
nullable: true,
})
changePercentYtd?: number;
@ApiPropertyOptional({
description: '换手率(%',
example: 2.5,
})
@Column({
name: 'turnover_rate',
type: 'decimal',
precision: 10,
scale: 6,
nullable: true,
})
turnoverRate?: number;
@ApiPropertyOptional({
description: '市盈率',
example: 35.5,
})
@Column({
name: 'pe_ratio',
type: 'decimal',
precision: 12,
scale: 4,
nullable: true,
})
peRatio?: number;
@ApiPropertyOptional({
description: '市净率',
example: 8.5,
})
@Column({
name: 'pb_ratio',
type: 'decimal',
precision: 12,
scale: 4,
nullable: true,
})
pbRatio?: number;
@ApiPropertyOptional({
description: '总市值(单位:元)',
example: 2000000000000.0,
})
@Column({
name: 'market_cap',
type: 'decimal',
precision: 20,
scale: 2,
nullable: true,
})
marketCap?: number;
@ApiProperty({
description: '创建时间',
example: '2024-01-01T00:00:00.000Z',
})
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StockDailyPriceService } from './stock-daily-price.service';
import { StockDailyPriceController } from './stock-daily-price.controller';
import { StockDailyPrice } from './stock-daily-price.entity';
@Module({
imports: [TypeOrmModule.forFeature([StockDailyPrice])],
controllers: [StockDailyPriceController],
providers: [StockDailyPriceService],
exports: [StockDailyPriceService],
})
export class StockDailyPriceModule {}
@@ -0,0 +1,361 @@
import {
Injectable,
NotFoundException,
ConflictException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StockDailyPrice } from './stock-daily-price.entity';
import { CreateStockDailyPriceDto } from './dto/create-stock-daily-price.dto';
import { UpdateStockDailyPriceDto } from './dto/update-stock-daily-price.dto';
import { QueryStockDailyPriceDto } from './dto/query-stock-daily-price.dto';
import { BatchCreateStockDailyPriceDto } from './dto/batch-create-stock-daily-price.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
@Injectable()
export class StockDailyPriceService {
private readonly logger = new Logger(StockDailyPriceService.name);
constructor(
@InjectRepository(StockDailyPrice)
private readonly stockDailyPriceRepository: Repository<StockDailyPrice>,
) {}
/**
* 单独创建股票每日价格
*/
async create(
createStockDailyPriceDto: CreateStockDailyPriceDto,
): Promise<StockDailyPrice> {
// 检查同一股票同一日期是否已存在
const existing = await this.stockDailyPriceRepository.findOne({
where: {
stockCode: createStockDailyPriceDto.stockCode,
market: createStockDailyPriceDto.market,
tradeDate: new Date(createStockDailyPriceDto.tradeDate),
},
});
if (existing) {
throw new ConflictException(
`股票 ${createStockDailyPriceDto.stockCode} (${createStockDailyPriceDto.market}) 在日期 ${createStockDailyPriceDto.tradeDate} 的价格记录已存在`,
);
}
const stockDailyPrice = this.stockDailyPriceRepository.create({
...createStockDailyPriceDto,
tradeDate: new Date(createStockDailyPriceDto.tradeDate),
});
return this.stockDailyPriceRepository.save(stockDailyPrice);
}
/**
* 批量创建股票每日价格
*/
async batchCreate(
batchCreateStockDailyPriceDto: BatchCreateStockDailyPriceDto,
): Promise<StockDailyPrice[]> {
const prices = batchCreateStockDailyPriceDto.prices.map((dto) =>
this.stockDailyPriceRepository.create({
...dto,
tradeDate: new Date(dto.tradeDate),
}),
);
// 检查是否有重复的 stock_code + market + trade_date 组合
const uniqueKeys = prices.map((p) => ({
stockCode: p.stockCode,
market: p.market,
tradeDate: p.tradeDate,
}));
// 检查批量数据内部是否有重复
const uniquePairs = new Set(
uniqueKeys.map(
(k) =>
`${k.stockCode}-${k.market}-${k.tradeDate.toISOString()}`,
),
);
if (uniquePairs.size !== uniqueKeys.length) {
throw new ConflictException(
'批量数据中存在重复的股票代码、市场和日期组合',
);
}
// 检查数据库中是否已存在
const existingPrices = await this.stockDailyPriceRepository.find({
where: uniqueKeys.map((k) => ({
stockCode: k.stockCode,
market: k.market,
tradeDate: k.tradeDate,
})),
});
if (existingPrices.length > 0) {
const conflicts = existingPrices.map(
(p) =>
`${p.stockCode} (${p.market}) - ${p.tradeDate.toISOString().split('T')[0]}`,
);
throw new ConflictException(
`以下价格记录已存在:${conflicts.join('、')}`,
);
}
return this.stockDailyPriceRepository.save(prices);
}
/**
* 批量更新股票每日价格
*/
async batchUpdate(
updateDtos: UpdateStockDailyPriceDto[],
): Promise<StockDailyPrice[]> {
const updatedPrices: StockDailyPrice[] = [];
for (const updateDto of updateDtos) {
// 必须包含 stockCode、market 和 tradeDate 来定位记录
if (
!updateDto.stockCode ||
!updateDto.market ||
!updateDto.tradeDate
) {
throw new ConflictException(
'批量更新时,每条记录必须包含 stockCode、market 和 tradeDate',
);
}
const existing = await this.stockDailyPriceRepository.findOne({
where: {
stockCode: updateDto.stockCode,
market: updateDto.market,
tradeDate: new Date(updateDto.tradeDate),
},
});
if (!existing) {
throw new NotFoundException(
`未找到价格记录:${updateDto.stockCode} (${updateDto.market}) - ${updateDto.tradeDate}`,
);
}
// 更新字段
Object.assign(existing, {
...updateDto,
tradeDate: updateDto.tradeDate
? new Date(updateDto.tradeDate)
: existing.tradeDate,
});
const saved = await this.stockDailyPriceRepository.save(existing);
updatedPrices.push(saved);
}
return updatedPrices;
}
/**
* 查询股票每日价格(支持多种查询条件和分页)
*/
async findAll(queryDto: QueryStockDailyPriceDto): Promise<{
list: StockDailyPrice[];
pagination: PaginationInfo;
}> {
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
// 排序字段映射
const sortBy = queryDto.sortBy || 'tradeDate';
const sortOrder = queryDto.sortOrder || 'DESC';
// 构建查询
let query = this.stockDailyPriceRepository.createQueryBuilder('price');
if (queryDto.stockCode) {
query = query.andWhere('price.stock_code = :stockCode', {
stockCode: queryDto.stockCode,
});
}
if (queryDto.stockName) {
query = query.andWhere('price.stock_name LIKE :stockName', {
stockName: `%${queryDto.stockName}%`,
});
}
if (queryDto.market) {
query = query.andWhere('price.market = :market', {
market: queryDto.market,
});
}
// 日期查询:支持精确日期、日期范围或模糊查询
if (queryDto.startDate && queryDto.endDate) {
query = query.andWhere(
'price.trade_date BETWEEN :startDate AND :endDate',
{
startDate: queryDto.startDate,
endDate: queryDto.endDate,
},
);
} else if (queryDto.startDate) {
query = query.andWhere('price.trade_date >= :startDate', {
startDate: queryDto.startDate,
});
} else if (queryDto.endDate) {
query = query.andWhere('price.trade_date <= :endDate', {
endDate: queryDto.endDate,
});
} else if (queryDto.tradeDate) {
// 模糊查询日期(如 '2024-01' 匹配 2024-01-XX
query = query.andWhere('price.trade_date::text LIKE :tradeDate', {
tradeDate: `${queryDto.tradeDate}%`,
});
}
// 获取总数
const total = await query.getCount();
// 添加排序和分页
query = query
.orderBy(`price.${sortBy}`, sortOrder)
.addOrderBy('price.id', 'ASC')
.skip(skip)
.take(limit);
const list = await query.getMany();
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/**
* 根据 ID 查询单个股票每日价格
*/
async findOneById(id: number): Promise<StockDailyPrice> {
const stockDailyPrice = await this.stockDailyPriceRepository.findOne({
where: { id },
});
if (!stockDailyPrice) {
throw new NotFoundException(`未找到ID为 ${id} 的价格记录`);
}
return stockDailyPrice;
}
/**
* 根据股票代码、市场和日期查询单个股票每日价格
*/
async findOneByCode(
stockCode: string,
market: string,
tradeDate: string,
): Promise<StockDailyPrice> {
const stockDailyPrice = await this.stockDailyPriceRepository.findOne({
where: {
stockCode,
market,
tradeDate: new Date(tradeDate),
},
});
if (!stockDailyPrice) {
throw new NotFoundException(
`未找到价格记录:${stockCode} (${market}) - ${tradeDate}`,
);
}
return stockDailyPrice;
}
/**
* 根据股票代码查询单只股票的所有价格记录
*/
async findByStockCode(
stockCode: string,
market: string,
limit?: number,
): Promise<StockDailyPrice[]> {
const query = this.stockDailyPriceRepository
.createQueryBuilder('price')
.where('price.stock_code = :stockCode', { stockCode })
.andWhere('price.market = :market', { market })
.orderBy('price.trade_date', 'DESC')
.addOrderBy('price.id', 'ASC');
if (limit) {
query.take(limit);
}
return query.getMany();
}
/**
* 更新股票每日价格
*/
async update(
id: number,
updateStockDailyPriceDto: UpdateStockDailyPriceDto,
): Promise<StockDailyPrice> {
const stockDailyPrice = await this.findOneById(id);
// 如果更新 stock_code、market 或 trade_date,检查是否冲突
if (
'stockCode' in updateStockDailyPriceDto ||
'market' in updateStockDailyPriceDto ||
'tradeDate' in updateStockDailyPriceDto
) {
const newCode =
updateStockDailyPriceDto.stockCode ?? stockDailyPrice.stockCode;
const newMarket =
updateStockDailyPriceDto.market ?? stockDailyPrice.market;
const newDate = updateStockDailyPriceDto.tradeDate
? new Date(updateStockDailyPriceDto.tradeDate)
: stockDailyPrice.tradeDate;
const existing = await this.stockDailyPriceRepository.findOne({
where: {
stockCode: newCode,
market: newMarket,
tradeDate: newDate,
},
});
if (existing && existing.id !== id) {
throw new ConflictException(
`股票 ${newCode} (${newMarket}) 在日期 ${newDate.toISOString().split('T')[0]} 的价格记录已存在`,
);
}
}
Object.assign(stockDailyPrice, {
...updateStockDailyPriceDto,
tradeDate: updateStockDailyPriceDto.tradeDate
? new Date(updateStockDailyPriceDto.tradeDate)
: stockDailyPrice.tradeDate,
});
return this.stockDailyPriceRepository.save(stockDailyPrice);
}
/**
* 删除股票每日价格
*/
async remove(id: number): Promise<void> {
const stockDailyPrice = await this.findOneById(id);
await this.stockDailyPriceRepository.remove(stockDailyPrice);
}
}
@@ -0,0 +1,31 @@
import { IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { CreateStockInfoDto } from './create-stock-info.dto';
export class BatchCreateStockInfoDto {
@ApiProperty({
description: '股票信息列表',
type: [CreateStockInfoDto],
example: [
{
stockCode: '600519',
stockName: '贵州茅台',
market: 'sh',
fullName: '贵州茅台酒股份有限公司',
industry: '白酒',
},
{
stockCode: '000001',
stockName: '平安银行',
market: 'sz',
fullName: '平安银行股份有限公司',
industry: '银行',
},
],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateStockInfoDto)
stocks: CreateStockInfoDto[];
}
@@ -0,0 +1,80 @@
import {
IsString,
IsNotEmpty,
IsOptional,
MaxLength,
IsDateString,
IsIn,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateStockInfoDto {
@ApiProperty({
description: '股票代码',
example: '600519',
maxLength: 20,
})
@IsString()
@IsNotEmpty()
@MaxLength(20)
stockCode: string;
@ApiProperty({
description: '股票名称',
example: '贵州茅台',
maxLength: 100,
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
stockName: string;
@ApiProperty({
description: '市场标识',
example: 'sh',
maxLength: 20,
})
@IsString()
@IsNotEmpty()
@MaxLength(20)
market: string;
@ApiPropertyOptional({
description: '公司全称',
example: '贵州茅台酒股份有限公司',
maxLength: 200,
})
@IsOptional()
@IsString()
@MaxLength(200)
fullName?: string;
@ApiPropertyOptional({
description: '所属行业',
example: '白酒',
maxLength: 100,
})
@IsOptional()
@IsString()
@MaxLength(100)
industry?: string;
@ApiPropertyOptional({
description: '上市日期',
example: '2001-08-27',
})
@IsOptional()
@IsDateString()
listingDate?: string;
@ApiPropertyOptional({
description: '状态',
example: 'active',
enum: ['active', 'suspended', 'delisted'],
default: 'active',
})
@IsOptional()
@IsString()
@IsIn(['active', 'suspended', 'delisted'])
status?: string;
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { StockInfo } from '../stock-info.entity';
import { PaginationInfo } from '@/common/dto/pagination.dto';
/**
* 股票信息分页响应数据
*/
export class PaginatedStockInfoData {
@ApiProperty({ description: '股票信息列表', type: [StockInfo] })
list: StockInfo[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}
@@ -0,0 +1,72 @@
import { IsOptional, IsString, IsNumber, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class QueryStockInfoDto {
@ApiPropertyOptional({
description: '股票代码',
example: '600519',
})
@IsOptional()
@IsString()
stockCode?: string;
@ApiPropertyOptional({
description: '股票名称(模糊查询)',
example: '茅台',
})
@IsOptional()
@IsString()
stockName?: string;
@ApiPropertyOptional({
description: '市场标识',
example: 'sh',
})
@IsOptional()
@IsString()
market?: string;
@ApiPropertyOptional({
description: '页码',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量',
example: 10,
minimum: 1,
default: 10,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({
description: '排序字段',
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
@ApiPropertyOptional({
description: '排序方向',
example: 'DESC',
enum: ['ASC', 'DESC'],
default: 'DESC',
})
@IsOptional()
@IsString()
sortOrder?: 'ASC' | 'DESC' = 'DESC';
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateStockInfoDto } from './create-stock-info.dto';
export class UpdateStockInfoDto extends PartialType(CreateStockInfoDto) {}
@@ -0,0 +1,260 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { StockInfoService } from './stock-info.service';
import { CreateStockInfoDto } from './dto/create-stock-info.dto';
import { UpdateStockInfoDto } from './dto/update-stock-info.dto';
import { QueryStockInfoDto } from './dto/query-stock-info.dto';
import { BatchCreateStockInfoDto } from './dto/batch-create-stock-info.dto';
import { PaginatedStockInfoData } from './dto/paginated-response.dto';
import { StockInfo } from './stock-info.entity';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('stock-info')
@Controller('stock-info')
export class StockInfoController {
constructor(private readonly stockInfoService: StockInfoService) {}
/**
* 单独创建股票信息
*/
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '创建股票信息',
description: '创建单个股票基本信息',
})
@ApiResponse({
status: 201,
description: '创建成功',
type: StockInfo,
})
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 409, description: '股票代码已存在' })
create(@Body() createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
return this.stockInfoService.create(createStockInfoDto);
}
/**
* 批量创建股票信息
*/
@Post('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '批量创建股票信息',
description: '一次性创建多个股票基本信息',
})
@ApiResponse({
status: 201,
description: '批量创建成功',
type: [StockInfo],
})
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 409, description: '存在重复的股票代码' })
batchCreate(
@Body() batchCreateStockInfoDto: BatchCreateStockInfoDto,
): Promise<StockInfo[]> {
return this.stockInfoService.batchCreate(batchCreateStockInfoDto);
}
/**
* Upsert:存在则更新,不存在则新增
*/
@Post('upsert')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '创建或更新股票信息',
description: '如果股票代码已存在则更新,不存在则新增',
})
@ApiResponse({
status: 200,
description: '操作成功',
type: StockInfo,
})
upsert(@Body() createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
return this.stockInfoService.upsert(createStockInfoDto);
}
/**
* 批量 Upsert
*/
@Post('batch-upsert')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '批量创建或更新股票信息',
description: '批量操作:存在则更新,不存在则新增',
})
@ApiResponse({
status: 200,
description: '操作成功',
type: [StockInfo],
})
batchUpsert(
@Body() batchCreateStockInfoDto: BatchCreateStockInfoDto,
): Promise<StockInfo[]> {
return this.stockInfoService.batchUpsert(batchCreateStockInfoDto);
}
/**
* 查询股票信息列表(支持分页和多种查询条件)
*/
@Get()
@ApiOperation({
summary: '查询股票信息列表',
description:
'支持分页查询,查询条件:股票代码、股票名称(模糊)、市场类型',
})
@ApiResponse({
status: 200,
description: '查询成功',
type: PaginatedStockInfoData,
})
findAll(@Query() queryDto: QueryStockInfoDto): Promise<{
list: StockInfo[];
pagination: any;
}> {
return this.stockInfoService.findAll(queryDto);
}
/**
* 根据 ID 查询单个股票信息
*/
@Get(':id')
@ApiOperation({
summary: '根据ID查询股票信息',
description: '根据股票信息ID查询单个股票信息',
})
@ApiParam({ name: 'id', description: '股票信息ID', type: Number })
@ApiResponse({
status: 200,
description: '查询成功',
type: StockInfo,
})
@ApiResponse({ status: 404, description: '未找到股票信息' })
findOneById(@Param('id') id: string): Promise<StockInfo> {
return this.stockInfoService.findOneById(+id);
}
/**
* 根据股票代码和市场查询单个股票信息
*/
@Get('code/:stockCode')
@ApiOperation({
summary: '根据股票代码查询股票信息',
description: '根据股票代码和市场查询单个股票信息',
})
@ApiParam({ name: 'stockCode', description: '股票代码', type: String })
@ApiQuery({ name: 'market', description: '市场标识', required: true })
@ApiResponse({
status: 200,
description: '查询成功',
type: StockInfo,
})
@ApiResponse({ status: 404, description: '未找到股票信息' })
findOneByCode(
@Param('stockCode') stockCode: string,
@Query('market') market: string,
): Promise<StockInfo> {
return this.stockInfoService.findOneByCode(stockCode, market);
}
/**
* 更新股票信息
*/
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '更新股票信息',
description: '根据ID更新股票基本信息',
})
@ApiParam({ name: 'id', description: '股票信息ID', type: Number })
@ApiResponse({
status: 200,
description: '更新成功',
type: StockInfo,
})
@ApiResponse({ status: 404, description: '未找到股票信息' })
@ApiResponse({ status: 409, description: '股票代码冲突' })
update(
@Param('id') id: string,
@Body() updateStockInfoDto: UpdateStockInfoDto,
): Promise<StockInfo> {
return this.stockInfoService.update(+id, updateStockInfoDto);
}
/**
* 批量更新股票信息
*/
@Patch('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '批量更新股票信息',
description: '批量更新股票基本信息',
})
@ApiResponse({
status: 200,
description: '更新成功',
type: [StockInfo],
})
@ApiResponse({ status: 404, description: '未找到股票信息' })
batchUpdate(
@Body() updateDtos: UpdateStockInfoDto[],
): Promise<StockInfo[]> {
return this.stockInfoService.batchUpdate(updateDtos);
}
/**
* 删除股票信息
*/
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: '删除股票信息',
description: '根据ID删除股票基本信息',
})
@ApiParam({ name: 'id', description: '股票信息ID', type: Number })
@ApiResponse({ status: 204, description: '删除成功' })
@ApiResponse({ status: 404, description: '未找到股票信息' })
remove(@Param('id') id: string): Promise<void> {
return this.stockInfoService.remove(+id);
}
}
@@ -0,0 +1,97 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@Entity('stock_info')
@Unique(['stockCode', 'market'])
export class StockInfo {
@ApiProperty({ description: '主键ID', example: 1 })
@PrimaryGeneratedColumn({ name: 'id' })
id: number;
@ApiProperty({
description: '股票代码',
example: '600519',
maxLength: 20,
})
@Column({ name: 'stock_code', type: 'varchar', length: 20 })
@Index()
stockCode: string;
@ApiProperty({
description: '股票名称',
example: '贵州茅台',
maxLength: 100,
})
@Column({ name: 'stock_name', type: 'varchar', length: 100 })
@Index()
stockName: string;
@ApiProperty({
description: '市场标识',
example: 'sh',
maxLength: 20,
})
@Column({ name: 'market', type: 'varchar', length: 20 })
@Index()
market: string;
@ApiPropertyOptional({
description: '公司全称',
example: '贵州茅台酒股份有限公司',
maxLength: 200,
})
@Column({ name: 'full_name', type: 'varchar', length: 200, nullable: true })
fullName?: string;
@ApiPropertyOptional({
description: '所属行业',
example: '白酒',
maxLength: 100,
})
@Column({ name: 'industry', type: 'varchar', length: 100, nullable: true })
industry?: string;
@ApiPropertyOptional({
description: '上市日期',
example: '2001-08-27',
})
@Column({ name: 'listing_date', type: 'date', nullable: true })
listingDate?: Date;
@ApiProperty({
description: '状态',
example: 'active',
enum: ['active', 'suspended', 'delisted'],
default: 'active',
})
@Column({
name: 'status',
type: 'varchar',
length: 20,
default: 'active',
})
@Index()
status: string;
@ApiProperty({
description: '创建时间',
example: '2024-01-01T00:00:00.000Z',
})
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ApiProperty({
description: '更新时间',
example: '2024-01-01T00:00:00.000Z',
})
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StockInfoService } from './stock-info.service';
import { StockInfoController } from './stock-info.controller';
import { StockInfo } from './stock-info.entity';
@Module({
imports: [TypeOrmModule.forFeature([StockInfo])],
controllers: [StockInfoController],
providers: [StockInfoService],
exports: [StockInfoService],
})
export class StockInfoModule {}
@@ -0,0 +1,367 @@
import {
Injectable,
NotFoundException,
ConflictException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
import { StockInfo } from './stock-info.entity';
import { CreateStockInfoDto } from './dto/create-stock-info.dto';
import { UpdateStockInfoDto } from './dto/update-stock-info.dto';
import { QueryStockInfoDto } from './dto/query-stock-info.dto';
import { BatchCreateStockInfoDto } from './dto/batch-create-stock-info.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
@Injectable()
export class StockInfoService {
private readonly logger = new Logger(StockInfoService.name);
constructor(
@InjectRepository(StockInfo)
private readonly stockInfoRepository: Repository<StockInfo>,
) {}
/**
* 单独创建股票信息
*/
async create(createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
// 检查同一市场的 stock_code 是否已存在
const existing = await this.stockInfoRepository.findOne({
where: {
stockCode: createStockInfoDto.stockCode,
market: createStockInfoDto.market,
},
});
if (existing) {
throw new ConflictException(
`市场 "${createStockInfoDto.market}" 中已存在代码为 "${createStockInfoDto.stockCode}" 的股票`,
);
}
const stockInfo = this.stockInfoRepository.create({
...createStockInfoDto,
status: createStockInfoDto.status ?? 'active',
listingDate: createStockInfoDto.listingDate
? new Date(createStockInfoDto.listingDate)
: undefined,
});
return this.stockInfoRepository.save(stockInfo);
}
/**
* 批量创建股票信息
*/
async batchCreate(
batchCreateStockInfoDto: BatchCreateStockInfoDto,
): Promise<StockInfo[]> {
const stocks = batchCreateStockInfoDto.stocks.map((dto) =>
this.stockInfoRepository.create({
...dto,
status: dto.status ?? 'active',
listingDate: dto.listingDate
? new Date(dto.listingDate)
: undefined,
}),
);
// 检查是否有重复的 stock_code + market 组合
const codeMarketPairs = stocks.map((s) => ({
stockCode: s.stockCode,
market: s.market,
}));
const existingStocks = await this.stockInfoRepository.find({
where: codeMarketPairs.map((pair) => ({
stockCode: pair.stockCode,
market: pair.market,
})),
});
if (existingStocks.length > 0) {
const conflicts = existingStocks.map(
(s) => `${s.stockCode} (${s.market})`,
);
throw new ConflictException(
`以下股票已存在:${conflicts.join('、')}`,
);
}
// 检查批量数据内部是否有重复
const uniquePairs = new Set(
codeMarketPairs.map((p) => `${p.stockCode}-${p.market}`),
);
if (uniquePairs.size !== codeMarketPairs.length) {
throw new ConflictException(
'批量数据中存在重复的股票代码和市场组合',
);
}
return this.stockInfoRepository.save(stocks);
}
/**
* 批量更新股票信息
*/
async batchUpdate(updateDtos: UpdateStockInfoDto[]): Promise<StockInfo[]> {
const updatedStocks: StockInfo[] = [];
for (const updateDto of updateDtos) {
// 必须包含 stockCode 和 market 来定位记录
if (!updateDto.stockCode || !updateDto.market) {
throw new ConflictException(
'批量更新时,每条记录必须包含 stockCode 和 market',
);
}
const existing = await this.stockInfoRepository.findOne({
where: {
stockCode: updateDto.stockCode,
market: updateDto.market,
},
});
if (!existing) {
throw new NotFoundException(
`未找到股票:${updateDto.stockCode} (${updateDto.market})`,
);
}
// 更新字段
if (updateDto.stockName !== undefined) {
existing.stockName = updateDto.stockName;
}
if (updateDto.fullName !== undefined) {
existing.fullName = updateDto.fullName;
}
if (updateDto.industry !== undefined) {
existing.industry = updateDto.industry;
}
if (updateDto.listingDate !== undefined) {
existing.listingDate = updateDto.listingDate
? new Date(updateDto.listingDate)
: undefined;
}
if (updateDto.status !== undefined) {
existing.status = updateDto.status;
}
const saved = await this.stockInfoRepository.save(existing);
updatedStocks.push(saved);
}
return updatedStocks;
}
/**
* Upsert:存在则更新,不存在则新增
*/
async upsert(createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
const existing = await this.stockInfoRepository.findOne({
where: {
stockCode: createStockInfoDto.stockCode,
market: createStockInfoDto.market,
},
});
if (existing) {
// 更新现有记录
Object.assign(existing, {
...createStockInfoDto,
listingDate: createStockInfoDto.listingDate
? new Date(createStockInfoDto.listingDate)
: existing.listingDate,
status: createStockInfoDto.status ?? existing.status,
});
return this.stockInfoRepository.save(existing);
} else {
// 创建新记录
return this.create(createStockInfoDto);
}
}
/**
* 批量 Upsert
*/
async batchUpsert(
batchCreateStockInfoDto: BatchCreateStockInfoDto,
): Promise<StockInfo[]> {
const results: StockInfo[] = [];
for (const dto of batchCreateStockInfoDto.stocks) {
const result = await this.upsert(dto);
results.push(result);
}
return results;
}
/**
* 查询股票信息(支持多种查询条件和分页)
*/
async findAll(queryDto: QueryStockInfoDto): Promise<{
list: StockInfo[];
pagination: PaginationInfo;
}> {
const where: FindOptionsWhere<StockInfo> = {};
if (queryDto.stockCode) {
where.stockCode = queryDto.stockCode;
}
if (queryDto.market) {
where.market = queryDto.market;
}
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
// 排序字段映射
const sortBy = queryDto.sortBy || 'createdAt';
const sortOrder = queryDto.sortOrder || 'DESC';
// 构建排序对象
const order: Record<string, 'ASC' | 'DESC'> = {};
if (sortBy === 'createdAt') {
order.createdAt = sortOrder;
} else if (sortBy === 'updatedAt') {
order.updatedAt = sortOrder;
} else if (sortBy === 'stockCode') {
order.stockCode = sortOrder;
} else if (sortBy === 'stockName') {
order.stockName = sortOrder;
} else {
order.createdAt = 'DESC';
}
// 添加默认排序
order.id = 'ASC';
// 查询数据(如果 stockName 存在,使用 Like 进行模糊查询)
let query = this.stockInfoRepository.createQueryBuilder('stock_info');
if (queryDto.stockCode) {
query = query.andWhere('stock_info.stock_code = :stockCode', {
stockCode: queryDto.stockCode,
});
}
if (queryDto.stockName) {
query = query.andWhere('stock_info.stock_name LIKE :stockName', {
stockName: `%${queryDto.stockName}%`,
});
}
if (queryDto.market) {
query = query.andWhere('stock_info.market = :market', {
market: queryDto.market,
});
}
// 获取总数
const total = await query.getCount();
// 添加排序和分页
query = query
.orderBy(`stock_info.${sortBy}`, sortOrder)
.addOrderBy('stock_info.id', 'ASC')
.skip(skip)
.take(limit);
const list = await query.getMany();
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/**
* 根据 ID 查询单个股票信息
*/
async findOneById(id: number): Promise<StockInfo> {
const stockInfo = await this.stockInfoRepository.findOne({
where: { id },
});
if (!stockInfo) {
throw new NotFoundException(`未找到ID为 ${id} 的股票信息`);
}
return stockInfo;
}
/**
* 根据股票代码和市场查询单个股票信息
*/
async findOneByCode(stockCode: string, market: string): Promise<StockInfo> {
const stockInfo = await this.stockInfoRepository.findOne({
where: { stockCode, market },
});
if (!stockInfo) {
throw new NotFoundException(`未找到股票:${stockCode} (${market})`);
}
return stockInfo;
}
/**
* 更新股票信息
*/
async update(
id: number,
updateStockInfoDto: UpdateStockInfoDto,
): Promise<StockInfo> {
const stockInfo = await this.findOneById(id);
// 如果更新 stock_code 或 market,检查是否冲突
if (
'stockCode' in updateStockInfoDto ||
'market' in updateStockInfoDto
) {
const newCode = updateStockInfoDto.stockCode ?? stockInfo.stockCode;
const newMarket = updateStockInfoDto.market ?? stockInfo.market;
const existing = await this.stockInfoRepository.findOne({
where: {
stockCode: newCode,
market: newMarket,
},
});
if (existing && existing.id !== id) {
throw new ConflictException(
`市场 "${newMarket}" 中已存在代码为 "${newCode}" 的股票`,
);
}
}
Object.assign(stockInfo, {
...updateStockInfoDto,
listingDate: updateStockInfoDto.listingDate
? new Date(updateStockInfoDto.listingDate)
: stockInfo.listingDate,
});
return this.stockInfoRepository.save(stockInfo);
}
/**
* 删除股票信息
*/
async remove(id: number): Promise<void> {
const stockInfo = await this.findOneById(id);
await this.stockInfoRepository.remove(stockInfo);
}
}
@@ -0,0 +1,22 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsIn } from 'class-validator';
export class UploadFileDto {
@ApiPropertyOptional({
description: '存储文件夹',
example: 'broker',
enum: ['broker', 'user', 'temp'],
})
@IsOptional()
@IsString()
@IsIn(['broker', 'user', 'temp'])
folder?: string;
@ApiPropertyOptional({
description: '自定义文件名',
example: 'custom-filename.jpg',
})
@IsOptional()
@IsString()
filename?: string;
}
@@ -0,0 +1,60 @@
/**
* 文件上传对象类型
*/
export interface FileUpload {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
size: number;
buffer: Buffer;
}
/**
* 存储提供者接口
* 所有存储实现都必须实现此接口,方便切换不同的存储方案
*/
export interface UploadResult {
path: string; // 存储路径(相对路径或完整路径)
url: string; // 访问URL
filename: string; // 文件名
size: number; // 文件大小(字节)
mimetype: string; // MIME类型
}
export interface UploadOptions {
folder?: string; // 存储文件夹,如 'broker', 'user' 等
filename?: string; // 自定义文件名
maxSize?: number; // 最大文件大小(字节)
allowedMimeTypes?: string[]; // 允许的MIME类型
}
export interface IStorageProvider {
/**
* 上传文件
* @param file 文件对象
* @param options 上传选项
* @returns 上传结果
*/
upload(file: FileUpload, options?: UploadOptions): Promise<UploadResult>;
/**
* 删除文件
* @param path 文件路径
*/
delete(path: string): Promise<void>;
/**
* 获取文件的访问URL
* @param path 文件路径
* @returns 访问URL
*/
getUrl(path: string): string;
/**
* 检查文件是否存在
* @param path 文件路径
* @returns 是否存在
*/
exists(path: string): Promise<boolean>;
}
@@ -0,0 +1,173 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import type {
IStorageProvider,
UploadResult,
UploadOptions,
FileUpload,
} from '../interfaces/storage-provider.interface';
/**
* 本地存储提供者
* 将文件存储在服务器本地目录
*/
@Injectable()
export class LocalStorageProvider implements IStorageProvider {
private readonly logger = new Logger(LocalStorageProvider.name);
private readonly basePath: string;
private readonly baseUrl: string;
constructor(private readonly configService: ConfigService) {
// 从环境变量读取配置
this.basePath =
this.configService.get<string>('STORAGE_PATH') || './uploads';
this.baseUrl =
this.configService.get<string>('STORAGE_BASE_URL') ||
'http://localhost:3200/uploads';
}
/**
* 上传文件到本地存储
*/
async upload(
file: FileUpload,
options?: UploadOptions,
): Promise<UploadResult> {
try {
// 验证文件大小
if (options?.maxSize && file.size > options.maxSize) {
throw new Error(
`文件大小超过限制 ${options.maxSize / 1024 / 1024}MB`,
);
}
// 验证文件类型
if (
options?.allowedMimeTypes &&
!options.allowedMimeTypes.includes(file.mimetype)
) {
throw new Error(
`不支持的文件类型,允许的类型:${options.allowedMimeTypes.join(', ')}`,
);
}
// 确定存储文件夹
const folder = options?.folder || 'temp';
const uploadDir = path.join(this.basePath, folder);
// 确保目录存在
await fs.mkdir(uploadDir, { recursive: true });
// 生成文件名
const filename = this.generateFilename(file, options?.filename);
const filePath = path.join(uploadDir, filename);
const relativePath = path
.join(folder, filename)
.replace(/\\/g, '/');
// 保存文件
await fs.writeFile(filePath, file.buffer);
// 生成访问URL
const url = `${this.baseUrl}/${relativePath}`;
this.logger.log(`文件上传成功: ${relativePath}`);
return {
path: relativePath,
url,
filename,
size: file.size,
mimetype: file.mimetype,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : '未知错误';
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`文件上传失败: ${errorMessage}`, errorStack);
throw error;
}
}
/**
* 删除文件
*/
async delete(filePath: string): Promise<void> {
try {
// filePath 是相对路径(如 broker/filename.jpg
const fullPath = path.join(this.basePath, filePath);
// 安全检查:确保文件路径在 basePath 内,防止路径遍历攻击
const resolvedPath = path.resolve(fullPath);
const resolvedBasePath = path.resolve(this.basePath);
if (!resolvedPath.startsWith(resolvedBasePath)) {
throw new Error('非法文件路径');
}
// 检查文件是否存在
try {
await fs.access(resolvedPath);
} catch {
this.logger.warn(`文件不存在: ${resolvedPath}`);
return;
}
await fs.unlink(resolvedPath);
this.logger.log(`文件删除成功: ${resolvedPath}`);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : '未知错误';
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`文件删除失败: ${errorMessage}`, errorStack);
throw error;
}
}
/**
* 获取文件的访问URL
*/
getUrl(filePath: string): string {
const relativePath = filePath.startsWith(this.basePath)
? filePath.replace(this.basePath, '').replace(/^[/\\]/, '')
: filePath;
return `${this.baseUrl}/${relativePath}`.replace(/\/+/g, '/');
}
/**
* 检查文件是否存在
*/
async exists(filePath: string): Promise<boolean> {
try {
const fullPath = filePath.startsWith(this.basePath)
? filePath
: path.join(this.basePath, filePath);
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
/**
* 生成文件名
* 格式: {timestamp}-{random}-{originalname}
*/
private generateFilename(file: FileUpload, customName?: string): string {
if (customName) {
return customName;
}
const timestamp = Date.now();
const random = crypto.randomBytes(8).toString('hex');
const ext = path.extname(file.originalname);
const nameWithoutExt = path.basename(file.originalname, ext);
// 清理文件名,移除特殊字符
const cleanName = nameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
return `${timestamp}-${random}-${cleanName}${ext}`;
}
}
@@ -0,0 +1,248 @@
import {
Controller,
Post,
Delete,
Param,
UseInterceptors,
UploadedFile,
Body,
HttpCode,
HttpStatus,
UseGuards,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiConsumes,
ApiBody,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { StorageService } from './storage.service';
import { UploadFileDto } from './dto/upload-file.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import type { FileUpload } from './interfaces/storage-provider.interface';
@ApiTags('storage')
@Controller('storage')
export class StorageController {
constructor(private readonly storageService: StorageService) {}
/**
* 管理员上传文件(需要鉴权)
* 用于上传券商Logo等基础数据
*/
@Post('upload')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: '管理员上传文件',
description: '上传单个文件,需要管理员权限,用于上传券商Logo等基础数据',
})
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: '要上传的文件',
},
folder: {
type: 'string',
enum: ['broker', 'user', 'temp'],
description: '存储文件夹',
},
filename: {
type: 'string',
description: '自定义文件名',
},
},
},
})
@ApiResponse({
status: 200,
description: '上传成功',
schema: {
type: 'object',
properties: {
path: {
type: 'string',
example: 'broker/1234567890-abcdef-broker-logo.jpg',
},
url: {
type: 'string',
example:
'http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg',
},
filename: {
type: 'string',
example: '1234567890-abcdef-broker-logo.jpg',
},
size: {
type: 'number',
example: 102400,
},
mimetype: {
type: 'string',
example: 'image/jpeg',
},
},
},
})
@ApiResponse({ status: 400, description: '文件格式或大小不符合要求' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
async uploadAdmin(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB
new FileTypeValidator({
fileType: /^image\/(jpeg|jpg|png|gif|webp)$/,
}),
],
}),
)
file: FileUpload,
@Body() uploadDto: UploadFileDto,
) {
const options = {
folder: uploadDto.folder || 'temp',
maxSize: 5 * 1024 * 1024, // 5MB
allowedMimeTypes: [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
],
filename: uploadDto.filename,
};
return await this.storageService.upload(file, options);
}
/**
* 用户上传头像(不需要鉴权)
* 用于用户注册或更新头像
*/
@Post('upload/avatar')
@HttpCode(HttpStatus.OK)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: '上传用户头像',
description: '上传用户头像,不需要鉴权,用于用户注册或更新头像',
})
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: '要上传的头像文件',
},
},
},
})
@ApiResponse({
status: 200,
description: '上传成功',
schema: {
type: 'object',
properties: {
path: {
type: 'string',
example: 'user/1234567890-abcdef-avatar.jpg',
},
url: {
type: 'string',
example:
'http://localhost:3200/uploads/user/1234567890-abcdef-avatar.jpg',
},
filename: {
type: 'string',
example: '1234567890-abcdef-avatar.jpg',
},
size: {
type: 'number',
example: 102400,
},
mimetype: {
type: 'string',
example: 'image/jpeg',
},
},
},
})
@ApiResponse({ status: 400, description: '文件格式或大小不符合要求' })
async uploadAvatar(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB
new FileTypeValidator({
fileType: /^image\/(jpeg|jpg|png|gif|webp)$/,
}),
],
}),
)
file: FileUpload,
) {
const options = {
folder: 'user', // 用户头像固定存储在 user 文件夹
maxSize: 2 * 1024 * 1024, // 2MB(头像文件限制更小)
allowedMimeTypes: [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
],
};
return await this.storageService.upload(file, options);
}
/**
* 删除文件(需要管理员权限)
*/
@Delete('*path')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '删除文件',
description: '根据文件路径删除文件,需要管理员权限',
})
@ApiParam({
name: 'path',
description: '文件路径(相对路径)',
example: 'broker/1234567890-abcdef-broker-logo.jpg',
})
@ApiResponse({ status: 204, description: '删除成功' })
@ApiResponse({ status: 404, description: '文件不存在' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
async delete(@Param('path') filePath: string) {
// 移除路径开头的 /,如果有的话
const cleanPath = filePath.startsWith('/')
? filePath.substring(1)
: filePath;
await this.storageService.delete(cleanPath);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { StorageController } from './storage.controller';
import { StorageService } from './storage.service';
@Module({
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}
@@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LocalStorageProvider } from './providers/local-storage.provider';
import type {
IStorageProvider,
UploadResult,
UploadOptions,
FileUpload,
} from './interfaces/storage-provider.interface';
/**
* 存储服务
* 根据配置选择不同的存储提供者
*/
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly provider: IStorageProvider;
constructor(private readonly configService: ConfigService) {
// 根据环境变量选择存储提供者
const storageType =
// 是的,STORAGE_TYPE 应配置在 .env 文件中,否则将默认使用 'local'
this.configService.get<string>('STORAGE_TYPE') || 'local';
this.logger.log(`使用存储类型: ${storageType}`);
switch (storageType) {
case 'local':
this.provider = new LocalStorageProvider(configService);
break;
// 未来可以添加其他存储提供者
// case 'qiniu':
// this.provider = new QiniuStorageProvider(configService);
// break;
default:
this.provider = new LocalStorageProvider(configService);
this.logger.warn(
`未知的存储类型 ${storageType},使用默认本地存储`,
);
}
}
/**
* 上传文件
*/
async upload(
file: FileUpload,
options?: UploadOptions,
): Promise<UploadResult> {
return this.provider.upload(file, options);
}
/**
* 删除文件
*/
async delete(path: string): Promise<void> {
return this.provider.delete(path);
}
/**
* 获取文件URL
*/
getUrl(path: string): string {
return this.provider.getUrl(path);
}
/**
* 检查文件是否存在
*/
async exists(path: string): Promise<boolean> {
return this.provider.exists(path);
}
/**
* 从URL中提取存储路径
* 例如: http://localhost:3200/uploads/broker/filename.jpg -> broker/filename.jpg
* @param url 完整的文件访问URL
* @returns 存储路径(相对路径),如果无法解析则返回null
*/
extractStoragePath(url: string): string | null {
try {
// 尝试从URL中提取路径
// 格式: http://domain/uploads/folder/filename.ext
const urlObj = new URL(url);
const pathname = urlObj.pathname;
// 移除 /uploads/ 前缀,获取相对路径
const uploadsPrefix = '/uploads/';
if (pathname.startsWith(uploadsPrefix)) {
return pathname.substring(uploadsPrefix.length);
}
// 如果不是标准格式,尝试直接使用路径名(去掉开头的 /)
return pathname.startsWith('/') ? pathname.substring(1) : pathname;
} catch (error) {
// 如果URL格式不正确,返回null
this.logger.warn(`无法解析URL: ${url}`, error);
return null;
}
}
}
@@ -6,7 +6,6 @@ import {
MinLength, MinLength,
MaxLength, MaxLength,
Matches, Matches,
IsIn,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@@ -95,15 +94,4 @@ export class CreateUserDto {
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)
unionId?: string; unionId?: string;
@ApiPropertyOptional({
description: '用户角色',
example: 'user',
enum: ['user', 'admin', 'super_admin'],
default: 'user',
})
@IsOptional()
@IsString()
@IsIn(['user', 'admin', 'super_admin'])
role?: string;
} }
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '../user.entity';
import { PaginationInfo } from '@/common/dto/pagination.dto';
/**
* 用户分页响应数据
*/
export class PaginatedUserData {
@ApiProperty({ description: '用户列表', type: [User] })
list: User[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}
@@ -1,4 +1,12 @@
import { IsOptional, IsString, IsEmail } from 'class-validator'; import {
IsOptional,
IsString,
IsEmail,
IsNumber,
Min,
IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
export class QueryUserDto { export class QueryUserDto {
@@ -10,11 +18,91 @@ export class QueryUserDto {
@IsString() @IsString()
username?: string; username?: string;
@ApiPropertyOptional({
description: '昵称',
example: 'John Doe',
})
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: '邮箱', description: '邮箱',
example: 'user@example.com', example: 'user@example.com',
}) })
@IsOptional() @IsOptional()
@IsEmail() @IsString()
email?: string; email?: string;
@ApiPropertyOptional({
description: '电话',
example: '13800138000',
})
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({
description: '角色',
example: 'user',
enum: ['user', 'admin', 'super_admin'],
})
@IsOptional()
@IsString()
@IsIn(['user', 'admin', 'super_admin'])
role?: string;
@ApiPropertyOptional({
description: '状态',
example: 'active',
enum: ['active', 'inactive', 'deleted'],
})
@IsOptional()
@IsString()
@IsIn(['active', 'inactive', 'deleted'])
status?: string;
@ApiPropertyOptional({
description: '页码',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量',
example: 10,
minimum: 1,
default: 10,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({
description: '排序字段',
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
@ApiPropertyOptional({
description: '排序方向',
example: 'DESC',
enum: ['ASC', 'DESC'],
default: 'DESC',
})
@IsOptional()
@IsString()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC' = 'DESC';
} }
@@ -0,0 +1,102 @@
/**
* 普通用户 Mock 数据
* 用于种子数据初始化
*/
export interface MockUserData {
username: string;
email: string;
nickname: string;
phone?: string;
password: string; // 明文密码,会自动加密
}
/**
* 12个普通用户的Mock数据
*/
export const MOCK_USERS: MockUserData[] = [
{
username: 'user001',
email: 'user001@vestmind.com',
nickname: '用户001',
phone: '13800010001',
password: 'user123456',
},
{
username: 'user002',
email: 'user002@vestmind.com',
nickname: '用户002',
phone: '13800010002',
password: 'user123456',
},
{
username: 'user003',
email: 'user003@vestmind.com',
nickname: '用户003',
phone: '13800010003',
password: 'user123456',
},
{
username: 'user004',
email: 'user004@vestmind.com',
nickname: '用户004',
phone: '13800010004',
password: 'user123456',
},
{
username: 'user005',
email: 'user005@vestmind.com',
nickname: '用户005',
phone: '13800010005',
password: 'user123456',
},
{
username: 'user006',
email: 'user006@vestmind.com',
nickname: '用户006',
phone: '13800010006',
password: 'user123456',
},
{
username: 'user007',
email: 'user007@vestmind.com',
nickname: '用户007',
phone: '13800010007',
password: 'user123456',
},
{
username: 'user008',
email: 'user008@vestmind.com',
nickname: '用户008',
phone: '13800010008',
password: 'user123456',
},
{
username: 'user009',
email: 'user009@vestmind.com',
nickname: '用户009',
phone: '13800010009',
password: 'user123456',
},
{
username: 'user010',
email: 'user010@vestmind.com',
nickname: '用户010',
phone: '13800010010',
password: 'user123456',
},
{
username: 'user011',
email: 'user011@vestmind.com',
nickname: '用户011',
phone: '13800010011',
password: 'user123456',
},
{
username: 'user012',
email: 'user012@vestmind.com',
nickname: '用户012',
phone: '13800010012',
password: 'user123456',
},
];
+47 -31
View File
@@ -22,11 +22,13 @@ import { UserService } from './user.service';
import { User } from './user.entity'; import { User } from './user.entity';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUserDto } from './dto/query-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
import { QueryUserDto } from './dto/query-user.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard';
import { PaginatedUserData } from './dto/paginated-response.dto';
@ApiTags('user') @ApiTags('user')
@Controller('user') @Controller('user')
@@ -57,56 +59,59 @@ export class UserController {
} }
/** /**
* 查询所有用户 * 查询所有用户(支持分页和筛选)
*/ */
@Get() @Get()
// @UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('admin', 'super_admin') @Roles('admin', 'super_admin')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: '查询所有用户', summary: '查询用户列表',
description: '获取所有用户列表(需要管理员权限)', description: '获取用户列表,支持分页和多种筛选条件(需要管理员权限)',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: '查询成功', description: '查询成功',
type: [User], type: PaginatedUserData,
}) })
@ApiResponse({ status: 401, description: '未授权' }) @ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' }) @ApiResponse({ status: 403, description: '权限不足' })
findAll(): Promise<User[]> { findAll(@Query() queryDto: QueryUserDto): Promise<PaginatedUserData> {
return this.userService.findAll(); return this.userService.findAllPaginated(queryDto);
} }
/** // /**
* 根据 username 或 email 查询单个用户 // * 根据 username 或 email 查询单个用户
*/ // */
@Get('find') // @Get('find')
@ApiOperation({ // @ApiOperation({
summary: '查询单个用户', // summary: '查询单个用户',
description: '根据 username 或 email 查询用户', // description: '根据 username 或 email 查询用户',
}) // })
@ApiResponse({ // @ApiResponse({
status: 200, // status: 200,
description: '查询成功', // description: '查询成功',
type: User, // type: User,
}) // })
@ApiResponse({ // @ApiResponse({
status: 400, // status: 400,
description: '请求参数错误,必须提供 username 或 email', // description: '请求参数错误,必须提供 username 或 email',
}) // })
@ApiResponse({ status: 404, description: '用户不存在' }) // @ApiResponse({ status: 404, description: '用户不存在' })
findOne(@Query() queryDto: QueryUserDto): Promise<User> { // findOne(@Query() queryDto: QueryUserDto): Promise<User> {
return this.userService.findOne(queryDto); // return this.userService.findOne(queryDto);
} // }
/** /**
* 根据 ID 查询单个用户 * 根据 ID 查询单个用户
* 需要管理员权限或者是用户本人
*/ */
@Get(':id') @Get(':id')
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: '根据ID查询用户', summary: '根据ID查询用户',
description: '根据用户ID获取详细信息', description: '根据用户ID获取详细信息,需要管理员权限或者是用户本人',
}) })
@ApiParam({ name: 'id', description: '用户ID', type: Number }) @ApiParam({ name: 'id', description: '用户ID', type: Number })
@ApiResponse({ @ApiResponse({
@@ -114,6 +119,11 @@ export class UserController {
description: '查询成功', description: '查询成功',
type: User, type: User,
}) })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({
status: 403,
description: '权限不足,只能查询自己的信息或需要管理员权限',
})
@ApiResponse({ status: 404, description: '用户不存在' }) @ApiResponse({ status: 404, description: '用户不存在' })
findOneById(@Param('id') id: string): Promise<User> { findOneById(@Param('id') id: string): Promise<User> {
return this.userService.findOneById(+id); return this.userService.findOneById(+id);
@@ -123,6 +133,8 @@ export class UserController {
* 更新用户信息 * 更新用户信息
*/ */
@Patch(':id') @Patch(':id')
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: '更新用户信息', summary: '更新用户信息',
description: '更新用户信息,不允许修改 username、openId、unionId', description: '更新用户信息,不允许修改 username、openId、unionId',
@@ -147,6 +159,8 @@ export class UserController {
*/ */
@Patch(':id/password') @Patch(':id/password')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: '修改密码', summary: '修改密码',
description: '修改用户密码,需要先验证旧密码', description: '修改用户密码,需要先验证旧密码',
@@ -167,6 +181,8 @@ export class UserController {
*/ */
@Delete(':id') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: '删除用户', summary: '删除用户',
description: '软删除用户,将状态更新为 deleted', description: '软删除用户,将状态更新为 deleted',
+2 -1
View File
@@ -4,9 +4,10 @@ import { UserService } from './user.service';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { UserSeeder } from './user.seeder'; import { UserSeeder } from './user.seeder';
import { User } from './user.entity'; import { User } from './user.entity';
import { StorageModule } from '../storage/storage.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User])], imports: [TypeOrmModule.forFeature([User]), StorageModule],
controllers: [UserController], controllers: [UserController],
providers: [UserService, UserSeeder], providers: [UserService, UserSeeder],
exports: [UserService], exports: [UserService],
+184 -36
View File
@@ -1,14 +1,23 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, In } from 'typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { User } from './user.entity'; import { User } from './user.entity';
import { MOCK_USERS } from './mock-users.data';
/** /**
* 用户数据种子(Seeder * 用户数据种子(Seeder
* *
* 用途:在应用启动时自动创建初始管理员用户 * 用途:在应用启动时自动创建初始用户(超级管理员、管理员、普通用户
*
* 功能:
* 1. 创建超级管理员和管理员各一名(从环境变量读取配置)
* 2. 创建12名普通用户(使用Mock数据)
*
* 性能优化:
* - 使用批量查询(IN查询)检查用户是否存在,只执行2次数据库查询
* - 只创建不存在的用户,保证幂等性
* *
* 优点: * 优点:
* 1. 代码化管理,版本控制友好 * 1. 代码化管理,版本控制友好
@@ -18,13 +27,14 @@ import { User } from './user.entity';
* 5. 幂等性:如果用户已存在,不会重复创建 * 5. 幂等性:如果用户已存在,不会重复创建
* *
* 使用方式: * 使用方式:
* 1. 通过环境变量配置初始管理员信息 * 1. 通过环境变量配置管理员信息
* 2. 应用启动时自动执行 * 2. 应用启动时自动执行
* 3. 仅在开发/测试环境自动执行,生产环境建议手动创建 * 3. 仅在开发/测试环境自动执行,生产环境建议手动创建
*/ */
@Injectable() @Injectable()
export class UserSeeder implements OnModuleInit { export class UserSeeder implements OnModuleInit {
private readonly logger = new Logger(UserSeeder.name); private readonly logger = new Logger(UserSeeder.name);
private readonly saltRounds = 10; // bcrypt 加盐轮数
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
@@ -44,15 +54,51 @@ export class UserSeeder implements OnModuleInit {
return; return;
} }
await this.seedAdminUser(); // 执行种子数据创建
await this.seedAllUsers();
} }
/** /**
* 创建初始管理员用户 * 创建所有种子用户(超级管理员、管理员、普通用户
*/ */
async seedAdminUser(): Promise<void> { async seedAllUsers(): Promise<void> {
try { try {
// 从环境变量读取配置,如果没有则使用默认值 // 创建管理员用户(超级管理员和管理员)
await this.seedAdminUsers();
// 创建普通用户
await this.seedMockUsers();
} catch (error) {
this.logger.error('创建种子用户失败:', error);
// 不抛出错误,避免影响应用启动
}
}
/**
* 创建管理员用户(超级管理员和管理员)
*
* 性能优化说明:
* - 使用批量查询(IN查询)一次检查两个管理员用户是否存在
* - 只创建不存在的用户
* - 只执行1次数据库查询,而不是2次
*/
async seedAdminUsers(): Promise<void> {
try {
// 从环境变量读取超级管理员配置
const superAdminUsername =
this.configService.get<string>('SUPER_ADMIN_USERNAME') ||
'superadmin';
const superAdminPassword =
this.configService.get<string>('SUPER_ADMIN_PASSWORD') ||
'admin123';
const superAdminEmail =
this.configService.get<string>('SUPER_ADMIN_EMAIL') ||
'superadmin@vestmind.com';
const superAdminNickname =
this.configService.get<string>('SUPER_ADMIN_NICKNAME') ||
'超级管理员';
// 从环境变量读取管理员配置
const adminUsername = const adminUsername =
this.configService.get<string>('ADMIN_USERNAME') || 'admin'; this.configService.get<string>('ADMIN_USERNAME') || 'admin';
const adminPassword = const adminPassword =
@@ -63,57 +109,159 @@ export class UserSeeder implements OnModuleInit {
const adminNickname = const adminNickname =
this.configService.get<string>('ADMIN_NICKNAME') || this.configService.get<string>('ADMIN_NICKNAME') ||
'系统管理员'; '系统管理员';
const adminRole =
this.configService.get<string>('ADMIN_ROLE') || 'admin';
// 检查管理员用户是否已存在 // 构建管理员用户数据
const existingAdmin = await this.userRepository.findOne({ const adminUsersToCreate = [
where: { username: adminUsername }, {
username: superAdminUsername,
email: superAdminEmail,
nickname: superAdminNickname,
password: superAdminPassword,
role: 'super_admin',
},
{
username: adminUsername,
email: adminEmail,
nickname: adminNickname,
password: adminPassword,
role: 'admin',
},
];
// 批量查询:一次检查所有管理员用户是否存在(性能优化)
const existingAdminUsernames = await this.userRepository.find({
where: {
username: In([superAdminUsername, adminUsername]),
},
select: ['username'],
}); });
if (existingAdmin) { const existingUsernamesSet = new Set(
existingAdminUsernames.map((u) => u.username),
);
// 过滤出需要创建的用户(不存在的用户)
const usersToCreate = adminUsersToCreate.filter(
(user) => !existingUsernamesSet.has(user.username),
);
if (usersToCreate.length === 0) {
this.logger.log( this.logger.log(
`管理员用户 "${adminUsername}" 已存在,跳过创建`, `管理员用户已存在(超级管理员: ${superAdminUsername}, 管理员: ${adminUsername},跳过创建`,
); );
return; return;
} }
// 检查邮箱是否已被使用 // 批量创建用户
const existingByEmail = await this.userRepository.findOne({ const usersToSave = await Promise.all(
where: { email: adminEmail }, usersToCreate.map(async (userData) => {
const passwordHash = await bcrypt.hash(
userData.password,
this.saltRounds,
);
return this.userRepository.create({
username: userData.username,
passwordHash,
email: userData.email,
nickname: userData.nickname,
role: userData.role,
status: 'active',
});
}),
);
await this.userRepository.save(usersToSave);
// 记录日志
const createdUsernames = usersToCreate.map((u) => u.username);
this.logger.log(
`✅ 成功创建管理员用户: ${createdUsernames.join(', ')}`,
);
usersToCreate.forEach((user) => {
this.logger.warn(
`⚠️ ${user.role === 'super_admin' ? '超级管理员' : '管理员'} "${user.username}" 默认密码: ${user.password},请尽快修改!`,
);
});
} catch (error) {
this.logger.error('创建管理员用户失败:', error);
throw error;
}
}
/**
* 创建普通用户(Mock数据)
*
* 性能优化说明:
* - 使用批量查询(IN查询)一次检查所有12个普通用户是否存在
* - 只创建不存在的用户
* - 只执行1次数据库查询,而不是12次
*
* 关于只检查第一个用户的方案评估:
* - 如果第一个用户存在,但其他11个用户被删除了,会导致其他用户不会被创建
* - 使用批量查询既能保证性能(只查询1次),又能保证数据完整性
*/
async seedMockUsers(): Promise<void> {
try {
const mockUsernames = MOCK_USERS.map((user) => user.username);
// 批量查询:一次检查所有普通用户是否存在(性能优化)
const existingUsers = await this.userRepository.find({
where: {
username: In(mockUsernames),
},
select: ['username'],
}); });
if (existingByEmail) { const existingUsernamesSet = new Set(
this.logger.warn( existingUsers.map((u) => u.username),
`邮箱 "${adminEmail}" 已被使用,跳过创建管理员用户`, );
// 过滤出需要创建的用户(不存在的用户)
const usersToCreate = MOCK_USERS.filter(
(user) => !existingUsernamesSet.has(user.username),
);
if (usersToCreate.length === 0) {
this.logger.log(
`所有普通用户(共${MOCK_USERS.length}名)已存在,跳过创建`,
); );
return; return;
} }
// 使用 bcrypt 加密密码 // 批量创建用户
// saltRounds 指的是生成 bcrypt 哈希时的加盐轮数(成本系数),轮数越高,计算越慢,安全性越高,通常 10~12 为常用值 const usersToSave = await Promise.all(
const saltRounds = 10; usersToCreate.map(async (mockUser) => {
const passwordHash = await bcrypt.hash(adminPassword, saltRounds); const passwordHash = await bcrypt.hash(
mockUser.password,
this.saltRounds,
);
// 创建管理员用户 return this.userRepository.create({
const adminUser = this.userRepository.create({ username: mockUser.username,
username: adminUsername,
passwordHash, passwordHash,
email: adminEmail, email: mockUser.email,
nickname: adminNickname, nickname: mockUser.nickname,
role: adminRole, phone: mockUser.phone,
role: 'user',
status: 'active', status: 'active',
}); });
}),
);
await this.userRepository.save(adminUser); await this.userRepository.save(usersToSave);
this.logger.log( this.logger.log(
`✅ 成功创建初始管理员用户: ${adminUsername} (${adminEmail})`, `✅ 成功创建 ${usersToCreate.length} 名普通用户(共 ${MOCK_USERS.length} 名)`,
); );
this.logger.warn(`⚠️ 默认密码: ${adminPassword},请尽快修改!`); if (usersToCreate.length < MOCK_USERS.length) {
this.logger.log(
` 已存在 ${MOCK_USERS.length - usersToCreate.length} 名用户,已跳过`,
);
}
} catch (error) { } catch (error) {
this.logger.error('创建初始管理员用户失败:', error); this.logger.error('创建普通用户失败:', error);
// 不抛出错误,避免影响应用启动 throw error;
} }
} }
@@ -121,6 +269,6 @@ export class UserSeeder implements OnModuleInit {
* 手动执行种子数据(可用于 CLI 命令) * 手动执行种子数据(可用于 CLI 命令)
*/ */
async run(): Promise<void> { async run(): Promise<void> {
await this.seedAdminUser(); await this.seedAllUsers();
} }
} }
+120 -3
View File
@@ -3,21 +3,27 @@ import {
NotFoundException, NotFoundException,
ConflictException, ConflictException,
BadRequestException, BadRequestException,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, FindOptionsWhere } from 'typeorm';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { User } from './user.entity'; import { User } from './user.entity';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUserDto } from './dto/query-user.dto'; import { QueryUserDto } from './dto/query-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
import { StorageService } from '../storage/storage.service';
@Injectable() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name);
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
private readonly storageService: StorageService,
) {} ) {}
/** /**
@@ -91,6 +97,7 @@ export class UserService {
); );
// 创建用户 // 创建用户
// 注意:所有注册用户的 role 固定为 'user',不允许通过注册接口设置其他角色
const user = this.userRepository.create({ const user = this.userRepository.create({
username: createUserDto.username, username: createUserDto.username,
passwordHash, passwordHash,
@@ -101,7 +108,7 @@ export class UserService {
openId: createUserDto.openId, openId: createUserDto.openId,
unionId: createUserDto.unionId, unionId: createUserDto.unionId,
status: 'active', status: 'active',
role: createUserDto.role || 'user', // 默认为普通用户 role: 'user', // 固定为普通用户,不允许通过注册接口修改
}); });
return this.userRepository.save(user); return this.userRepository.save(user);
@@ -118,6 +125,95 @@ export class UserService {
}); });
} }
/**
* 查询用户(支持多种查询条件和分页)
*/
async findAllPaginated(queryDto: QueryUserDto): Promise<{
list: User[];
pagination: PaginationInfo;
}> {
const where: FindOptionsWhere<User> = {};
if (queryDto.username) {
where.username = queryDto.username;
}
if (queryDto.nickname) {
where.nickname = queryDto.nickname;
}
if (queryDto.email) {
where.email = queryDto.email;
}
if (queryDto.phone) {
where.phone = queryDto.phone;
}
if (queryDto.role) {
where.role = queryDto.role;
}
if (queryDto.status) {
where.status = queryDto.status;
}
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
// 排序字段映射
const sortBy = queryDto.sortBy || 'createdAt';
const sortOrder = queryDto.sortOrder || 'DESC';
// 构建排序对象
const order: Record<string, 'ASC' | 'DESC'> = {};
if (sortBy === 'createdAt') {
order.createdAt = sortOrder;
} else if (sortBy === 'updatedAt') {
order.updatedAt = sortOrder;
} else if (sortBy === 'lastLoginAt') {
order.lastLoginAt = sortOrder;
} else {
order.createdAt = 'DESC';
}
// 添加默认排序
order.userId = 'ASC';
// 查询总数
const total = await this.userRepository.count({ where });
// 查询分页数据
const list = await this.userRepository.find({
where,
order,
skip,
take: limit,
});
// 移除 passwordHash 字段
const listWithoutPassword = list.map((user) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { passwordHash, ...userWithoutPassword } = user;
return userWithoutPassword as User;
});
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list: listWithoutPassword,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/** /**
* 根据 username 或 email 查询单个用户 * 根据 username 或 email 查询单个用户
*/ */
@@ -158,7 +254,10 @@ export class UserService {
throw new NotFoundException(`未找到ID为 ${id} 的用户`); throw new NotFoundException(`未找到ID为 ${id} 的用户`);
} }
return user; // 移除 passwordHash 字段
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { passwordHash, ...userWithoutPassword } = user;
return userWithoutPassword as User;
} }
/** /**
@@ -231,9 +330,27 @@ export class UserService {
/** /**
* 删除用户(软删除,更新状态为 deleted) * 删除用户(软删除,更新状态为 deleted)
* 同时删除用户头像图片
*/ */
async remove(id: number): Promise<void> { async remove(id: number): Promise<void> {
const user = await this.findOneById(id); const user = await this.findOneById(id);
// 删除用户头像图片
if (user.avatarUrl) {
try {
const imagePath = this.storageService.extractStoragePath(
user.avatarUrl,
);
if (imagePath) {
await this.storageService.delete(imagePath);
this.logger.log(`已删除用户头像: ${imagePath}`);
}
} catch (error) {
// 图片删除失败不影响用户删除操作
this.logger.warn(`删除用户头像失败: ${user.avatarUrl}`, error);
}
}
user.status = 'deleted'; user.status = 'deleted';
await this.userRepository.save(user); await this.userRepository.save(user);
} }
@@ -229,7 +229,7 @@ describe('UserService (集成测试)', () => {
password: 'password123', password: 'password123',
email: 'findall3@example.com', email: 'findall3@example.com',
nickname: '查询测试用户3', nickname: '查询测试用户3',
role: 'admin', // 注意:注册时不允许传入 role,所有注册用户的 role 固定为 'user'
}, },
]; ];
@@ -288,7 +288,7 @@ describe('UserService (集成测试)', () => {
nickname: '完整信息用户', nickname: '完整信息用户',
avatarUrl: 'https://example.com/avatar.jpg', avatarUrl: 'https://example.com/avatar.jpg',
phone: '13800138000', phone: '13800138000',
role: 'admin', // 注意:注册时不允许传入 role,所有注册用户的 role 固定为 'user'
}; };
await service.create(createUserDto); await service.create(createUserDto);
@@ -307,7 +307,7 @@ describe('UserService (集成测试)', () => {
expect(foundUser?.nickname).toBe(createUserDto.nickname); expect(foundUser?.nickname).toBe(createUserDto.nickname);
expect(foundUser?.avatarUrl).toBe(createUserDto.avatarUrl); expect(foundUser?.avatarUrl).toBe(createUserDto.avatarUrl);
expect(foundUser?.phone).toBe(createUserDto.phone); expect(foundUser?.phone).toBe(createUserDto.phone);
expect(foundUser?.role).toBe(createUserDto.role); expect(foundUser?.role).toBe('user'); // 注册用户的 role 固定为 'user'
expect(foundUser?.status).toBe('active'); expect(foundUser?.status).toBe('active');
expect(foundUser?.createdAt).toBeDefined(); expect(foundUser?.createdAt).toBeDefined();
expect(foundUser?.updatedAt).toBeDefined(); expect(foundUser?.updatedAt).toBeDefined();
+26 -23
View File
@@ -1,25 +1,28 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "nodenext", "module": "nodenext",
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"resolvePackageJsonExports": true, "resolvePackageJsonExports": true,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": true, "isolatedModules": true,
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"target": "ES2023", "target": "ES2023",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "paths": {
"skipLibCheck": true, "@/*": ["src/*"]
"strictNullChecks": true, },
"forceConsistentCasingInFileNames": true, "incremental": true,
"noImplicitAny": false, "skipLibCheck": true,
"strictBindCallApply": false, "strictNullChecks": true,
"noFallthroughCasesInSwitch": false "forceConsistentCasingInFileNames": true,
} "noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
} }
File diff suppressed because one or more lines are too long
+439
View File
@@ -0,0 +1,439 @@
# 资源上传文档
## 概述
本系统提供了完整的文件上传和管理功能,支持本地存储(未来可扩展至云存储)。主要用于上传券商Logo、用户头像等资源文件。
## 目录结构
```
storage/
├── dto/
│ └── upload-file.dto.ts # 上传文件DTO
├── interfaces/
│ └── storage-provider.interface.ts # 存储提供者接口
├── providers/
│ └── local-storage.provider.ts # 本地存储实现
├── storage.controller.ts # 存储控制器
├── storage.module.ts # 存储模块
└── storage.service.ts # 存储服务
```
## 环境配置
### 必需的环境变量
`.env` 文件中配置以下环境变量:
```env
# 存储类型:local(本地存储),未来可扩展为 qiniu、oss 等
STORAGE_TYPE=local
# 文件存储路径(相对于项目根目录)
STORAGE_PATH=./uploads
# 文件访问基础URL(用于生成文件访问链接)
STORAGE_BASE_URL=http://localhost:3200/uploads
```
### 配置说明
| 变量名 | 说明 | 默认值 | 示例 |
| ------------------ | --------------- | ------------------------------- | --------------------------------- |
| `STORAGE_TYPE` | 存储类型 | `local` | `local``qiniu`(未来支持) |
| `STORAGE_PATH` | 文件存储路径 | `./uploads` | `./uploads``/var/www/uploads` |
| `STORAGE_BASE_URL` | 文件访问基础URL | `http://localhost:3200/uploads` | `https://api.example.com/uploads` |
### 生产环境配置建议
```env
# 生产环境配置示例
STORAGE_TYPE=local
STORAGE_PATH=/var/www/invest-mind/uploads
STORAGE_BASE_URL=https://api.example.com/uploads
```
## 静态文件服务配置
系统在 `main.ts` 中自动配置了静态文件服务,无需额外配置:
```typescript
// 配置静态文件服务
const storagePath = configService.get<string>('STORAGE_PATH') || './uploads';
app.useStaticAssets(join(process.cwd(), storagePath), {
prefix: '/uploads/',
});
```
这意味着所有存储在 `STORAGE_PATH` 目录下的文件都可以通过 `/uploads/` 前缀访问。
## API 接口
### 1. 管理员上传文件
**接口地址:** `POST /api/storage/upload`
**权限要求:** 需要管理员权限(`admin``super_admin`
**请求格式:** `multipart/form-data`
**请求参数:**
| 参数名 | 类型 | 必填 | 说明 | 可选值 |
| ---------- | ------ | ---- | ------------ | ------------------------ |
| `file` | File | 是 | 要上传的文件 | - |
| `folder` | string | 否 | 存储文件夹 | `broker``user``temp` |
| `filename` | string | 否 | 自定义文件名 | - |
**文件限制:**
- 最大文件大小:5MB
- 允许的文件类型:`image/jpeg``image/jpg``image/png``image/gif``image/webp`
**请求示例:**
```bash
curl -X POST http://localhost:3200/api/storage/upload \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "file=@/path/to/image.jpg" \
-F "folder=broker" \
-F "filename=custom-name.jpg"
```
**响应示例:**
```json
{
"path": "broker/1234567890-abcdef-broker-logo.jpg",
"url": "http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg",
"filename": "1234567890-abcdef-broker-logo.jpg",
"size": 102400,
"mimetype": "image/jpeg"
}
```
### 2. 用户上传头像
**接口地址:** `POST /api/storage/upload/avatar`
**权限要求:** 无需鉴权(公开接口)
**请求格式:** `multipart/form-data`
**请求参数:**
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | ---- | ---------------- |
| `file` | File | 是 | 要上传的头像文件 |
**文件限制:**
- 最大文件大小:2MB
- 允许的文件类型:`image/jpeg``image/jpg``image/png``image/gif``image/webp`
- 文件固定存储在 `user` 文件夹
**请求示例:**
```bash
curl -X POST http://localhost:3200/api/storage/upload/avatar \
-F "file=@/path/to/avatar.jpg"
```
**响应示例:**
```json
{
"path": "user/1234567890-abcdef-avatar.jpg",
"url": "http://localhost:3200/uploads/user/1234567890-abcdef-avatar.jpg",
"filename": "1234567890-abcdef-avatar.jpg",
"size": 51200,
"mimetype": "image/jpeg"
}
```
### 3. 删除文件
**接口地址:** `DELETE /api/storage/{path}`
**权限要求:** 需要管理员权限(`admin``super_admin`
**路径参数:**
| 参数名 | 类型 | 说明 | 示例 |
| ------ | ------ | ------------ | ------------------------------------------ |
| `path` | string | 文件相对路径 | `broker/1234567890-abcdef-broker-logo.jpg` |
**请求示例:**
```bash
curl -X DELETE http://localhost:3200/api/storage/broker/1234567890-abcdef-broker-logo.jpg \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
**响应:**
- 成功:`204 No Content`
- 文件不存在:`404 Not Found`
- 权限不足:`403 Forbidden`
## 文件夹类型说明
系统支持三种文件夹类型,用于分类存储不同类型的文件:
| 文件夹 | 用途 | 说明 |
| -------- | ------------ | ------------------------------ |
| `broker` | 券商相关文件 | 用于存储券商Logo等基础数据文件 |
| `user` | 用户相关文件 | 用于存储用户头像等个人文件 |
| `temp` | 临时文件 | 用于存储临时文件,可定期清理 |
## 文件访问
### 访问方式
上传成功后,系统会返回文件的访问URL,可以通过以下方式访问:
1. **直接访问URL**:使用返回的 `url` 字段直接访问
```
http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg
```
2. **通过静态文件服务**:所有文件都通过 `/uploads/` 前缀提供静态文件服务
```
http://localhost:3200/uploads/{folder}/{filename}
```
### 文件命名规则
如果不指定自定义文件名,系统会自动生成文件名,格式为:
```
{timestamp}-{random}-{originalname}
```
例如:
- 原始文件名:`logo.jpg`
- 生成文件名:`1234567890-abcdef-logo.jpg`
其中:
- `timestamp`:时间戳(毫秒)
- `random`8字节随机十六进制字符串
- `originalname`:原始文件名(去除特殊字符)
### 文件名清理规则
系统会自动清理文件名中的特殊字符:
- 保留:字母、数字、下划线 `_`、连字符 `-`
- 替换:其他特殊字符会被替换为下划线 `_`
## 文件存储结构
本地存储的文件结构如下:
```
uploads/
├── broker/ # 券商相关文件
│ └── 1234567890-abcdef-broker-logo.jpg
├── user/ # 用户相关文件
│ └── 1234567890-abcdef-avatar.jpg
└── temp/ # 临时文件
└── 1234567890-abcdef-temp-file.jpg
```
## 安全特性
### 1. 路径遍历防护
删除文件时会进行安全检查,防止路径遍历攻击:
```typescript
// 安全检查:确保文件路径在 basePath 内
const resolvedPath = path.resolve(fullPath);
const resolvedBasePath = path.resolve(this.basePath);
if (!resolvedPath.startsWith(resolvedBasePath)) {
throw new Error('非法文件路径');
}
```
### 2. 文件类型验证
- 仅允许上传图片文件(jpeg、jpg、png、gif、webp
- 通过 MIME 类型和文件扩展名双重验证
### 3. 文件大小限制
- 管理员上传:最大 5MB
- 用户头像:最大 2MB
### 4. 权限控制
- 管理员上传和删除操作需要管理员权限
- 用户头像上传无需鉴权(用于注册场景)
## 使用示例
### JavaScript/TypeScript 示例
```typescript
// 上传文件
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('folder', 'broker');
formData.append('filename', 'custom-name.jpg');
const response = await fetch('http://localhost:3200/api/storage/upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
const result = await response.json();
console.log('文件URL:', result.url);
```
### React 示例
```tsx
import { useState } from 'react';
function FileUpload() {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<any>(null);
const handleUpload = async () => {
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('folder', 'broker');
try {
const response = await fetch(
'http://localhost:3200/api/storage/upload',
{
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: formData,
},
);
const data = await response.json();
setResult(data);
} catch (error) {
console.error('上传失败:', error);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<button onClick={handleUpload} disabled={uploading}>
{uploading ? '上传中...' : '上传'}
</button>
{result && (
<div>
<p>上传成功!</p>
<img src={result.url} alt="上传的文件" />
</div>
)}
</div>
);
}
```
## 扩展性
### 添加新的存储提供者
系统采用策略模式,可以轻松添加新的存储提供者(如七牛云、阿里云OSS等):
1. 实现 `IStorageProvider` 接口
2. 在 `StorageService` 中添加新的存储类型判断
3. 配置相应的环境变量
示例:
```typescript
// providers/qiniu-storage.provider.ts
export class QiniuStorageProvider implements IStorageProvider {
// 实现接口方法
}
// storage.service.ts
switch (storageType) {
case 'qiniu':
this.provider = new QiniuStorageProvider(configService);
break;
}
```
## 常见问题
### 1. 文件上传失败
**问题:** 上传文件时返回 400 错误
**解决方案:**
- 检查文件大小是否超过限制(管理员5MB,头像2MB)
- 检查文件类型是否为支持的图片格式
- 检查文件是否损坏
### 2. 文件无法访问
**问题:** 上传成功但无法通过URL访问文件
**解决方案:**
- 检查 `STORAGE_BASE_URL` 配置是否正确
- 检查静态文件服务是否正常启动
- 检查文件是否实际存在于 `STORAGE_PATH` 目录
- 检查文件权限是否正确
### 3. 删除文件失败
**问题:** 删除文件时返回 404 或 403 错误
**解决方案:**
- 404:检查文件路径是否正确,文件是否存在
- 403:检查是否有管理员权限
- 检查文件路径是否包含非法字符
### 4. 生产环境配置
**问题:** 如何在生产环境配置文件存储
**解决方案:**
1. 设置 `STORAGE_PATH` 为绝对路径(如 `/var/www/uploads`
2. 设置 `STORAGE_BASE_URL` 为生产域名(如 `https://api.example.com/uploads`
3. 确保目录有写入权限:`chmod -R 755 /var/www/uploads`
4. 考虑使用云存储服务(未来支持)
## 相关文件
- `storage.controller.ts` - API 控制器
- `storage.service.ts` - 存储服务
- `local-storage.provider.ts` - 本地存储实现
- `storage-provider.interface.ts` - 存储提供者接口
- `main.ts` - 静态文件服务配置
## 更新日志
- 初始版本:支持本地存储、文件上传、文件删除功能
+2 -2
View File
@@ -2,10 +2,10 @@
# 运行 pnpm dev 时会加载此文件 # 运行 pnpm dev 时会加载此文件
# 开发服务器端口 # 开发服务器端口
VITE_PORT=3201 VITE_PORT=3200
# API 基础地址(开发环境) # API 基础地址(开发环境)
VITE_API_BASE_URL=http://localhost:3200/api VITE_API_BASE_URL=http://localhost:3201/api
# 应用名称 # 应用名称
VITE_APP_NAME=投小记 VITE_APP_NAME=投小记
+9 -6
View File
@@ -1,13 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>投小记</title>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html>
</html>
+2 -1
View File
@@ -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",
+4 -2
View File
@@ -1,5 +1,5 @@
import { RouterProvider } from 'react-router'; import { RouterProvider } from 'react-router';
import { ConfigProvider } from 'antd'; import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import { router } from './router'; import { router } from './router';
import './App.css'; import './App.css';
@@ -15,7 +15,9 @@ function App() {
}, },
}} }}
> >
<RouterProvider router={router} /> <AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider> </ConfigProvider>
); );
} }
+73
View File
@@ -0,0 +1,73 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import ErrorPage from './ErrorPage';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 记录错误信息
console.error('ErrorBoundary 捕获到错误:', error, errorInfo);
this.setState({
error,
errorInfo,
});
// 可以在这里将错误发送到错误监控服务
// 例如:Sentry, LogRocket 等
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<ErrorPage
error={this.state.error}
errorInfo={this.state.errorInfo}
onReset={this.handleReset}
/>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+130
View File
@@ -0,0 +1,130 @@
.error-page {
min-height: calc(100vh - 64px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #f9fafb;
}
.error-page-card {
max-width: 600px;
width: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 12px;
}
.error-page-content {
text-align: center;
padding: 20px;
}
.error-icon {
font-size: 80px;
color: #8b5cf6;
margin-bottom: 24px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
.error-title {
color: #1f2937;
margin-bottom: 16px !important;
}
.error-description {
color: #6b7280;
font-size: 16px;
line-height: 1.6;
margin-bottom: 24px;
}
.error-message {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 24px;
text-align: left;
}
.error-actions {
margin-top: 32px;
margin-bottom: 24px;
}
.error-details {
margin-top: 24px;
text-align: left;
}
.error-details-content {
max-height: 400px;
overflow-y: auto;
}
.error-detail-section {
margin-bottom: 20px;
}
.error-detail-section:last-child {
margin-bottom: 0;
}
.error-detail-section h5 {
color: #1f2937;
margin-bottom: 8px;
}
.error-stack {
background: #1f2937;
color: #f9fafb;
padding: 16px;
border-radius: 6px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
}
/* 响应式 */
@media (max-width: 768px) {
.error-page {
padding: 16px;
}
.error-icon {
font-size: 60px;
}
.error-title {
font-size: 24px !important;
}
.error-description {
font-size: 14px;
}
.error-actions {
flex-direction: column;
width: 100%;
}
.error-actions .ant-btn {
width: 100%;
}
}
+184
View File
@@ -0,0 +1,184 @@
import { Button, Card, Typography, Space, Collapse } from 'antd';
import { ReloadOutlined, HomeOutlined, BugOutlined, FileSearchOutlined } from '@ant-design/icons';
import { useNavigate, useRouteError, isRouteErrorResponse } from 'react-router';
import type { ErrorInfo } from 'react';
import './ErrorPage.css';
const { Title, Paragraph, Text } = Typography;
interface ErrorPageProps {
error?: Error | null;
errorInfo?: ErrorInfo | null;
onReset?: () => void;
is404?: boolean;
}
const ErrorPage = ({ error: propError, errorInfo, onReset, is404: propIs404 }: ErrorPageProps) => {
const navigate = useNavigate();
const routeError = useRouteError();
// 判断是否为 404 错误
const is404 =
propIs404 ||
(isRouteErrorResponse(routeError) && routeError.status === 404) ||
(routeError instanceof Error && routeError.message.includes('404'));
// 获取错误信息
const error = propError || (routeError instanceof Error ? routeError : null);
const errorMessage = is404
? '页面未找到'
: isRouteErrorResponse(routeError)
? routeError.statusText || '页面加载失败'
: error?.message || '未知错误';
const handleGoHome = () => {
navigate('/');
if (onReset) {
onReset();
}
};
const handleReload = () => {
window.location.reload();
};
const handleGoBack = () => {
navigate(-1);
};
return (
<div className="error-page">
<Card className="error-page-card">
<div className="error-page-content">
<div className="error-icon">
{is404 ? <FileSearchOutlined /> : <BugOutlined />}
</div>
<Title level={2} className="error-title">
{is404 ? '页面未找到' : '哎呀,出错了!'}
</Title>
<Paragraph className="error-description">
{is404 ? (
<>
访
<br />
URL
</>
) : (
<>
<br />
</>
)}
</Paragraph>
{errorMessage && !is404 && (
<div className="error-message">
<Text type="danger" strong>
{errorMessage}
</Text>
</div>
)}
<Space size="middle" className="error-actions">
{!is404 && (
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={handleReload}
size="large"
>
</Button>
)}
<Button
type={is404 ? 'primary' : 'default'}
icon={<HomeOutlined />}
onClick={handleGoHome}
size="large"
>
</Button>
{is404 && (
<Button icon={<ReloadOutlined />} onClick={handleGoBack} size="large">
</Button>
)}
</Space>
{import.meta.env.DEV &&
!is404 &&
(error ||
errorInfo ||
(routeError !== null && routeError !== undefined)) && (
<Collapse
ghost
className="error-details"
items={[
{
key: '1',
label: '错误详情(开发模式)',
children: (
<div className="error-details-content">
{error && (
<div className="error-detail-section">
<Title level={5}></Title>
<pre className="error-stack">
{error.stack || error.toString()}
</pre>
</div>
)}
{isRouteErrorResponse(routeError) && (
<div className="error-detail-section">
<Title level={5}></Title>
<pre className="error-stack">
{`状态码: ${routeError.status}\n状态文本: ${routeError.statusText}\n数据: ${JSON.stringify(
routeError.data as Record<
string,
unknown
>,
null,
2
)}`}
</pre>
</div>
)}
{errorInfo && (
<div className="error-detail-section">
<Title level={5}></Title>
<pre className="error-stack">
{errorInfo.componentStack}
</pre>
</div>
)}
{routeError !== null &&
routeError !== undefined &&
!(routeError instanceof Error) &&
!isRouteErrorResponse(routeError) && (
<div className="error-detail-section">
<Title level={5}></Title>
<pre className="error-stack">
{JSON.stringify(
routeError as Record<
string,
unknown
>,
null,
2
)}
</pre>
</div>
)}
</div>
),
},
]}
/>
)}
</div>
</Card>
</div>
);
};
export default ErrorPage;
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router';
import { authService } from '../services/auth';
interface ProtectedRouteProps {
children: ReactNode;
}
/**
*
* 访
*/
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const isAuthenticated = authService.isAuthenticated();
if (!isAuthenticated) {
// 未登录,重定向到登录页
return <Navigate to="/login" replace />;
}
// 已登录,渲染子组件
return <>{children}</>;
}
+28 -23
View File
@@ -5,6 +5,8 @@
/* 侧边栏样式 */ /* 侧边栏样式 */
.main-sider { .main-sider {
background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%) !important; background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%) !important;
display: flex;
flex-direction: column;
} }
.sidebar-header { .sidebar-header {
@@ -25,26 +27,7 @@
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
} }
.sidebar-menu { /* 侧边栏菜单样式已移至 SidebarMenu.css */
background: transparent !important;
border: none;
}
.sidebar-menu .ant-menu-item {
margin: 0 !important;
padding: 12px 20px !important;
height: auto !important;
line-height: 1.5 !important;
}
.sidebar-menu .ant-menu-item-selected {
background: rgba(255, 255, 255, 0.2) !important;
border-left: 3px solid white;
}
.sidebar-menu .ant-menu-item:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
/* 顶部栏样式 */ /* 顶部栏样式 */
.main-header { .main-header {
@@ -60,7 +43,7 @@
top: 0; top: 0;
z-index: 100; z-index: 100;
height: 64px; height: 64px;
line-height: 64px; line-height: 1;
} }
.header-left { .header-left {
@@ -74,9 +57,15 @@
font-size: 18px; font-size: 18px;
cursor: pointer; cursor: pointer;
color: #1f2937; color: #1f2937;
padding: 8px; padding: 0;
border-radius: 4px; border-radius: 4px;
transition: background 0.2s; transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
} }
.collapse-trigger:hover, .collapse-trigger:hover,
@@ -96,6 +85,7 @@
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
line-height: 1.2; line-height: 1.2;
margin-bottom: 4px;
} }
.page-subtitle { .page-subtitle {
@@ -119,6 +109,7 @@
padding: 8px 12px; padding: 8px 12px;
border-radius: 8px; border-radius: 8px;
transition: background 0.2s; transition: background 0.2s;
height: 100%;
} }
.user-info:hover { .user-info:hover {
@@ -129,23 +120,37 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
justify-content: center;
line-height: 1.2;
} }
.user-name { .user-name {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #1f2937; color: #1f2937;
line-height: 1.2;
white-space: nowrap;
} }
.user-role { .user-role {
font-size: 12px; font-size: 12px;
color: #6b7280; color: #6b7280;
line-height: 1.2;
}
.user-details .ant-badge {
line-height: 1.2;
}
.user-details .ant-badge-status-text {
font-size: 12px;
line-height: 1.2;
} }
/* 内容区域 */ /* 内容区域 */
.main-content { .main-content {
padding: 24px; padding: 24px;
background: #f9fafb; background: #f3f4f6;
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
} }
+89 -73
View File
@@ -1,40 +1,45 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router'; import { Outlet, useLocation, useNavigate } from 'react-router';
import { Layout, Menu, Avatar, Badge, Drawer } from 'antd'; import { Layout, Avatar, Badge, Drawer, Dropdown, message } from 'antd';
import { import {
BarChartOutlined,
FileTextOutlined,
EditOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
LogoutOutlined,
UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { authService } from '@/services/auth';
import type { UserInfo } from '@/types/user';
import { useBrokerStore } from '@/stores/broker';
import SidebarMenu from './SidebarMenu';
import ErrorBoundary from '@/components/ErrorBoundary';
import { getPageInfo } from './menuConfig';
import './MainLayout.css'; import './MainLayout.css';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
interface MainLayoutProps { const MainLayout = () => {
isAdmin?: boolean;
}
// 页面标题映射
const pageTitles: Record<string, { title: string; subtitle: string }> = {
'/': { title: '资产账户', subtitle: '买股票就是买公司' },
'/assets': { title: '资产账户', subtitle: '买股票就是买公司' },
'/plans': { title: '交易计划', subtitle: '计划你的交易,交易你的计划' },
'/review': { title: '投资复盘', subtitle: '回顾过去是为了更好应对将来' },
};
const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [user, setUser] = useState<UserInfo | null>(null);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
// 获取用户信息
useEffect(() => {
const currentUser = authService.getUser();
setUser(currentUser);
}, []);
// 初始化券商数据
useEffect(() => {
useBrokerStore.getState().fetchBrokers();
}, []);
// 根据路由获取页面标题 // 根据路由获取页面标题
const pageInfo = useMemo(() => { const pageInfo = useMemo(() => {
return pageTitles[location.pathname] || pageTitles['/']; return getPageInfo(location.pathname);
}, [location.pathname]); }, [location.pathname]);
// 检测移动端 // 检测移动端
@@ -51,41 +56,50 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
return () => window.removeEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile);
}, []); }, []);
// 菜单项配置 // 处理登出
const menuItems: MenuProps['items'] = [ const handleLogout = () => {
authService.logout();
message.success('已退出登录');
navigate('/login', { replace: true });
};
// 用户下拉菜单
const userMenuItems: MenuProps['items'] = [
{ {
key: '/assets', key: 'profile',
icon: <BarChartOutlined />, icon: <UserOutlined />,
label: '资产账户', label: '个人资料',
onClick: () => navigate('/user-info'),
}, },
{ {
key: '/plans', type: 'divider',
icon: <FileTextOutlined />,
label: '交易计划',
}, },
{ {
key: '/review', key: 'logout',
icon: <EditOutlined />, icon: <LogoutOutlined />,
label: '投资复盘', label: '退出登录',
danger: true,
onClick: handleLogout,
}, },
]; ];
// 处理菜单点击 // 获取角色显示文本
const handleMenuClick = ({ key }: { key: string }) => { const getRoleText = (role: string) => {
navigate(key); const roleMap: Record<string, string> = {
if (isMobile) { user: '普通用户',
setMobileMenuOpen(false); admin: '管理员',
} super_admin: '超级管理员',
};
return roleMap[role] || '普通用户';
}; };
// 获取当前选中的菜单项 // 获取角色状态
const selectedKeys = useMemo(() => { const getRoleStatus = (role: string): 'success' | 'warning' | 'error' => {
const path = location.pathname; if (role === 'admin' || role === 'super_admin') {
if (path === '/' || path === '/assets') { return 'warning';
return ['/assets'];
} }
return [path]; return 'success';
}, [location.pathname]); };
return ( return (
<Layout className="main-layout"> <Layout className="main-layout">
@@ -109,14 +123,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
<div className="logo">{collapsed ? '投' : '投小记'}</div> <div className="logo">{collapsed ? '投' : '投小记'}</div>
{!collapsed && <div className="logo-subtitle">VestMind</div>} {!collapsed && <div className="logo-subtitle">VestMind</div>}
</div> </div>
<Menu <SidebarMenu collapsed={collapsed} user={user} />
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
items={menuItems}
onClick={handleMenuClick}
className="sidebar-menu"
/>
</Sider> </Sider>
)} )}
@@ -130,12 +137,10 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
width={240} width={240}
> >
<Menu <SidebarMenu
mode="inline" collapsed={false}
selectedKeys={selectedKeys} user={user}
items={menuItems} onMenuClick={() => setMobileMenuOpen(false)}
onClick={handleMenuClick}
style={{ border: 'none' }}
/> />
</Drawer> </Drawer>
)} )}
@@ -171,29 +176,40 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
</div> </div>
</div> </div>
<div className="header-right"> <div className="header-right">
<div className="user-info"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Avatar <div className="user-info" style={{ cursor: 'pointer' }}>
style={{ <Avatar
backgroundColor: '#8b5cf6', style={{
verticalAlign: 'middle', backgroundColor: '#8b5cf6',
}} verticalAlign: 'middle',
> }}
U src={user?.avatarUrl}
</Avatar> >
<div className="user-details"> {user?.nickname?.[0] || user?.username?.[0] || 'U'}
<div className="user-name"></div> </Avatar>
<Badge <div className="user-details">
status="success" <div className="user-name">
text={<span className="user-role"></span>} {user?.nickname || user?.username || '用户'}
/> </div>
<Badge
status={user ? getRoleStatus(user.role) : 'success'}
text={
<span className="user-role">
{user ? getRoleText(user.role) : '普通用户'}
</span>
}
/>
</div>
</div> </div>
</div> </Dropdown>
</div> </div>
</Header> </Header>
{/* 内容区域 */} {/* 内容区域 */}
<Content className="main-content"> <Content className="main-content">
<Outlet /> <ErrorBoundary>
<Outlet />
</ErrorBoundary>
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>
+155
View File
@@ -0,0 +1,155 @@
.sidebar-menu-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sidebar-menu {
background: transparent !important;
border: none;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-menu .ant-menu-item {
margin: 0 !important;
padding: 12px 20px !important;
height: auto !important;
line-height: 1.5 !important;
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.3s !important;
}
/* 菜单文本样式 - 加大字体并加粗 */
.sidebar-menu .ant-menu-item {
font-size: 15px !important;
font-weight: 600 !important;
}
.sidebar-menu .ant-menu-item .ant-menu-title-content {
font-size: 15px !important;
font-weight: 600 !important;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
}
/* hover状态 - 不缩进,只改变背景 */
.sidebar-menu .ant-menu-item:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
.sidebar-menu .ant-menu-item:hover .ant-menu-title-content {
transform: translateX(0);
}
/* 选中状态 - 文本缩进 */
.sidebar-menu .ant-menu-item-selected {
background: rgba(255, 255, 255, 0.2) !important;
border-left: 3px solid white;
}
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
transform: translateX(8px);
}
.sidebar-menu .ant-menu-item-selected:hover {
background: rgba(255, 255, 255, 0.25) !important;
}
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
transform: translateX(8px);
}
/* 菜单分组标题 */
.sidebar-menu .ant-menu-item-group-title {
padding: 12px 20px 8px !important;
font-size: 12px !important;
color: rgba(255, 255, 255, 0.7) !important;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
transition:
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-menu .menu-section-title .ant-menu-item-group-title {
opacity: 1;
}
/* 折叠状态下隐藏分组标题 */
.sidebar-menu .ant-menu-item-group-title:empty {
opacity: 0;
height: 0;
padding: 0;
margin: 0;
overflow: hidden;
}
/* 分隔线 */
.sidebar-menu .ant-menu-item-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1) !important;
margin: 8px 20px !important;
border: none;
}
.sidebar-menu .menu-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1) !important;
margin: 8px 20px !important;
border: none;
}
/* 管理员功能区域动画 */
.sidebar-menu .admin-section {
animation: fadeInDown 0.4s ease-out;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 菜单项图标样式和动画 */
.sidebar-menu .ant-menu-item-icon {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 18px !important;
display: inline-flex;
align-items: center;
}
/* hover状态 - 图标不缩进,只轻微放大 */
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
transform: scale(1.05);
}
/* 选中状态下图标也跟随文本缩进 */
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
transform: translateX(8px);
}
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-item-icon {
transform: translateX(8px) scale(1.05);
}
/* 折叠状态下的样式调整 */
.sidebar-menu .ant-menu-item-group-title {
transition: opacity 0.2s ease;
}
/* 响应式调整 */
@media (max-width: 768px) {
.sidebar-menu .ant-menu-item {
padding: 14px 20px !important;
}
}
+99
View File
@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { Menu } from 'antd';
import type { MenuProps } from 'antd';
import type { UserInfo } from '@/types/user';
import { getMainMenuItems, getAdminMenuItems } from './menuConfig';
import './SidebarMenu.css';
interface SidebarMenuProps {
collapsed: boolean;
user: UserInfo | null;
onMenuClick?: () => void;
}
const SidebarMenu = ({ collapsed, user, onMenuClick }: SidebarMenuProps) => {
const location = useLocation();
const navigate = useNavigate();
// 判断是否为管理员
const isAdmin = useMemo(() => {
return user?.role === 'admin' || user?.role === 'super_admin';
}, [user?.role]);
// 合并菜单项(带分组)
const menuItems: MenuProps['items'] = useMemo(() => {
// 获取主要功能菜单项
const mainMenuConfigs = getMainMenuItems();
const mainMenuItems: MenuProps['items'] = mainMenuConfigs.map((config) => ({
key: config.key,
icon: config.icon,
label: config.label,
}));
// 获取管理员功能菜单项
const adminMenuConfigs = getAdminMenuItems();
const adminMenuItems: MenuProps['items'] = adminMenuConfigs.map((config) => ({
key: config.key,
icon: config.icon,
label: config.label,
}));
const items: MenuProps['items'] = [
{
type: 'group',
label: collapsed ? '' : '主要功能',
className: 'menu-section-title',
children: mainMenuItems,
},
];
// 如果是管理员,添加分隔线和管理员功能
if (isAdmin && adminMenuItems.length > 0) {
items.push({
type: 'divider',
className: 'menu-divider',
});
items.push({
type: 'group',
label: collapsed ? '' : '管理员功能',
className: 'menu-section-title admin-section',
children: adminMenuItems,
});
}
return items;
}, [collapsed, isAdmin]);
// 处理菜单点击
const handleMenuClick = ({ key }: { key: string }) => {
navigate(key);
if (onMenuClick) {
onMenuClick();
}
};
// 获取当前选中的菜单项
const selectedKeys = useMemo(() => {
const path = location.pathname;
if (path === '/' || path === '/assets') {
return ['/assets'];
}
return [path];
}, [location.pathname]);
return (
<div className="sidebar-menu-container">
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
items={menuItems}
onClick={handleMenuClick}
className="sidebar-menu"
/>
</div>
);
};
export default SidebarMenu;
+186
View File
@@ -0,0 +1,186 @@
import {
BarChartOutlined,
FileTextOutlined,
EditOutlined,
SettingOutlined,
DashboardOutlined,
BankOutlined,
UserOutlined,
StockOutlined,
LineChartOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
/**
*
*/
export interface RouteMenuConfig {
/** 路由路径 */
path: string;
/** 菜单键值(通常与 path 相同) */
key: string;
/** 菜单图标 */
icon: ReactNode;
/** 菜单标签 */
label: string;
/** 页面标题 */
title: string;
/** 页面副标题 */
subtitle: string;
/** 菜单分组:'main' 主要功能,'admin' 管理员功能 */
group: 'main' | 'admin';
/** 是否需要管理员权限 */
requireAdmin?: boolean;
}
/**
*
*/
export const routeMenuConfig: RouteMenuConfig[] = [
{
path: '/assets',
key: '/assets',
icon: <BarChartOutlined />,
label: '资产账户',
title: '资产账户',
subtitle: '买股票就是买公司',
group: 'main',
},
{
path: '/plans',
key: '/plans',
icon: <FileTextOutlined />,
label: '交易计划',
title: '交易计划',
subtitle: '计划你的交易,交易你的计划',
group: 'main',
},
{
path: '/review',
key: '/review',
icon: <EditOutlined />,
label: '投资复盘',
title: '投资复盘',
subtitle: '回顾过去是为了更好应对将来',
group: 'main',
},
// {
// path: '/user-info',
// key: '/user-info',
// icon: <UserOutlined />,
// label: '个人资料',
// title: '个人资料',
// subtitle: '查看和编辑个人信息',
// group: 'main',
// },
{
path: '/user',
key: '/user',
icon: <UserOutlined />,
label: '用户管理',
title: '用户管理',
subtitle: '管理用户信息',
group: 'admin',
requireAdmin: true,
},
{
path: '/broker',
key: '/broker',
icon: <BankOutlined />,
label: '券商管理',
title: '券商管理',
subtitle: '管理券商信息',
group: 'admin',
requireAdmin: true,
},
{
path: '/stock-info',
key: '/stock-info',
icon: <StockOutlined />,
label: '股票信息',
title: '股票信息',
subtitle: '管理股票基本信息',
group: 'admin',
requireAdmin: true,
},
{
path: '/stock-daily-price',
key: '/stock-daily-price',
icon: <LineChartOutlined />,
label: '股票价格',
title: '股票价格',
subtitle: '查看股票每日价格数据',
group: 'admin',
requireAdmin: true,
},
{
path: '/seo',
key: '/seo',
icon: <SettingOutlined />,
label: 'SEO配置',
title: 'SEO配置',
subtitle: '优化搜索引擎可见性',
group: 'admin',
requireAdmin: true,
},
{
path: '/analytics',
key: '/analytics',
icon: <DashboardOutlined />,
label: '数据统计',
title: '数据统计',
subtitle: '了解用户行为与系统数据',
group: 'admin',
requireAdmin: true,
},
];
/**
*
*/
export const getPageInfo = (path: string): { title: string; subtitle: string } => {
// 处理根路径
if (path === '/' || path === '') {
const defaultRoute = routeMenuConfig.find((item) => item.path === '/assets');
return defaultRoute
? { title: defaultRoute.title, subtitle: defaultRoute.subtitle }
: { title: '资产账户', subtitle: '买股票就是买公司' };
}
const config = routeMenuConfig.find((item) => item.path === path);
return config
? { title: config.title, subtitle: config.subtitle }
: { title: '资产账户', subtitle: '买股票就是买公司' };
};
/**
*
*/
export const getMainMenuItems = () => {
return routeMenuConfig.filter((item) => item.group === 'main');
};
/**
*
*/
export const getAdminMenuItems = () => {
return routeMenuConfig.filter((item) => item.group === 'admin' && item.requireAdmin);
};
/**
*
*/
export const pageTitles: Record<string, { title: string; subtitle: string }> = (() => {
const titles: Record<string, { title: string; subtitle: string }> = {
'/': getPageInfo('/assets'),
};
routeMenuConfig.forEach((config) => {
titles[config.path] = {
title: config.title,
subtitle: config.subtitle,
};
});
return titles;
})();
+3 -3
View File
@@ -4,7 +4,7 @@ import './index.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
) )
-113
View File
@@ -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%;
}
}
+58
View File
@@ -0,0 +1,58 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-box {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
margin: 0 0 8px 0;
font-size: 32px;
font-weight: 600;
color: var(--primary-color, #8b5cf6);
}
.login-header p {
margin: 0;
color: var(--text-secondary, #6b7280);
font-size: 14px;
}
.login-form {
margin-top: 24px;
}
.login-form .ant-input-affix-wrapper,
.login-form .ant-input {
border-radius: 8px;
}
.login-button {
height: 44px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
background: var(--primary-color, #8b5cf6);
border-color: var(--primary-color, #8b5cf6);
}
.login-button:hover {
background: var(--primary-dark, #7c3aed);
border-color: var(--primary-dark, #7c3aed);
}
+95
View File
@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { Button, Form, Input, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { authService } from '../services/auth';
import type { LoginRequest } from '@/types/user';
import './LoginPage.css';
export default function LoginPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
// 如果已登录,重定向到首页
useEffect(() => {
if (authService.isAuthenticated()) {
navigate('/', { replace: true });
}
}, [navigate]);
const onFinish = async (values: LoginRequest) => {
setLoading(true);
try {
await authService.login(values);
message.success('登录成功');
navigate('/', { replace: true });
} catch (error: any) {
message.error(error.message || '登录失败,请检查用户名和密码');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-box">
<div className="login-header">
<h1></h1>
<p></p>
</div>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
className="login-form"
>
<Form.Item
name="usernameOrEmail"
rules={[
{
required: true,
message: '请输入用户名或邮箱',
},
{
min: 3,
message: '用户名或邮箱至少3个字符',
},
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名或邮箱" />
</Form.Item>
<Form.Item
name="password"
rules={[
{
required: true,
message: '请输入密码',
},
{
min: 6,
message: '密码至少6个字符',
},
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="login-button"
>
</Button>
</Form.Item>
</Form>
</div>
</div>
);
}
+201
View 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,19 @@
import { Card, Row, Col, Statistic, Button } from 'antd'; import { useEffect } from 'react';
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 { stockDataService } from '@/services/stock-data';
import './AssetsPage.css'; import './AssetsPage.css';
const AssetsPage = () => { const AssetsPage = () => {
// 写死的数据 // 初始化股票数据(页面加载时)
useEffect(() => {
stockDataService.init().catch((error) => {
console.error('初始化股票数据失败', error);
});
}, []);
// 写死的数据(占位)
const stats = { const stats = {
totalAssets: 1234567, totalAssets: 1234567,
totalProfit: 234567, totalProfit: 234567,
@@ -11,36 +21,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 +84,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>
); );
}; };
@@ -0,0 +1,624 @@
import { useState, useMemo, useEffect, useRef } from 'react';
import {
Modal,
Form,
Input,
InputNumber,
Select,
Switch,
AutoComplete,
Button,
Alert,
Tag,
App as AntdApp,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { positionService } from '@/services/position';
import { stockDataService, type AssetSearchResult } from '@/services/stock-data';
import { useBrokerStore } from '@/stores/broker';
import type { CreatePositionRequest } from '@/types/position';
// 简单的防抖函数
function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}
interface CreatePositionModalProps {
open: boolean;
onCancel: () => void;
onSuccess: () => void;
}
const CreatePositionModal = ({ open, onCancel, onSuccess }: CreatePositionModalProps) => {
const [form] = Form.useForm();
const { message: messageApi } = AntdApp.useApp();
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState<AssetSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedAsset, setSelectedAsset] = useState<AssetSearchResult | null>(null);
const [assetType, setAssetType] = useState<string>('');
const [showManualInput, setShowManualInput] = useState(false);
const nameInputRef = useRef<any>(null);
const brokers = useBrokerStore((state) => state.brokers);
// 初始化股票数据(弹窗打开时)
useEffect(() => {
if (open) {
stockDataService.init().catch((error) => {
console.error('初始化股票数据失败', error);
messageApi.warning('股票数据加载失败,搜索功能可能不可用');
});
}
}, [open, messageApi]);
// 防抖搜索(前端字符串匹配)
const debouncedSearch = useMemo(
() =>
debounce((keyword: string) => {
if (!keyword || keyword.trim().length < 1) {
setSearchResults([]);
setIsSearching(false);
return;
}
setIsSearching(true);
try {
// 在前端进行字符串匹配
const results = stockDataService.searchAssets(keyword, 10);
setSearchResults(results);
} catch (error: any) {
console.error('搜索失败', error);
setSearchResults([]);
messageApi.error(error.message || '搜索失败');
} finally {
setIsSearching(false);
}
}, 300),
[messageApi]
);
// 搜索资产
const handleSearch = (keyword: string) => {
setSearchKeyword(keyword);
debouncedSearch(keyword);
};
// 选择资产
const handleSelectAsset = (asset: AssetSearchResult) => {
setSelectedAsset(asset);
setSearchKeyword(asset.name);
setSearchResults([]);
setShowManualInput(false);
// 统一市场代码:sh/sz/bj -> 'a' (A股)
// 港股和美股直接使用
const formMarket = asset.market === 'a' ? 'a' : asset.market;
// 自动填充表单
form.setFieldsValue({
assetType: 'stock', // 能匹配上的一定是股票
market: formMarket,
symbol: asset.symbol,
name: asset.name,
currency: asset.market === 'hk' ? 'HKD' : asset.market === 'us' ? 'USD' : 'CNY',
});
setAssetType('stock');
};
// 重新选择
const handleResetSearch = () => {
setSelectedAsset(null);
setSearchKeyword('');
setSearchResults([]);
form.setFieldsValue({
assetType: assetType || undefined,
market: undefined,
symbol: undefined,
name: undefined,
});
};
// 资产类型变化
const handleAssetTypeChange = (value: string) => {
setAssetType(value);
form.setFieldsValue({ assetType: value });
// 如果手动修改了资产类型,清空搜索选择
if (selectedAsset && value !== 'stock') {
handleResetSearch();
}
// 如果选择了现金或其他,隐藏搜索框
if (value === 'cash' || value === 'other') {
setShowManualInput(true);
} else {
setShowManualInput(false);
}
// 如果搜索框有内容,重新搜索
if (searchKeyword && value === 'stock') {
handleSearch(searchKeyword);
}
};
// 获取搜索框占位符
const getSearchPlaceholder = () => {
return '输入股票代码或名称搜索(如:600519 或 贵州茅台)';
};
// 提交表单
const handleSubmit = async (values: any) => {
setLoading(true);
try {
// 如果市场是 'a'A股),转换为 'sh'(默认使用上海市场)
const marketValue = values.market === 'a' ? 'sh' : values.market;
// 现金类型:symbol 为空字符串,name 固定为"现金"
// 其他类型:symbol 为空字符串
const symbolValue =
values.assetType === 'cash' || values.assetType === 'other'
? ''
: values.symbol || '';
const nameValue = values.assetType === 'cash' ? '现金' : values.name || '';
const requestData: CreatePositionRequest = {
// 其他类型不需要 brokerId 和 market
brokerId: values.assetType === 'other' ? undefined : values.brokerId,
assetType: values.assetType,
symbol: symbolValue,
name: nameValue,
market:
values.assetType === 'other' || values.assetType === 'cash'
? undefined
: marketValue,
// 现金类型不需要 shares 和 costPrice,使用默认值
shares: values.assetType === 'cash' ? 1 : values.shares,
costPrice:
values.assetType === 'cash' ? values.currentPrice || 0 : values.costPrice,
currentPrice: values.currentPrice,
currency: values.currency || 'CNY',
autoPriceUpdate: values.autoPriceUpdate || false,
status: 'active',
};
const response = await positionService.createPosition(requestData);
if (response.code === 0) {
messageApi.success('创建持仓成功');
form.resetFields();
setSelectedAsset(null);
setSearchKeyword('');
setAssetType('');
onSuccess();
onCancel();
} else {
messageApi.error(response.message || '创建持仓失败');
}
} catch (error: any) {
console.error('创建持仓失败:', error);
messageApi.error('创建持仓失败,请重试!');
} finally {
setLoading(false);
}
};
// 重置表单
const handleCancel = () => {
form.resetFields();
setSelectedAsset(null);
setSearchKeyword('');
setSearchResults([]);
setAssetType('');
setShowManualInput(false);
onCancel();
};
// 市场选项
const marketOptions = [
{ value: 'a', label: 'A股' },
{ value: 'hk', label: '港股' },
{ value: 'us', label: '美股' },
{ value: 'jp', label: '日股' },
{ value: 'kr', label: '韩国股市' },
{ value: 'eu', label: '欧洲市场' },
{ value: 'sea', label: '东南亚市场' },
{ value: 'other', label: '其他' },
];
// 获取市场显示名称
const getMarketDisplayName = (market: string) => {
// 统一市场代码映射
if (market === 'a' || market === 'sh' || market === 'sz' || market === 'bj') {
return 'A股';
}
const option = marketOptions.find((opt) => opt.value === market);
return option ? option.label : market;
};
// 根据资产类型获取代码标签
const getCodeLabel = (type: string) => {
switch (type) {
case 'stock':
return '股票代码';
case 'fund':
return '基金代码';
case 'bond':
return '债券代码';
default:
return '资产代码';
}
};
// 根据资产类型获取名称标签
const getNameLabel = (type: string) => {
switch (type) {
case 'stock':
return '股票名称';
case 'fund':
return '基金名称';
case 'bond':
return '债券名称';
default:
return '资产名称';
}
};
return (
<Modal
title="新建持仓"
open={open}
onCancel={handleCancel}
footer={null}
width={600}
destroyOnHidden
>
<Form
form={form}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
onFinish={handleSubmit}
initialValues={{
currency: 'CNY',
autoPriceUpdate: false,
}}
style={{
marginTop: 10,
maxWidth: '480px',
margin: '10px auto 0',
}}
>
{/* 搜索框(现金和其他类型时隐藏) */}
{!showManualInput && (
<Form.Item label="搜索资产">
<AutoComplete
value={searchKeyword}
options={searchResults.map((asset) => ({
value: `${asset.symbol} - ${asset.name}`,
label: (
<div>
<div>
<strong>{asset.symbol}</strong> - {asset.name}
</div>
<div style={{ fontSize: '12px', color: '#999' }}>
{getMarketDisplayName(asset.market)} -
</div>
</div>
),
asset: asset,
}))}
onChange={handleSearch}
onSelect={(_: any, option: any) => handleSelectAsset(option.asset)}
placeholder={getSearchPlaceholder()}
allowClear
disabled={!!selectedAsset}
notFoundContent={
searchKeyword && !isSearching ? (
<div>
<div></div>
<Button
type="link"
size="small"
onClick={() => {
// 聚焦到资产名称输入框
setTimeout(() => {
nameInputRef.current?.focus();
}, 100);
}}
>
</Button>
</div>
) : null
}
showSearch={true}
/>
{selectedAsset && (
<div style={{ marginTop: 8 }}>
<Tag color="blue">{selectedAsset.name}</Tag>
<Button
type="link"
size="small"
icon={<ReloadOutlined />}
onClick={handleResetSearch}
>
</Button>
</div>
)}
</Form.Item>
)}
{/* 资产类型 */}
<Form.Item
name="assetType"
label="资产类型"
rules={[{ required: true, message: '请选择资产类型' }]}
>
<Select
onChange={handleAssetTypeChange}
disabled={!!selectedAsset}
placeholder="选择资产类型"
>
<Select.Option value="stock"></Select.Option>
<Select.Option value="fund"></Select.Option>
<Select.Option value="bond"></Select.Option>
<Select.Option value="cash"></Select.Option>
<Select.Option value="other"></Select.Option>
</Select>
</Form.Item>
{/* 其他字段(有动画效果,只有在选择资产类型或选中资产后才显示) */}
<div
style={{
overflow: 'hidden',
transition: 'max-height 0.5s ease-in-out, opacity 0.5s ease-in-out',
maxHeight: assetType || selectedAsset ? '3000px' : '0',
opacity: assetType || selectedAsset ? 1 : 0,
}}
>
{/* 市场和券商(股票/基金/债券显示) */}
{(assetType === 'stock' || assetType === 'fund' || assetType === 'bond') && (
<>
<Form.Item
name="symbol"
label={getCodeLabel(assetType)}
rules={[
{ required: true, message: `请输入${getCodeLabel(assetType)}` },
]}
>
<Input
placeholder={`如:${assetType === 'stock' ? '600519、00700、AAPL' : assetType === 'fund' ? '000001' : '100001'}`}
disabled={!!selectedAsset}
/>
</Form.Item>
<Form.Item
name="name"
label={getNameLabel(assetType)}
rules={[
{ required: true, message: `请输入${getNameLabel(assetType)}` },
]}
>
<Input
ref={nameInputRef as any}
placeholder={`如:${assetType === 'stock' ? '贵州茅台' : assetType === 'fund' ? '华夏成长' : '国债'}`}
disabled={!!selectedAsset}
/>
</Form.Item>
<Form.Item name="market" label="市场">
<Select disabled={!!selectedAsset} placeholder="选择市场">
{marketOptions.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="brokerId" label="券商">
<Select placeholder="选择券商">
{brokers.map((broker) => (
<Select.Option
key={broker.brokerId}
value={broker.brokerId}
>
{broker.brokerName}
</Select.Option>
))}
</Select>
</Form.Item>
</>
)}
{/* 券商(现金显示) */}
{assetType === 'cash' && (
<Form.Item name="brokerId" label="券商">
<Select placeholder="选择券商">
{brokers.map((broker) => (
<Select.Option key={broker.brokerId} value={broker.brokerId}>
{broker.brokerName}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{/* 其他类型:只显示名称、成本价、数量、最新价 */}
{assetType === 'other' && (
<>
<Form.Item
name="name"
label="资产名称"
rules={[{ required: true, message: '请输入资产名称' }]}
>
<Input ref={nameInputRef as any} placeholder="如:其他资产" />
</Form.Item>
<Form.Item
name="costPrice"
label="成本价"
rules={[
{ required: true, message: '请输入成本价' },
{ type: 'number', min: 0.0001, message: '成本价必须大于0' },
]}
>
<InputNumber
prefix="¥"
precision={2}
placeholder="成本价"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="shares"
label="数量"
rules={[
{ required: true, message: '请输入数量' },
{ type: 'number', min: 0.0001, message: '数量必须大于0' },
]}
>
<InputNumber
precision={4}
placeholder="输入数量"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="currentPrice" label="最新价">
<InputNumber
prefix="¥"
precision={2}
placeholder="输入最新价"
style={{ width: '100%' }}
/>
</Form.Item>
</>
)}
{/* 价格和数量(现金和其他类型不显示成本价和持股数量) */}
{assetType && assetType !== 'cash' && assetType !== 'other' && (
<>
<Form.Item
name="costPrice"
label="成本价"
rules={[
{ required: true, message: '请输入成本价' },
{ type: 'number', min: 0.0001, message: '成本价必须大于0' },
]}
>
<InputNumber
prefix="¥"
precision={2}
placeholder="成本价"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="shares"
label="持股数量"
rules={[
{ required: true, message: '请输入持股数量' },
{ type: 'number', min: 0.0001, message: '持股数量必须大于0' },
]}
>
<InputNumber
precision={4}
placeholder="输入持股数量"
style={{ width: '100%' }}
/>
</Form.Item>
</>
)}
{/* 最新价/现金余额(其他类型已在上面单独处理) */}
{assetType && assetType !== 'other' && (
<Form.Item
name="currentPrice"
label={assetType === 'cash' ? '现金余额' : '最新价'}
rules={
assetType === 'cash'
? [
{ required: true, message: '请输入现金余额' },
{ type: 'number', min: 0, message: '现金余额不能小于0' },
]
: []
}
>
<InputNumber
prefix="¥"
precision={2}
placeholder={assetType === 'cash' ? '输入现金余额' : '输入最新价'}
style={{ width: '100%' }}
/>
</Form.Item>
)}
{/*
<Form.Item name="currency" label="货币类型">
<Select>
<Select.Option value="CNY"></Select.Option>
<Select.Option value="HKD"></Select.Option>
<Select.Option value="USD"></Select.Option>
</Select>
</Form.Item>*/}
{assetType && (
<>
<Form.Item
name="autoPriceUpdate"
label="自动更新价格"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Alert
description={
<div>
<p> </p>
<p> 使</p>
<p>
//
</p>
<p> </p>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<div style={{ display: 'flex', justifyContent: 'center', gap: 12 }}>
<Button onClick={handleCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</div>
</>
)}
</div>
</Form>
</Modal>
);
};
export default CreatePositionModal;
@@ -0,0 +1,73 @@
/* 我的持仓容器 */
.position-list-container {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
/* 标题和按钮区域 */
.position-list-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.position-list-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #1f2937;
}
@media (min-width: 768px) {
.position-list-title {
font-size: 18px;
}
}
/* 添加资产按钮 - 完全圆形 */
.position-add-btn {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border: 1px solid #8b5cf6 !important;
background: transparent !important;
color: #8b5cf6 !important;
border-radius: 50% !important;
cursor: pointer;
transition: all 0.2s;
}
.position-add-btn:hover,
.position-add-btn:focus {
background: #8b5cf6 !important;
color: #fff !important;
border-color: #8b5cf6 !important;
}
.position-add-btn:active {
background: #7c3aed !important;
border-color: #7c3aed !important;
color: #fff !important;
}
.position-add-btn .anticon {
font-size: 16px;
}
@media (min-width: 768px) {
.position-add-btn {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
}
.position-add-btn .anticon {
font-size: 18px;
}
}
@@ -0,0 +1,209 @@
import { useState, useEffect } from 'react';
import { Card, Button, App, Spin } from 'antd';
import { PlusOutlined, RightOutlined } from '@ant-design/icons';
import { positionService } from '@/services/position';
import type { PositionResponse } from '@/types/position';
import { useBrokerStore } from '@/stores/broker';
import { useMarketStore } from '@/stores/market';
import CreatePositionModal from './CreatePositionModal';
import '../AssetsPage.css';
import './PositionList.css';
const PositionList = () => {
const { message: messageApi } = App.useApp();
const [loading, setLoading] = useState(false);
const [positions, setPositions] = useState<PositionResponse[]>([]);
const [createModalOpen, setCreateModalOpen] = useState(false);
const getBrokerName = useBrokerStore((state) => state.getBrokerName);
const getMarketName = useMarketStore((state) => state.getMarketName);
// 加载持仓数据
const loadPositions = async () => {
setLoading(true);
try {
const positionData = await positionService.getPositionsByUserId();
if (positionData && positionData.code === 0) {
const positionList = positionData.data;
setPositions(positionList || []);
} else {
setPositions([]);
messageApi.error(positionData.message || '加载持仓数据失败');
}
} catch (error: any) {
messageApi.error(error.message || '加载持仓数据失败');
setPositions([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPositions();
}, []);
// 格式化价格显示(根据涨跌显示颜色)
const formatPrice = (currentPrice?: number, previousPrice?: number) => {
if (!currentPrice) return { text: '--', color: '#1f2937' };
const price = currentPrice.toFixed(2);
if (!previousPrice) return { text: price, color: '#1f2937' };
if (currentPrice > previousPrice) {
return { text: price, color: '#ef4444' }; // 红色(上涨)
} else if (currentPrice < previousPrice) {
return { text: price, color: '#10b981' }; // 绿色(下跌)
}
return { text: price, color: '#1f2937' };
};
// 格式化盈亏显示
const formatProfit = (profit: number, profitPercent: number) => {
const isPositive = profit >= 0;
const profitText = `${isPositive ? '+' : ''}${Math.abs(profit).toLocaleString()}`;
const percentText = `${isPositive ? '+' : ''}${profitPercent.toFixed(2)}%`;
const color = isPositive ? '#ef4444' : '#10b981';
return { profitText, percentText, color };
};
// 格式化市值颜色(根据盈亏状态)
const formatMarketValueColor = (marketValue: number, costValue: number) => {
if (marketValue > costValue) {
return '#ef4444'; // 盈利显示红色
} else if (marketValue < costValue) {
return '#10b981'; // 亏损显示绿色
}
return '#1f2937'; // 持平显示灰色
};
return (
<div className="position-list-container">
<div className="position-list-header">
<h2 className="position-list-title"></h2>
<Button
type="default"
shape="circle"
icon={<PlusOutlined />}
className="position-add-btn"
onClick={() => setCreateModalOpen(true)}
/>
</div>
<Spin spinning={loading}>
<div className="positions-grid">
{positions &&
positions.map((position) => {
const priceInfo = formatPrice(
position.currentPrice,
position.previousPrice
);
const profitInfo = formatProfit(
position.profit,
position.profitPercent
);
const marketValueColor = formatMarketValueColor(
position.marketValue,
position.costValue
);
const brokerName = position.brokerId
? getBrokerName(position.brokerId)
: '';
const marketText = getMarketName(position.market);
return (
<Card key={position.positionId} className="position-card">
<div className="position-content">
{/* 左侧:基本信息 */}
<div className="position-left">
<div className="position-name">{position.name}</div>
<div className="position-meta-info">
<span className="position-symbol">
{position.symbol}
</span>
{marketText && (
<span className="meta-tag">{marketText}</span>
)}
{brokerName && (
<span className="meta-tag">{brokerName}</span>
)}
</div>
<div className="position-holding-info">
<span>
{position.shares.toLocaleString()}
</span>
</div>
<div className="position-holding-days">
<span> {position.holdingDays} </span>
</div>
</div>
{/* 右侧:价格和盈亏信息 */}
<div className="position-right">
<div
className="position-current-price"
style={{ color: priceInfo.color }}
>
{priceInfo.text}
</div>
<div
className="position-market-value"
style={{ color: marketValueColor }}
>
<span className="position-value-text">
{position.marketValue.toLocaleString()}
</span>
</div>
{/* 累计收益和累计收益率 */}
<div
className="position-profit"
style={{ color: profitInfo.color }}
>
{position.assetType !== 'cash' ? (
<>
<span className="position-profit-amount">
{profitInfo.profitText}
</span>
<span className="position-profit-percent">
{profitInfo.percentText}
</span>
</>
) : (
<>
<span
className="position-profit-amount"
style={{ opacity: 0 }}
>
--
</span>
<span
className="position-profit-percent"
style={{ opacity: 0 }}
>
--
</span>
</>
)}
</div>
</div>
</div>
{/* 分割线和更新按钮 */}
<div className="position-footer">
<button className="position-update-btn">
<span></span>
<RightOutlined className="position-update-icon" />
</button>
</div>
</Card>
);
})}
</div>
</Spin>
{/* 新建持仓弹窗 */}
<CreatePositionModal
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onSuccess={loadPositions}
/>
</div>
);
};
export default PositionList;
+1
View File
@@ -0,0 +1 @@
export { default } from './AssetsPage';
@@ -0,0 +1,228 @@
import { useEffect, useState } from 'react';
import { Modal, Form, Input, Select, Upload, message } from 'antd';
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons';
import type { UploadProps, UploadFile } from 'antd';
import { brokerService } from '@/services/broker';
import { storageService } from '@/services/storage';
import type { Broker, CreateBrokerRequest } from '@/types/broker';
import { REGION_OPTIONS } from '@/types/broker';
import type { RcFile } from 'antd/es/upload';
const { Option } = Select;
interface BrokerFormModalProps {
visible: boolean;
editingBroker: Broker | null;
onCancel: () => void;
onSuccess: () => void;
}
const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: BrokerFormModalProps) => {
const [form] = Form.useForm();
const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const isEdit = !!editingBroker;
// 当编辑数据变化时,更新表单
useEffect(() => {
if (visible) {
if (editingBroker) {
form.setFieldsValue({
brokerCode: editingBroker.brokerCode,
brokerName: editingBroker.brokerName,
region: editingBroker.region,
brokerImage: editingBroker.brokerImage,
});
// 如果有图片,设置文件列表
if (editingBroker.brokerImage) {
setFileList([
{
uid: '-1',
name: 'broker-logo',
status: 'done',
url: editingBroker.brokerImage,
},
]);
} else {
setFileList([]);
}
} else {
form.resetFields();
setFileList([]);
}
}
}, [visible, editingBroker, form]);
// 自定义上传函数
const customRequest: UploadProps['customRequest'] = async (options) => {
const { file, onSuccess, onError } = options;
setUploading(true);
try {
const uploadFile = file as RcFile;
const response = await storageService.uploadFile({
file: uploadFile,
folder: 'broker',
});
// 更新表单字段
form.setFieldValue('brokerImage', response.url);
// 更新文件列表
setFileList([
{
uid: response.path,
name: response.filename,
status: 'done',
url: response.url,
},
]);
message.success('图片上传成功');
onSuccess?.(response);
} catch (error: any) {
message.error(error.message || '图片上传失败');
onError?.(error);
} finally {
setUploading(false);
}
};
// 图片上传配置
const uploadProps: UploadProps = {
name: 'file',
listType: 'picture-card',
maxCount: 1,
fileList,
accept: 'image/*',
customRequest,
beforeUpload: (file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件!');
return false;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
message.error('图片大小不能超过 5MB');
return false;
}
return true;
},
onRemove: () => {
setFileList([]);
form.setFieldValue('brokerImage', '');
return true;
},
onChange: ({ fileList: newFileList }) => {
setFileList(newFileList);
},
};
// 提交表单
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const formData: CreateBrokerRequest = {
brokerCode: values.brokerCode,
brokerName: values.brokerName,
region: values.region,
brokerImage: values.brokerImage || undefined,
};
if (isEdit && editingBroker) {
await brokerService.updateBroker(editingBroker.brokerId, formData);
message.success('更新成功');
} else {
await brokerService.createBroker(formData);
message.success('创建成功');
}
onSuccess();
} catch (error: any) {
if (error.errorFields) {
// 表单验证错误
return;
}
message.error(error.message || (isEdit ? '更新失败' : '创建失败'));
}
};
return (
<Modal
title={isEdit ? '编辑券商' : '新建券商'}
open={visible}
onCancel={onCancel}
onOk={handleSubmit}
width={600}
destroyOnHidden
>
<Form form={form} layout="vertical" autoComplete="off" style={{ marginTop: 20 }}>
<Form.Item
name="brokerCode"
label="券商代码"
rules={[
{ required: true, message: '请输入券商代码' },
{ max: 50, message: '券商代码不能超过50个字符' },
]}
>
<Input placeholder="请输入券商代码" disabled={isEdit} />
</Form.Item>
<Form.Item
name="brokerName"
label="券商名称"
rules={[
{ required: true, message: '请输入券商名称' },
{ max: 100, message: '券商名称不能超过100个字符' },
]}
>
<Input placeholder="请输入券商名称" />
</Form.Item>
<Form.Item
name="region"
label="地区"
rules={[{ required: true, message: '请选择地区' }]}
>
<Select placeholder="请选择地区">
{REGION_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="brokerImage"
label="券商Logo"
rules={[{ max: 500, message: '图片地址不能超过500个字符' }]}
>
<Input
placeholder="图片URL(上传后自动填充)"
allowClear
readOnly
style={{ cursor: 'not-allowed' }}
/>
</Form.Item>
<Form.Item label="上传Logo">
<Upload {...uploadProps}>
{fileList.length >= 1 ? null : (
<div>
{uploading ? <LoadingOutlined /> : <PlusOutlined />}
<div style={{ marginTop: 8 }}></div>
</div>
)}
</Upload>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
JPGPNGGIFWebP 5MB
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default BrokerFormModal;
+38
View File
@@ -0,0 +1,38 @@
.broker-page {
padding: 0;
}
.broker-search-form {
margin-bottom: 16px;
}
.broker-search-form .ant-form-item {
margin-bottom: 16px;
}
.broker-action-bar {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
/* 表格样式优化 */
.broker-page .ant-table {
background: #fff;
}
.broker-page .ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
}
/* 响应式 */
@media (max-width: 768px) {
.broker-search-form .ant-form-item {
margin-bottom: 12px;
}
.broker-action-bar {
margin-bottom: 12px;
}
}
+328
View File
@@ -0,0 +1,328 @@
import { useState, useEffect, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Space,
Image,
Popconfirm,
Card,
Form,
Row,
Col,
App as AntdApp,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { brokerService } from '@/services/broker';
import type { Broker, QueryBrokerRequest } from '@/types/broker';
import { REGION_OPTIONS, getRegionText } from '@/types/broker';
import BrokerFormModal from './BrokerFormModal';
import './BrokerPage.css';
const { Option } = Select;
const BrokerPage = () => {
const { message: messageApi } = AntdApp.useApp();
const [brokers, setBrokers] = useState<Broker[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [form] = Form.useForm();
const [modalVisible, setModalVisible] = useState(false);
const [editingBroker, setEditingBroker] = useState<Broker | null>(null);
const formRef = useRef<QueryBrokerRequest>({});
// 加载数据
const loadData = async (params?: QueryBrokerRequest, resetPage = false) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : pagination.current;
const pageSize = pagination.pageSize;
const queryParams: QueryBrokerRequest = {
page: currentPage,
limit: pageSize,
sortBy: 'createdAt',
sortOrder: 'DESC',
...formRef.current,
...params,
};
const response = await brokerService.getBrokerList(queryParams);
setBrokers(response.list);
setPagination((prev) => ({
...prev,
current: response.pagination.current_page,
pageSize: response.pagination.page_size,
total: response.pagination.total,
}));
} catch (error: any) {
messageApi.error(error.message || '加载券商列表失败');
} finally {
setLoading(false);
}
};
// 初始加载
useEffect(() => {
loadData({}, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 当分页改变时,重新加载数据
useEffect(() => {
// 避免初始加载时重复请求
if (pagination.current > 0 && pagination.pageSize > 0) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.current, pagination.pageSize]);
// 查询
const handleSearch = () => {
const values = form.getFieldsValue();
formRef.current = {
brokerCode: values.brokerCode || undefined,
brokerName: values.brokerName || undefined,
region: values.region || undefined,
};
loadData(formRef.current, true);
};
// 重置
const handleReset = () => {
form.resetFields();
formRef.current = {};
loadData({}, true);
};
// 新建
const handleCreate = () => {
setEditingBroker(null);
setModalVisible(true);
};
// 编辑
const handleEdit = (record: Broker) => {
setEditingBroker(record);
setModalVisible(true);
};
// 删除
const handleDelete = async (id: number) => {
try {
await brokerService.deleteBroker(id);
messageApi.success('删除成功');
loadData();
} catch (error: any) {
messageApi.error(error.message || '删除失败');
}
};
// 保存成功回调
const handleSaveSuccess = () => {
setModalVisible(false);
setEditingBroker(null);
loadData();
};
// 表格列定义
const columns: ColumnsType<Broker> = [
{
title: '券商Logo',
dataIndex: 'brokerImage',
key: 'brokerImage',
width: 100,
render: (image: string) => {
if (image) {
return (
<Image
src={image}
alt="券商Logo"
width={32}
height={32}
style={{ objectFit: 'contain' }}
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='50'%3E%3Crect width='50' height='50' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3ELogo%3C/text%3E%3C/svg%3E"
/>
);
}
return (
<div
style={{
width: 32,
height: 32,
backgroundColor: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: 12,
borderRadius: 6,
border: '1px solid #e5e5e5',
}}
>
Logo
</div>
);
},
},
{
title: '券商代码',
dataIndex: 'brokerCode',
key: 'brokerCode',
width: 120,
},
{
title: '券商名称',
dataIndex: 'brokerName',
key: 'brokerName',
width: 200,
},
{
title: '地区',
dataIndex: 'region',
key: 'region',
width: 120,
render: (region: string) => getRegionText(region),
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
render: (_: any, record: Broker) => (
<Space size="middle">
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
</Button>
<Popconfirm
title="确定要删除这个券商吗?"
onConfirm={() => handleDelete(record.brokerId)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div className="broker-page">
<Card>
{/* 查询表单 */}
<Form form={form} layout="inline" className="broker-search-form">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="brokerCode" label="券商代码">
<Input
placeholder="请输入券商代码"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="brokerName" label="券商名称">
<Input
placeholder="请输入券商名称"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="region" label="地区">
<Select
placeholder="请选择地区"
allowClear
style={{ width: '100%' }}
>
{REGION_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
{/* 操作栏 */}
<div className="broker-action-bar">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
{/* 表格 */}
<Table
columns={columns}
dataSource={brokers}
rowKey="brokerId"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize: pageSize || 10,
}));
},
}}
scroll={{ x: 800 }}
/>
</Card>
{/* 新建/编辑弹窗 */}
<BrokerFormModal
visible={modalVisible}
editingBroker={editingBroker}
onCancel={() => {
setModalVisible(false);
setEditingBroker(null);
}}
onSuccess={handleSaveSuccess}
/>
</div>
);
};
export default BrokerPage;
+1
View File
@@ -0,0 +1 @@
export { default } from './BrokerPage';
@@ -0,0 +1,28 @@
.stock-daily-price-page {
padding: 0;
}
.stock-daily-price-search-form {
margin-bottom: 16px;
}
.stock-daily-price-search-form .ant-form-item {
margin-bottom: 16px;
}
/* 表格样式优化 */
.stock-daily-price-page .ant-table {
background: #fff;
}
.stock-daily-price-page .ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
}
/* 响应式 */
@media (max-width: 768px) {
.stock-daily-price-search-form .ant-form-item {
margin-bottom: 12px;
}
}
@@ -0,0 +1,384 @@
import { useState, useEffect, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Space,
Card,
Form,
Row,
Col,
App as AntdApp,
Tag,
DatePicker,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { stockDailyPriceService } from '@/services/stock-daily-price';
import type { StockDailyPrice, QueryStockDailyPriceRequest } from '@/types/stock-daily-price';
import { MARKET_OPTIONS, getMarketText } from '@/types/stock-daily-price';
import dayjs from 'dayjs';
import './StockDailyPricePage.css';
const { Option } = Select;
const { RangePicker } = DatePicker;
const StockDailyPricePage = () => {
const { message: messageApi } = AntdApp.useApp();
const [prices, setPrices] = useState<StockDailyPrice[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [form] = Form.useForm();
const formRef = useRef<QueryStockDailyPriceRequest>({});
// 初始化:默认查询最近7天
useEffect(() => {
const endDate = dayjs();
const startDate = endDate.subtract(6, 'day'); // 最近7天(包含今天)
form.setFieldsValue({
dateRange: [startDate, endDate],
});
formRef.current = {
startDate: startDate.format('YYYY-MM-DD'),
endDate: endDate.format('YYYY-MM-DD'),
};
loadData(formRef.current, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 加载数据
const loadData = async (params?: QueryStockDailyPriceRequest, resetPage = false) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : pagination.current;
const pageSize = pagination.pageSize;
const queryParams: QueryStockDailyPriceRequest = {
page: currentPage,
limit: pageSize,
sortBy: 'tradeDate',
sortOrder: 'DESC',
...formRef.current,
...params,
};
const response = await stockDailyPriceService.getStockDailyPriceList(queryParams);
setPrices(response.list);
setPagination((prev) => ({
...prev,
current: response.pagination.current_page,
pageSize: response.pagination.page_size,
total: response.pagination.total,
}));
} catch (error: any) {
messageApi.error(error.message || '加载股票价格列表失败');
} finally {
setLoading(false);
}
};
// 当分页改变时,重新加载数据
useEffect(() => {
if (pagination.current > 0 && pagination.pageSize > 0) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.current, pagination.pageSize]);
// 查询
const handleSearch = () => {
const values = form.getFieldsValue();
const queryParams: QueryStockDailyPriceRequest = {
stockCode: values.stockCode || undefined,
stockName: values.stockName || undefined,
market: values.market || undefined,
};
// 处理日期范围
if (values.dateRange && values.dateRange.length === 2) {
queryParams.startDate = values.dateRange[0].format('YYYY-MM-DD');
queryParams.endDate = values.dateRange[1].format('YYYY-MM-DD');
}
formRef.current = queryParams;
loadData(queryParams, true);
};
// 重置
const handleReset = () => {
form.resetFields();
// 重置为默认的最近7天
const endDate = dayjs();
const startDate = endDate.subtract(6, 'day');
form.setFieldsValue({
dateRange: [startDate, endDate],
});
formRef.current = {
startDate: startDate.format('YYYY-MM-DD'),
endDate: endDate.format('YYYY-MM-DD'),
};
loadData(formRef.current, true);
};
// 格式化价格
const formatPrice = (price?: number) => {
if (price === null || price === undefined) return '-';
return price.toFixed(2);
};
// 格式化金额
const formatAmount = (amount?: number) => {
if (amount === null || amount === undefined) return '-';
if (amount >= 100000000) {
return `${(amount / 100000000).toFixed(2)}亿`;
}
if (amount >= 10000) {
return `${(amount / 10000).toFixed(2)}`;
}
return amount.toFixed(2);
};
// 格式化成交量
const formatVolume = (volume?: number) => {
if (volume === null || volume === undefined) return '-';
if (volume >= 10000) {
return `${(volume / 10000).toFixed(2)}万手`;
}
return `${volume}`;
};
// 表格列定义
const columns: ColumnsType<StockDailyPrice> = [
{
title: '股票代码',
dataIndex: 'stockCode',
key: 'stockCode',
width: 120,
fixed: 'left',
},
{
title: '股票名称',
dataIndex: 'stockName',
key: 'stockName',
width: 150,
fixed: 'left',
},
{
title: '市场',
dataIndex: 'market',
key: 'market',
width: 100,
render: (market: string) => <Tag color="blue">{getMarketText(market)}</Tag>,
},
{
title: '交易日期',
dataIndex: 'tradeDate',
key: 'tradeDate',
width: 120,
render: (date: Date) => dayjs(date).format('YYYY-MM-DD'),
},
{
title: '开盘价',
dataIndex: 'openPrice',
key: 'openPrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '收盘价',
dataIndex: 'closePrice',
key: 'closePrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '最高价',
dataIndex: 'highPrice',
key: 'highPrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '最低价',
dataIndex: 'lowPrice',
key: 'lowPrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '涨跌额',
dataIndex: 'changeAmount',
key: 'changeAmount',
width: 100,
align: 'right',
render: (amount?: number) => {
if (amount === null || amount === undefined) return '-';
const color = amount >= 0 ? '#ff4d4f' : '#52c41a';
return <span style={{ color }}>{formatPrice(amount)}</span>;
},
},
{
title: '涨跌幅',
dataIndex: 'changePercent',
key: 'changePercent',
width: 100,
align: 'right',
render: (percent?: number) => {
if (percent === null || percent === undefined) return '-';
const color = percent >= 0 ? '#ff4d4f' : '#52c41a';
return (
<span style={{ color }}>
{percent >= 0 ? '+' : ''}
{percent.toFixed(2)}%
</span>
);
},
},
{
title: '成交量',
dataIndex: 'volume',
key: 'volume',
width: 120,
align: 'right',
render: formatVolume,
},
{
title: '成交额',
dataIndex: 'amount',
key: 'amount',
width: 150,
align: 'right',
render: formatAmount,
},
{
title: '换手率',
dataIndex: 'turnoverRate',
key: 'turnoverRate',
width: 100,
align: 'right',
render: (rate?: number) => {
if (rate === null || rate === undefined) return '-';
return `${rate.toFixed(2)}%`;
},
},
{
title: '市盈率',
dataIndex: 'peRatio',
key: 'peRatio',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '市净率',
dataIndex: 'pbRatio',
key: 'pbRatio',
width: 100,
align: 'right',
render: formatPrice,
},
];
return (
<div className="stock-daily-price-page">
<Card>
{/* 查询表单 */}
<Form form={form} layout="inline" className="stock-daily-price-search-form">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="stockCode" label="股票代码">
<Input
placeholder="请输入股票代码"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="stockName" label="股票名称">
<Input
placeholder="请输入股票名称"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="market" label="市场">
<Select
placeholder="请选择市场"
allowClear
style={{ width: '100%' }}
>
{MARKET_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="dateRange" label="日期范围" layout="horizontal">
<RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
</Form.Item>
</Col>
<Col span={18}>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
{/* 表格 */}
<Table
columns={columns}
dataSource={prices}
rowKey="id"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize: pageSize || 10,
}));
},
}}
scroll={{ x: 1600 }}
/>
</Card>
</div>
);
};
export default StockDailyPricePage;
@@ -0,0 +1 @@
export { default } from './StockDailyPricePage';
@@ -0,0 +1,28 @@
.stock-info-page {
padding: 0;
}
.stock-info-search-form {
margin-bottom: 16px;
}
.stock-info-search-form .ant-form-item {
margin-bottom: 16px;
}
/* 表格样式优化 */
.stock-info-page .ant-table {
background: #fff;
}
.stock-info-page .ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
}
/* 响应式 */
@media (max-width: 768px) {
.stock-info-search-form .ant-form-item {
margin-bottom: 12px;
}
}
@@ -0,0 +1,250 @@
import { useState, useEffect, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Space,
Card,
Form,
Row,
Col,
App as AntdApp,
Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { stockInfoService } from '@/services/stock-info';
import type { StockInfo, QueryStockInfoRequest } from '@/types/stock-info';
import { MARKET_OPTIONS, getMarketText, STATUS_OPTIONS, getStatusText } from '@/types/stock-info';
import dayjs from 'dayjs';
import './StockInfoPage.css';
const { Option } = Select;
const StockInfoPage = () => {
const { message: messageApi } = AntdApp.useApp();
const [stocks, setStocks] = useState<StockInfo[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [form] = Form.useForm();
const formRef = useRef<QueryStockInfoRequest>({});
// 加载数据
const loadData = async (params?: QueryStockInfoRequest, resetPage = false) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : pagination.current;
const pageSize = pagination.pageSize;
const queryParams: QueryStockInfoRequest = {
page: currentPage,
limit: pageSize,
sortBy: 'createdAt',
sortOrder: 'DESC',
...formRef.current,
...params,
};
const response = await stockInfoService.getStockInfoList(queryParams);
setStocks(response.list);
setPagination((prev) => ({
...prev,
current: response.pagination.current_page,
pageSize: response.pagination.page_size,
total: response.pagination.total,
}));
} catch (error: any) {
messageApi.error(error.message || '加载股票信息列表失败');
} finally {
setLoading(false);
}
};
// 初始加载
useEffect(() => {
loadData({}, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 当分页改变时,重新加载数据
useEffect(() => {
if (pagination.current > 0 && pagination.pageSize > 0) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.current, pagination.pageSize]);
// 查询
const handleSearch = () => {
const values = form.getFieldsValue();
formRef.current = {
stockCode: values.stockCode || undefined,
stockName: values.stockName || undefined,
market: values.market || undefined,
};
loadData(formRef.current, true);
};
// 重置
const handleReset = () => {
form.resetFields();
formRef.current = {};
loadData({}, true);
};
// 表格列定义
const columns: ColumnsType<StockInfo> = [
{
title: '股票代码',
dataIndex: 'stockCode',
key: 'stockCode',
width: 120,
},
{
title: '股票名称',
dataIndex: 'stockName',
key: 'stockName',
width: 200,
},
{
title: '市场',
dataIndex: 'market',
key: 'market',
width: 100,
render: (market: string) => <Tag color="blue">{getMarketText(market)}</Tag>,
},
{
title: '公司全称',
dataIndex: 'fullName',
key: 'fullName',
width: 300,
ellipsis: true,
},
{
title: '所属行业',
dataIndex: 'industry',
key: 'industry',
width: 150,
},
{
title: '上市日期',
dataIndex: 'listingDate',
key: 'listingDate',
width: 120,
render: (date: Date) => (date ? dayjs(date).format('YYYY-MM-DD') : '-'),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const colorMap: Record<string, string> = {
active: 'success',
suspended: 'warning',
delisted: 'error',
};
return <Tag color={colorMap[status] || 'default'}>{getStatusText(status)}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (date: Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<div className="stock-info-page">
<Card>
{/* 查询表单 */}
<Form form={form} layout="inline" className="stock-info-search-form">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="stockCode" label="股票代码">
<Input
placeholder="请输入股票代码"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="stockName" label="股票名称">
<Input
placeholder="请输入股票名称"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="market" label="市场">
<Select
placeholder="请选择市场"
allowClear
style={{ width: '100%' }}
>
{MARKET_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item>
<Space style={{ float: 'right' }}>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
{/* 表格 */}
<Table
columns={columns}
dataSource={stocks}
rowKey="id"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize: pageSize || 10,
}));
},
}}
scroll={{ x: 1200 }}
/>
</Card>
</div>
);
};
export default StockInfoPage;
+1
View File
@@ -0,0 +1 @@
export { default } from './StockInfoPage';
+137
View File
@@ -0,0 +1,137 @@
import { Modal, Descriptions, Avatar, Tag, Image, Space, Button, Popconfirm } from 'antd';
import { UserOutlined, DeleteOutlined } from '@ant-design/icons';
import type { User } from '@/types/user';
import { getRoleText, getStatusText } from '@/types/user';
import dayjs from 'dayjs';
interface UserDetailModalProps {
visible: boolean;
user: User | null;
onCancel: () => void;
onDelete?: (userId: number) => void;
}
const UserDetailModal = ({ visible, user, onCancel, onDelete }: UserDetailModalProps) => {
if (!user) {
return null;
}
const isFrozen = user.status === 'inactive';
const isSuperAdmin = user.role === 'super_admin';
const canDelete = isFrozen && !isSuperAdmin && onDelete;
return (
<Modal
title="用户详情"
open={visible}
onCancel={onCancel}
footer={[
<Button key="close" onClick={onCancel}>
</Button>,
canDelete && (
<Popconfirm
key="delete"
title="确定要删除这个用户吗?"
description="删除后无法恢复"
onConfirm={() => onDelete?.(user.userId)}
okText="确定"
cancelText="取消"
>
<Button key="delete" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
),
].filter(Boolean)}
width={700}
>
{/* 头像和昵称区域 */}
<div style={{ marginBottom: 24, textAlign: 'center' }}>
<Space size={16} align="center">
{user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt="头像"
width={60}
height={60}
style={{ borderRadius: 4 }}
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Crect width='60' height='60' fill='%23f0f0f0'/%3E%3Cpath d='M30 20c5.5 0 10 4.5 10 10s-4.5 10-10 10-10-4.5-10-10 4.5-10 10-10zm0 24c6.6 0 20 3.3 20 10v6H10v-6c0-6.7 13.4-10 20-10z' fill='%23999'/%3E%3C/svg%3E"
/>
) : (
<Avatar size={60} icon={<UserOutlined />} />
)}
<span
style={{
fontSize: 20,
fontWeight: 'bold',
color: '#262626',
}}
>
{user.nickname || user.username}
</span>
</Space>
</div>
<Descriptions column={2} bordered>
<Descriptions.Item label="用户ID">{user.userId}</Descriptions.Item>
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
<Descriptions.Item label="邮箱">{user.email}</Descriptions.Item>
<Descriptions.Item label="电话">{user.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="角色">
<Tag
color={
user.role === 'super_admin'
? 'red'
: user.role === 'admin'
? 'orange'
: 'blue'
}
>
{getRoleText(user.role)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag
color={
user.status === 'active'
? 'success'
: user.status === 'inactive'
? 'warning'
: 'error'
}
>
{getStatusText(user.status)}
</Tag>
</Descriptions.Item>
{user.openId && (
<Descriptions.Item label="微信OpenID">{user.openId}</Descriptions.Item>
)}
{user.unionId && (
<Descriptions.Item label="微信UnionID">{user.unionId}</Descriptions.Item>
)}
<Descriptions.Item label="创建时间" span={2}>
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="更新时间" span={2}>
{dayjs(user.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="最后登录时间" span={2}>
{user.lastLoginAt ? dayjs(user.lastLoginAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
</Descriptions.Item>
</Descriptions>
</Modal>
);
};
export default UserDetailModal;
+15
View File
@@ -0,0 +1,15 @@
.user-info-content {
padding: 24px 0;
}
.avatar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.info-field-text {
font-size: 14px;
line-height: 1.5715;
color: rgba(0, 0, 0, 0.88);
}
+479
View File
@@ -0,0 +1,479 @@
import { useState, useEffect, useRef } from 'react';
import {
Tabs,
Card,
Avatar,
Form,
Input,
Button,
Upload,
Image,
Space,
App as AntdApp,
Modal,
} from 'antd';
import type { TabsProps } from 'antd';
import { UserOutlined, UploadOutlined } from '@ant-design/icons';
import { authService } from '@/services/auth';
import { userService } from '@/services/user';
import { storageService } from '@/services/storage';
import type { UserInfo } from '@/types/user';
import type { UploadFile } from 'antd/es/upload';
import type { RcFile } from 'antd/es/upload';
import dayjs from 'dayjs';
import './UserInfoPage.css';
/**
* 使
* @param createdAt
* @returns 使 "365天" "1年30天"
*/
const calculateUsageDays = (createdAt: Date | string): string => {
const created = dayjs(createdAt);
const now = dayjs();
const days = now.diff(created, 'day');
if (days < 365) {
return `${days}`;
}
const years = Math.floor(days / 365);
const remainingDays = days % 365;
return `${years}${remainingDays}`;
};
const UserInfoPage = () => {
const { message: messageApi } = AntdApp.useApp();
const [user, setUser] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [editing, setEditing] = useState(false);
const [avatarFileList, setAvatarFileList] = useState<UploadFile[]>([]);
const [passwordForm] = Form.useForm();
const [infoForm] = Form.useForm();
const isLoadingRef = useRef(false);
// 加载用户信息
const loadUserInfo = async () => {
// 防止重复请求
if (isLoadingRef.current) {
return;
}
const currentUser = authService.getUser();
if (!currentUser) {
messageApi.error('未找到用户信息');
return;
}
isLoadingRef.current = true;
setLoading(true);
try {
const userData = await userService.getUserById(currentUser.userId);
console.log('userData:', userData);
setUser(userData);
infoForm.setFieldsValue({
nickname: userData.nickname || '',
phone: userData.phone || '',
email: userData.email || '',
});
// 设置头像文件列表
if (userData.avatarUrl) {
setAvatarFileList([
{
uid: '-1',
name: 'avatar',
status: 'done',
url: userData.avatarUrl,
},
]);
}
} catch (error: any) {
messageApi.error(error.message || '加载用户信息失败');
} finally {
setLoading(false);
isLoadingRef.current = false;
}
};
useEffect(() => {
loadUserInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 上传头像
const handleAvatarUpload = async (
file: RcFile,
onSuccess?: (response: any) => void,
onError?: (error: any) => void
) => {
setUploading(true);
try {
const response = await storageService.uploadAvatar(file);
// 更新用户头像
if (user) {
await userService.updateUser(user.userId, {
avatarUrl: response.url,
});
messageApi.success('头像上传成功');
await loadUserInfo();
onSuccess?.(response);
}
} catch (error: any) {
messageApi.error(error.message || '头像上传失败');
onError?.(error);
} finally {
setUploading(false);
}
};
// 头像上传配置
const avatarUploadProps = {
name: 'file',
listType: 'picture' as const,
maxCount: 1,
fileList: avatarFileList,
accept: 'image/*',
customRequest: async (options: any) => {
const { file, onSuccess, onError } = options;
await handleAvatarUpload(file as RcFile, onSuccess, onError);
},
beforeUpload: (file: File) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
messageApi.error('只能上传图片文件!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
messageApi.error('图片大小不能超过 2MB');
return false;
}
return true;
},
onRemove: () => {
setAvatarFileList([]);
},
onChange: ({ fileList: newFileList }: { fileList: UploadFile[] }) => {
setAvatarFileList(newFileList);
},
};
// 进入编辑模式
const handleEdit = () => {
if (user) {
infoForm.setFieldsValue({
nickname: user.nickname || '',
phone: user.phone || '',
email: user.email || '',
});
setEditing(true);
}
};
// 取消编辑
const handleCancelEdit = () => {
setEditing(false);
infoForm.resetFields();
};
// 处理更新按钮点击(先验证表单,再显示确认框)
const handleUpdateClick = async () => {
try {
// 先验证表单
const values = await infoForm.validateFields();
if (!user) return;
// 验证通过,显示确认框
Modal.confirm({
title: '确认更新',
content: '确定要更新个人信息吗?',
onOk: async () => {
try {
await userService.updateUser(user.userId, {
nickname: values.nickname || undefined,
phone: values.phone || undefined,
email: values.email || undefined,
});
messageApi.success('个人信息更新成功');
setEditing(false);
await loadUserInfo();
} catch (error: any) {
messageApi.error(error.message || '更新失败');
}
},
});
} catch (error: any) {
// 验证失败,不显示确认框
if (error.errorFields) {
return;
}
}
};
// 修改密码
const handleChangePassword = async () => {
try {
const values = await passwordForm.validateFields();
if (!user) return;
await userService.changePassword(user.userId, {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
messageApi.success('密码修改成功');
passwordForm.resetFields();
} catch (error: any) {
if (error.errorFields) {
return;
}
messageApi.error(error.message || '密码修改失败');
}
};
if (!user) {
return <Card loading={loading}>...</Card>;
}
// Tab配置
const tabItems: TabsProps['items'] = [
{
key: 'info',
label: '个人信息',
children: (
<div className="user-info-content">
<Form
form={infoForm}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
style={{ maxWidth: 600 }}
>
{/* 头像 */}
<Form.Item wrapperCol={{ span: 18, offset: 6 }}>
<div className="avatar-wrapper">
{user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt="头像"
width={100}
height={100}
style={{ borderRadius: 8 }}
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f0f0f0'/%3E%3Cpath d='M50 33c9.4 0 17 7.6 17 17s-7.6 17-17 17-17-7.6-17-17 7.6-17 17-17zm0 40c11 0 33.5 5.5 33.5 16.5v8H16.5v-8C16.5 78.5 39 73 50 73z' fill='%23999'/%3E%3C/svg%3E"
/>
) : (
<Avatar size={100} icon={<UserOutlined />} />
)}
<div
style={{
marginTop: 12,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{editing ? (
<Upload {...avatarUploadProps}>
<Button
icon={<UploadOutlined />}
loading={uploading}
size="small"
>
</Button>
</Upload>
) : (
<span
style={{ fontSize: 14, color: 'rgba(0, 0, 0, 0.65)' }}
>
</span>
)}
</div>
</div>
</Form.Item>
{/* 昵称 */}
<Form.Item
label="昵称"
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
>
{editing ? (
<Form.Item
name="nickname"
noStyle
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
>
<Input placeholder="请输入昵称" />
</Form.Item>
) : (
<span className="info-field-text">{user.nickname || '-'}</span>
)}
</Form.Item>
{/* 用户名(不可编辑) */}
<Form.Item label="用户名">
<span className="info-field-text">{user.username}</span>
</Form.Item>
{/* 电话 */}
<Form.Item
label="电话"
rules={[
{
pattern: /^[0-9+\-() ]+$/,
message: '电话号码格式不正确',
},
{ max: 20, message: '电话不能超过20个字符' },
]}
>
{editing ? (
<Form.Item
name="phone"
noStyle
rules={[
{
pattern: /^[0-9+\-() ]+$/,
message: '电话号码格式不正确',
},
{ max: 20, message: '电话不能超过20个字符' },
]}
>
<Input placeholder="请输入电话" />
</Form.Item>
) : (
<span className="info-field-text">{user.phone || '-'}</span>
)}
</Form.Item>
{/* 邮箱 */}
<Form.Item
label="邮箱"
rules={[
{ type: 'email', message: '邮箱格式不正确' },
{ max: 100, message: '邮箱不能超过100个字符' },
]}
>
{editing ? (
<Form.Item
name="email"
noStyle
rules={[
{ type: 'email', message: '邮箱格式不正确' },
{ max: 100, message: '邮箱不能超过100个字符' },
{ required: true, message: '请输入邮箱' },
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
) : (
<span className="info-field-text">{user.email}</span>
)}
</Form.Item>
{/* 注册时间(不可编辑) */}
<Form.Item label="注册时间">
<span className="info-field-text">
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</span>
</Form.Item>
{/* 使用天数(不可编辑) */}
<Form.Item label="使用天数">
<span className="info-field-text">
{calculateUsageDays(user.createdAt)}
</span>
</Form.Item>
{/* 操作按钮 */}
<Form.Item wrapperCol={{ offset: 6, span: 18 }} style={{ marginTop: 24 }}>
{editing ? (
<Space>
<Button type="primary" onClick={handleUpdateClick}>
</Button>
<Button onClick={handleCancelEdit}></Button>
</Space>
) : (
<Button type="primary" onClick={handleEdit}>
</Button>
)}
</Form.Item>
</Form>
</div>
),
},
{
key: 'password',
label: '修改密码',
children: (
<Form
form={passwordForm}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
style={{ maxWidth: 600 }}
onFinish={handleChangePassword}
>
<Form.Item
name="oldPassword"
label="原密码"
rules={[{ required: true, message: '请输入原密码' }]}
>
<Input.Password placeholder="请输入原密码" />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
{ max: 100, message: '密码长度不能超过100位' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
),
},
];
return (
<div className="user-info-page">
<Card>
<Tabs defaultActiveKey="info" items={tabItems} />
</Card>
</div>
);
};
export default UserInfoPage;
+7
View File
@@ -0,0 +1,7 @@
.user-page .user-search-form {
margin-bottom: 16px;
}
.user-page .user-search-form .ant-form-item {
margin-bottom: 16px;
}
+431
View File
@@ -0,0 +1,431 @@
import { useState, useEffect, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Space,
Avatar,
Popconfirm,
Card,
Form,
Row,
Col,
App as AntdApp,
Tag,
Image,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
LockOutlined,
UnlockOutlined,
UserOutlined,
} from '@ant-design/icons';
import { userService } from '@/services/user';
import type { User, QueryUserRequest } from '@/types/user';
import { USER_ROLE_OPTIONS, getRoleText, getStatusText, USER_STATUS_OPTIONS } from '@/types/user';
import UserDetailModal from './UserDetailModal';
import dayjs from 'dayjs';
import './UserPage.css';
const { Option } = Select;
const UserPage = () => {
const { message: messageApi } = AntdApp.useApp();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [form] = Form.useForm();
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const formRef = useRef<QueryUserRequest>({});
// 加载数据
const loadData = async (params?: QueryUserRequest, resetPage = false) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : pagination.current;
const pageSize = pagination.pageSize;
const queryParams: QueryUserRequest = {
page: currentPage,
limit: pageSize,
sortBy: 'createdAt',
sortOrder: 'DESC',
...formRef.current,
...params,
};
const response = await userService.getUserList(queryParams);
setUsers(response.list);
setPagination((prev) => ({
...prev,
current: response.pagination.current_page,
pageSize: response.pagination.page_size,
total: response.pagination.total,
}));
} catch (error: any) {
messageApi.error(error.message || '加载用户列表失败');
} finally {
setLoading(false);
}
};
// 初始加载
useEffect(() => {
loadData({}, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 当分页改变时,重新加载数据
useEffect(() => {
// 避免初始加载时重复请求
if (pagination.current > 0 && pagination.pageSize > 0) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.current, pagination.pageSize]);
// 查询
const handleSearch = () => {
const values = form.getFieldsValue();
formRef.current = {
username: values.username || undefined,
nickname: values.nickname || undefined,
email: values.email || undefined,
phone: values.phone || undefined,
role: values.role || undefined,
status: values.status || undefined,
};
loadData(formRef.current, true);
};
// 重置
const handleReset = () => {
form.resetFields();
formRef.current = {};
loadData({}, true);
};
// 查看详情
const handleView = (record: User) => {
setSelectedUser(record);
setDetailModalVisible(true);
};
// 冻结/解冻
const handleToggleStatus = async (user: User) => {
try {
const newStatus = user.status === 'active' ? 'inactive' : 'active';
await userService.updateUserStatus(user.userId, newStatus);
messageApi.success(newStatus === 'inactive' ? '用户已冻结' : '用户已解冻');
loadData();
} catch (error: any) {
messageApi.error(error.message || '操作失败');
}
};
// 删除
const handleDelete = async (id: number) => {
try {
await userService.deleteUser(id);
messageApi.success('删除成功');
loadData();
} catch (error: any) {
messageApi.error(error.message || '删除失败');
}
};
// 表格列定义
const columns: ColumnsType<User> = [
{
title: '用户头像',
dataIndex: 'avatarUrl',
key: 'avatarUrl',
width: 80,
render: (avatarUrl: string) => {
if (avatarUrl) {
return (
<Image
src={avatarUrl}
alt="头像"
width={32}
height={32}
style={{ borderRadius: 4, objectFit: 'cover' }}
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect width='20' height='20' fill='%23f0f0f0'/%3E%3Cpath d='M10 7c1.7 0 3 1.3 3 3s-1.3 3-3 3-3-1.3-3-3 1.3-3 3-3zm0 8c2.2 0 6.7 1.1 6.7 3.3v1.7H3.3v-1.7c0-2.2 4.5-3.3 6.7-3.3z' fill='%23999'/%3E%3C/svg%3E"
/>
);
}
return <Avatar size={32} icon={<UserOutlined />} style={{ fontSize: 12 }} />;
},
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 120,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 120,
render: (nickname: string) => nickname || '-',
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 180,
},
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
width: 120,
render: (phone: string) => phone || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (date: Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '最后登录时间',
dataIndex: 'lastLoginAt',
key: 'lastLoginAt',
width: 160,
render: (date: Date | undefined) =>
date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string, _record: User) => (
<Tag
color={
status === 'active'
? 'success'
: status === 'inactive'
? 'warning'
: 'error'
}
>
{getStatusText(status)}
</Tag>
),
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 120,
render: (role: string) => (
<Tag color={role === 'super_admin' ? 'red' : role === 'admin' ? 'orange' : 'blue'}>
{getRoleText(role)}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
render: (_: any, record: User) => {
const isFrozen = record.status === 'inactive';
const isSuperAdmin = record.role === 'super_admin';
return (
<Space size={0}>
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
>
</Button>
{/* 超级管理员不允许冻结 */}
{!isSuperAdmin && (
<>
{isFrozen ? (
<Popconfirm
title="确定要解冻这个用户吗?"
description="解冻后用户可以正常使用"
onConfirm={() => handleToggleStatus(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" icon={<UnlockOutlined />}>
</Button>
</Popconfirm>
) : (
<Popconfirm
title="确定要冻结这个用户吗?"
description="冻结后用户将无法登录和使用系统"
onConfirm={() => handleToggleStatus(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" icon={<LockOutlined />}>
</Button>
</Popconfirm>
)}
</>
)}
</Space>
);
},
},
];
return (
<div className="user-page">
<Card>
{/* 查询表单 */}
<Form form={form} layout="inline" className="user-search-form">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="username" label="用户名">
<Input
placeholder="请输入用户名"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="nickname" label="昵称">
<Input
placeholder="请输入昵称"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="email" label="邮箱">
<Input
placeholder="请输入邮箱"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="phone" label="电话">
<Input
placeholder="请输入电话"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="role" label="角色" initialValue="">
<Select
placeholder="请选择角色"
allowClear
style={{ width: '100%' }}
>
{USER_ROLE_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="status" label="状态" initialValue="">
<Select
placeholder="请选择状态"
allowClear
style={{ width: '100%' }}
>
{USER_STATUS_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
{/* 表格 */}
<Table
columns={columns}
dataSource={users}
rowKey="userId"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize: pageSize || 10,
}));
},
}}
scroll={{ x: 1500 }}
/>
</Card>
{/* 用户详情弹窗 */}
<UserDetailModal
visible={detailModalVisible}
user={selectedUser}
onCancel={() => {
setDetailModalVisible(false);
setSelectedUser(null);
}}
onDelete={async (userId: number) => {
try {
await handleDelete(userId);
setDetailModalVisible(false);
setSelectedUser(null);
} catch (error) {
// 错误已在 handleDelete 中处理
}
}}
/>
</div>
);
};
export default UserPage;

Some files were not shown because too many files have changed in this diff Show More