Files
invest-mind-store/apps/web/src/pages/assets/components/CreatePositionModal.tsx
2026-01-16 18:01:41 +08:00

625 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;