625 lines
26 KiB
TypeScript
625 lines
26 KiB
TypeScript
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;
|