feat: 更新持仓
This commit is contained in:
@@ -19,3 +19,6 @@ ADMIN_PASSWORD=joey5628
|
||||
ADMIN_EMAIL=zhangyi5628@126.com
|
||||
ADMIN_NICKNAME=思考的Joey
|
||||
ADMIN_ROLE=super_admin
|
||||
|
||||
STORAGE_PATH=./uploads # 存储路径(默认:./uploads)
|
||||
STORAGE_BASE_URL=http://localhost:3200/uploads # 访问URL(默认:http://localhost:3200/uploads)
|
||||
@@ -16,33 +16,48 @@ async function bootstrap() {
|
||||
bodyParser: false, // 禁用默认 bodyParser,使用自定义配置
|
||||
});
|
||||
|
||||
// 配置静态文件服务
|
||||
// 配置 CORS(必须在其他中间件之前)
|
||||
app.enableCors({
|
||||
origin: true, // 允许所有域名
|
||||
credentials: true, // 允许携带凭证
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||
});
|
||||
|
||||
// 配置静态文件服务(需要在 CORS 之后,以便静态文件也能应用 CORS)
|
||||
const storagePath =
|
||||
configService.get<string>('STORAGE_PATH') || './uploads';
|
||||
app.useStaticAssets(join(process.cwd(), storagePath), {
|
||||
prefix: '/uploads/',
|
||||
setHeaders: (res) => {
|
||||
// 为静态文件添加 CORS 头
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
},
|
||||
});
|
||||
|
||||
// 安全头设置(必须在其他中间件之前)
|
||||
app.use(helmet());
|
||||
// 安全头设置(必须在 CORS 之后)
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // 允许跨域资源访问
|
||||
}),
|
||||
);
|
||||
|
||||
// 速率限制
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60 * 1000, // 1 分钟
|
||||
max: 100, // 限制每个 IP 在 windowMs 时间内最多 10000 个请求
|
||||
max: 100, // 限制每个 IP 在 windowMs 时间内最多 100 个请求
|
||||
}),
|
||||
);
|
||||
|
||||
// 请求体解析(设置大小限制为 10mb)
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
|
||||
|
||||
app.enableCors();
|
||||
/* app.enableCors({
|
||||
// 允许的域名
|
||||
origin: ['http://localhost:3200'],
|
||||
}); */
|
||||
app.setGlobalPrefix('api');
|
||||
// 响应压缩(在 helmet 之后)
|
||||
app.use(compression());
|
||||
|
||||
@@ -12,14 +12,14 @@ import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePositionDto {
|
||||
@ApiProperty({
|
||||
description: '券商ID',
|
||||
@ApiPropertyOptional({
|
||||
description: '券商ID(可选)',
|
||||
example: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
brokerId: number;
|
||||
brokerId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产类型',
|
||||
@@ -31,18 +31,18 @@ export class CreatePositionDto {
|
||||
@IsIn(['stock', 'fund', 'cash', 'bond', 'other'])
|
||||
assetType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产代码(股票代码、基金代码等)',
|
||||
@ApiPropertyOptional({
|
||||
description: '资产代码(股票代码、基金代码等),现金和其他类型可为空',
|
||||
example: '600519',
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
symbol: string;
|
||||
symbol?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产名称',
|
||||
description: '资产名称,现金类型固定为"现金"',
|
||||
example: '贵州茅台',
|
||||
maxLength: 100,
|
||||
})
|
||||
|
||||
47
apps/api/src/modules/position/dto/search-asset.dto.ts
Normal file
47
apps/api/src/modules/position/dto/search-asset.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Request,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
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';
|
||||
@@ -33,6 +36,47 @@ import { User } from '../user/user.entity';
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户所有持仓(包含计算字段)
|
||||
*/
|
||||
|
||||
@@ -31,20 +31,22 @@ export class Position {
|
||||
@Index()
|
||||
userId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '券商ID',
|
||||
@ApiPropertyOptional({
|
||||
description: '券商ID(可选)',
|
||||
example: 1,
|
||||
})
|
||||
@Column({
|
||||
name: 'broker_id',
|
||||
type: 'bigint',
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => (value ? parseInt(value, 10) : null),
|
||||
to: (value: number | null | undefined) => value ?? null,
|
||||
from: (value: string | null) =>
|
||||
value ? parseInt(value, 10) : null,
|
||||
},
|
||||
})
|
||||
@Index()
|
||||
brokerId: number;
|
||||
brokerId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资产类型',
|
||||
|
||||
@@ -3,23 +3,180 @@ import {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户所有持仓(包含计算字段)
|
||||
*/
|
||||
@@ -92,11 +249,20 @@ export class PositionService {
|
||||
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
|
||||
const whereCondition: FindOptionsWhere<Position> = {
|
||||
userId,
|
||||
brokerId: createPositionDto.brokerId,
|
||||
symbol: createPositionDto.symbol,
|
||||
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;
|
||||
|
||||
7
apps/api/uploads/stock/stock-data.json
Normal file
7
apps/api/uploads/stock/stock-data.json
Normal file
File diff suppressed because one or more lines are too long
439
apps/api/资源上传文档.md
Normal file
439
apps/api/资源上传文档.md
Normal 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` - 静态文件服务配置
|
||||
|
||||
## 更新日志
|
||||
|
||||
- 初始版本:支持本地存储、文件上传、文件删除功能
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic } from 'antd';
|
||||
import { ArrowUpOutlined } from '@ant-design/icons';
|
||||
import PositionList from './components/PositionList';
|
||||
import { stockDataService } from '@/services/stock-data';
|
||||
import './AssetsPage.css';
|
||||
|
||||
const AssetsPage = () => {
|
||||
// 初始化股票数据(页面加载时)
|
||||
useEffect(() => {
|
||||
stockDataService.init().catch((error) => {
|
||||
console.error('初始化股票数据失败', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 写死的数据(占位)
|
||||
const stats = {
|
||||
totalAssets: 1234567,
|
||||
|
||||
624
apps/web/src/pages/assets/components/CreatePositionModal.tsx
Normal file
624
apps/web/src/pages/assets/components/CreatePositionModal.tsx
Normal file
@@ -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;
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -12,6 +13,7 @@ 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);
|
||||
|
||||
@@ -61,6 +63,16 @@ const PositionList = () => {
|
||||
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">
|
||||
@@ -70,6 +82,7 @@ const PositionList = () => {
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
className="position-add-btn"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
@@ -84,7 +97,13 @@ const PositionList = () => {
|
||||
position.profit,
|
||||
position.profitPercent
|
||||
);
|
||||
const brokerName = getBrokerName(position.brokerId);
|
||||
const marketValueColor = formatMarketValueColor(
|
||||
position.marketValue,
|
||||
position.costValue
|
||||
);
|
||||
const brokerName = position.brokerId
|
||||
? getBrokerName(position.brokerId)
|
||||
: '';
|
||||
const marketText = getMarketName(position.market);
|
||||
|
||||
return (
|
||||
@@ -122,21 +141,44 @@ const PositionList = () => {
|
||||
>
|
||||
{priceInfo.text}
|
||||
</div>
|
||||
<div className="position-market-value">
|
||||
<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>
|
||||
@@ -153,6 +195,13 @@ const PositionList = () => {
|
||||
})}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
{/* 新建持仓弹窗 */}
|
||||
<CreatePositionModal
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onSuccess={loadPositions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,35 @@ import type {
|
||||
UpdatePositionRequest,
|
||||
} from '@/types/position';
|
||||
|
||||
/**
|
||||
* 资产搜索结果
|
||||
*/
|
||||
export interface AssetSearchResult {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: string;
|
||||
assetType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持仓服务
|
||||
*/
|
||||
class PositionService {
|
||||
/**
|
||||
* 搜索资产(股票代码或名称)
|
||||
*/
|
||||
async searchAssets(
|
||||
keyword: string,
|
||||
assetType?: string,
|
||||
limit: number = 10
|
||||
): Promise<ApiResponse<AssetSearchResult[]>> {
|
||||
const params: any = { keyword, limit };
|
||||
if (assetType) {
|
||||
params.assetType = assetType;
|
||||
}
|
||||
return await api.get<ApiResponse<AssetSearchResult[]>>('/position/search', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户的所有持仓(不分页)
|
||||
*/
|
||||
@@ -43,3 +68,4 @@ class PositionService {
|
||||
}
|
||||
|
||||
export const positionService = new PositionService();
|
||||
export type { AssetSearchResult };
|
||||
|
||||
284
apps/web/src/services/stock-data.ts
Normal file
284
apps/web/src/services/stock-data.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { envConfig } from '@/config/env';
|
||||
|
||||
/**
|
||||
* 股票数据缓存键名
|
||||
*/
|
||||
const STOCK_DATA_CACHE_KEY = 'stock_data_cache';
|
||||
const STOCK_DATA_CACHE_TIME_KEY = 'stock_data_cache_time';
|
||||
|
||||
/**
|
||||
* 股票数据接口
|
||||
*/
|
||||
interface StockData {
|
||||
sh?: string; // A股-上海
|
||||
sz?: string; // A股-深圳
|
||||
bj?: string; // A股-北京
|
||||
hk?: string; // 港股
|
||||
us?: string; // 美股
|
||||
jp?: string; // 日股
|
||||
kr?: string; // 韩国股市
|
||||
eu?: string; // 欧洲市场
|
||||
sea?: string; // 东南亚市场
|
||||
other?: string; // 其他
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产搜索结果
|
||||
*/
|
||||
export interface AssetSearchResult {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: string; // 'a' | 'hk' | 'us' (a代表A股,统一sh/sz/bj)
|
||||
assetType: string; // 'stock'
|
||||
originalMarket?: string; // 原始市场代码(sh/sz/bj/hk/us),用于填充表单
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票数据服务
|
||||
*/
|
||||
class StockDataService {
|
||||
private stockDataCache: StockData | null = null;
|
||||
private cacheTime: number = 0;
|
||||
|
||||
/**
|
||||
* 从localStorage加载缓存
|
||||
*/
|
||||
private loadFromCache(): StockData | null {
|
||||
try {
|
||||
const cachedData = localStorage.getItem(STOCK_DATA_CACHE_KEY);
|
||||
const cacheTime = localStorage.getItem(STOCK_DATA_CACHE_TIME_KEY);
|
||||
|
||||
if (cachedData && cacheTime) {
|
||||
this.stockDataCache = JSON.parse(cachedData);
|
||||
this.cacheTime = parseInt(cacheTime, 10);
|
||||
return this.stockDataCache;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载股票数据缓存失败', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到localStorage
|
||||
*/
|
||||
private saveToCache(data: StockData): void {
|
||||
try {
|
||||
localStorage.setItem(STOCK_DATA_CACHE_KEY, JSON.stringify(data));
|
||||
localStorage.setItem(STOCK_DATA_CACHE_TIME_KEY, Date.now().toString());
|
||||
this.stockDataCache = data;
|
||||
this.cacheTime = Date.now();
|
||||
} catch (error) {
|
||||
console.error('保存股票数据缓存失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要更新(每周一更新)
|
||||
*/
|
||||
private shouldUpdate(): boolean {
|
||||
const cacheTimeStr = localStorage.getItem(STOCK_DATA_CACHE_TIME_KEY);
|
||||
const cacheTime = cacheTimeStr ? parseInt(cacheTimeStr, 10) : 0;
|
||||
|
||||
if (cacheTime === 0) {
|
||||
return true; // 没有缓存,需要加载
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentDayOfWeek = now.getDay(); // 0=周日,1=周一,...,6=周六
|
||||
|
||||
// 如果今天是周一,检查缓存是否是本周一的
|
||||
if (currentDayOfWeek === 1) {
|
||||
// 计算本周一00:00:00的时间戳
|
||||
const daysSinceMonday = 0; // 今天就是周一
|
||||
const thisMonday = new Date(now);
|
||||
thisMonday.setDate(now.getDate() - daysSinceMonday);
|
||||
thisMonday.setHours(0, 0, 0, 0);
|
||||
|
||||
// 如果缓存时间早于本周一00:00:00,需要更新
|
||||
return cacheTime < thisMonday.getTime();
|
||||
}
|
||||
|
||||
// 如果今天不是周一,检查缓存是否是上周一或更早
|
||||
// 计算上一个周一的时间戳
|
||||
const daysSinceLastMonday = currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1;
|
||||
const lastMonday = new Date(now);
|
||||
lastMonday.setDate(now.getDate() - daysSinceLastMonday);
|
||||
lastMonday.setHours(0, 0, 0, 0);
|
||||
|
||||
// 如果缓存时间早于上一个周一,需要更新
|
||||
if (cacheTime < lastMonday.getTime()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果缓存超过7天,也需要更新(兜底)
|
||||
const daysDiff = (now.getTime() - cacheTime) / (1000 * 60 * 60 * 24);
|
||||
return daysDiff > 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从HTTP地址获取股票数据
|
||||
*/
|
||||
async fetchStockData(): Promise<StockData> {
|
||||
try {
|
||||
// 构建URL(使用API基础URL,去掉/api后缀,加上/uploads路径)
|
||||
const apiBaseUrl = envConfig.apiBaseUrl.replace('/api', '');
|
||||
const stockDataUrl = `${apiBaseUrl}/uploads/stock/stock-data.json`;
|
||||
|
||||
console.log(`正在从 ${stockDataUrl} 加载股票数据...`);
|
||||
|
||||
const response = await fetch(stockDataUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: StockData = await response.json();
|
||||
|
||||
// 保存到缓存
|
||||
this.saveToCache(data);
|
||||
|
||||
console.log(`股票数据加载成功,共 ${Object.keys(data).length} 个市场`);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error(`加载股票数据失败: ${error.message}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取股票数据(带缓存)
|
||||
*/
|
||||
async getStockData(): Promise<StockData> {
|
||||
// 先尝试从缓存加载
|
||||
const cached = this.loadFromCache();
|
||||
|
||||
if (cached && !this.shouldUpdate()) {
|
||||
console.log('使用缓存的股票数据');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 需要更新,从HTTP获取
|
||||
console.log('更新股票数据...');
|
||||
try {
|
||||
return await this.fetchStockData();
|
||||
} catch (error) {
|
||||
// 如果获取失败,尝试使用缓存(即使过期)
|
||||
if (cached) {
|
||||
console.warn('获取股票数据失败,使用过期缓存');
|
||||
return cached;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索资产(前端字符串匹配)
|
||||
*/
|
||||
searchAssets(keyword: string, limit: number = 10): AssetSearchResult[] {
|
||||
if (!keyword || keyword.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.stockDataCache) {
|
||||
// 尝试从缓存加载
|
||||
const cached = this.loadFromCache();
|
||||
if (!cached) {
|
||||
console.warn('股票数据未加载,无法搜索');
|
||||
return [];
|
||||
}
|
||||
this.stockDataCache = cached;
|
||||
}
|
||||
|
||||
const results: AssetSearchResult[] = [];
|
||||
const keywordLower = keyword.toLowerCase().trim();
|
||||
|
||||
// 市场映射:市场代码 -> 统一市场代码
|
||||
// sh/sz/bj -> 'a' (A股)
|
||||
// hk -> 'hk' (港股)
|
||||
// us -> 'us' (美股)
|
||||
const marketMapping: Record<string, string> = {
|
||||
sh: 'a', // A股-上海 -> A股
|
||||
sz: 'a', // A股-深圳 -> A股
|
||||
bj: 'a', // A股-北京 -> A股
|
||||
hk: 'hk', // 港股
|
||||
us: 'us', // 美股
|
||||
};
|
||||
|
||||
// 遍历所有市场
|
||||
for (const [market, stockList] of Object.entries(this.stockDataCache)) {
|
||||
// 只搜索支持的市场(sh/sz/bj/hk/us)
|
||||
if (!['sh', 'sz', 'bj', 'hk', 'us'].includes(market)) {
|
||||
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; // 包含匹配
|
||||
}
|
||||
|
||||
// 统一市场代码:sh/sz/bj -> 'a' (A股)
|
||||
const unifiedMarket = marketMapping[market] || market;
|
||||
|
||||
results.push({
|
||||
symbol,
|
||||
name,
|
||||
market: unifiedMarket, // 统一市场代码:'a' | 'hk' | 'us'
|
||||
assetType: 'stock', // 能匹配上的一定是股票
|
||||
originalMarket: market, // 保存原始市场代码(sh/sz/bj/hk/us),用于后续填充表单
|
||||
score, // 用于排序(内部使用)
|
||||
} as AssetSearchResult & { score: number; originalMarket: string });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按匹配度排序,然后限制结果数量
|
||||
const sortedResults = (results as (AssetSearchResult & { score: number })[])
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ score, ...rest }) => rest); // 移除score字段
|
||||
|
||||
return sortedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化股票数据(页面加载时调用)
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
await this.getStockData();
|
||||
} catch (error) {
|
||||
console.error('初始化股票数据失败', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const stockDataService = new StockDataService();
|
||||
@@ -7,7 +7,7 @@ interface BrokerStore {
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
fetchBrokers: () => Promise<void>;
|
||||
getBrokerName: (brokerId: number) => string;
|
||||
getBrokerName: (brokerId?: number | null) => string;
|
||||
}
|
||||
|
||||
export const useBrokerStore = create<BrokerStore>((set, get) => ({
|
||||
@@ -46,7 +46,10 @@ export const useBrokerStore = create<BrokerStore>((set, get) => ({
|
||||
/**
|
||||
* 通过券商ID获取券商名称
|
||||
*/
|
||||
getBrokerName: (brokerId: number) => {
|
||||
getBrokerName: (brokerId?: number | null) => {
|
||||
if (brokerId === undefined || brokerId === null) {
|
||||
return '';
|
||||
}
|
||||
const { brokers } = get();
|
||||
const broker = brokers.find((b) => b.brokerId === brokerId);
|
||||
return broker?.brokerName || '';
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface PaginationInfo {
|
||||
export interface Position {
|
||||
positionId: number;
|
||||
userId: number;
|
||||
brokerId: number;
|
||||
brokerId?: number;
|
||||
assetType: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
@@ -55,7 +55,7 @@ export interface PaginatedPositionResponse {
|
||||
* 创建持仓请求
|
||||
*/
|
||||
export interface CreatePositionRequest {
|
||||
brokerId: number;
|
||||
brokerId?: number;
|
||||
assetType: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user