feat: 更新持仓

This commit is contained in:
R524809
2026-01-16 18:01:41 +08:00
parent b7972153cc
commit aa313b7605
16 changed files with 1757 additions and 39 deletions

View File

@@ -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,

View 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;

View File

@@ -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 }}
>
<span className="position-profit-amount">
{profitInfo.profitText}
</span>
<span className="position-profit-percent">
{profitInfo.percentText}
</span>
{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>
);
};

View File

@@ -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 };

View 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();

View File

@@ -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 || '';

View File

@@ -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;