/** * EventFormModal - 通用事件表单弹窗组件 (Ant Design 重构版) * 用于新建/编辑投资计划、复盘 * * 功能特性: * - 使用 Ant Design 组件 * - 简化字段:标题、日期、描述、关联股票 * - 计划/复盘模板系统 * - 股票多选组件带智能搜索 * - Ctrl + Enter 快捷键保存 */ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Modal, Form, Input, DatePicker, Select, Button, Tag, Divider, message, Space, Spin, ConfigProvider, theme, } from 'antd'; import type { SelectProps } from 'antd'; import { BulbOutlined, StarOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; import { useSelector } from 'react-redux'; import { useAppDispatch } from '@/store/hooks'; import { usePlanningData } from './PlanningContext'; import './EventFormModal.less'; import type { InvestmentEvent, EventType } from '@/types'; import { logger } from '@/utils/logger'; import { getApiBase } from '@/utils/apiConfig'; import { loadWatchlist, loadAllStocks } from '@/store/slices/stockSlice'; import { stockService } from '@/services/stockService'; dayjs.locale('zh-cn'); const { TextArea } = Input; /** * 股票选项接口 */ interface StockOption { value: string; label: string; stock_code: string; stock_name: string; } /** * 表单数据接口 */ interface FormData { title: string; date: dayjs.Dayjs; content: string; stocks: string[]; } /** * 模板类型 */ interface Template { label: string; content: string; } /** * 计划模板 */ const PLAN_TEMPLATES: Template[] = [ { label: '目标', content: '【投资目标】\n\n', }, { label: '策略', content: '【交易策略】\n\n', }, { label: '风险控制', content: '【风险控制】\n- 止损位:\n- 仓位控制:\n', }, { label: '时间规划', content: '【时间规划】\n- 建仓时机:\n- 持仓周期:\n', }, ]; /** * 复盘模板 */ const REVIEW_TEMPLATES: Template[] = [ { label: '操作回顾', content: '【操作回顾】\n- 买入操作:\n- 卖出操作:\n', }, { label: '盈亏分析', content: '【盈亏分析】\n- 盈亏金额:\n- 收益率:\n- 主要原因:\n', }, { label: '经验总结', content: '【经验总结】\n- 做对的地方:\n- 做错的地方:\n', }, { label: '后续调整', content: '【后续调整】\n- 策略调整:\n- 仓位调整:\n', }, ]; /** * Redux state 类型 */ interface RootState { stock: { watchlist: Array<{ stock_code: string; stock_name: string }>; allStocks: Array<{ code: string; name: string }>; loading: { watchlist: boolean; allStocks: boolean; }; }; } /** * EventFormModal Props */ export interface EventFormModalProps { /** 弹窗是否打开 */ isOpen: boolean; /** 关闭弹窗回调 */ onClose: () => void; /** 模式:新建或编辑 */ mode: 'create' | 'edit'; /** 事件类型(新建时使用) */ eventType?: EventType; /** 初始日期(新建时使用,如从日历点击) */ initialDate?: string; /** 编辑时的原始事件数据 */ editingEvent?: InvestmentEvent | null; /** 保存成功回调 */ onSuccess: () => void; /** 主题颜色 */ colorScheme?: string; /** 显示标签(如 "计划"、"复盘"、"事件") */ label?: string; /** 是否显示日期选择器 */ showDatePicker?: boolean; /** 是否显示类型选择 */ showTypeSelect?: boolean; /** 是否显示状态选择 */ showStatusSelect?: boolean; /** 是否显示重要度选择 */ showImportance?: boolean; /** 是否显示标签输入 */ showTags?: boolean; /** 股票输入方式:'tag' 为标签形式,'text' 为逗号分隔文本 */ stockInputMode?: 'tag' | 'text'; /** API 端点 */ apiEndpoint?: 'investment-plans' | 'calendar/events'; } /** * EventFormModal 组件 */ export const EventFormModal: React.FC = ({ isOpen, onClose, mode, eventType = 'plan', initialDate, editingEvent, onSuccess, label = '事件', apiEndpoint = 'investment-plans', }) => { const { loadAllData } = usePlanningData(); const dispatch = useAppDispatch(); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const [stockOptions, setStockOptions] = useState([]); const [searchText, setSearchText] = useState(''); const modalContentRef = useRef(null); // 从 Redux 获取自选股和全部股票列表 const watchlist = useSelector((state: RootState) => state.stock.watchlist); const allStocks = useSelector((state: RootState) => state.stock.allStocks); const watchlistLoading = useSelector((state: RootState) => state.stock.loading.watchlist); const allStocksLoading = useSelector((state: RootState) => state.stock.loading.allStocks); // 将自选股转换为 StockOption 格式 const watchlistOptions = useMemo(() => { return watchlist.map(item => ({ value: item.stock_code, label: `${item.stock_name}(${item.stock_code})`, stock_code: item.stock_code, stock_name: item.stock_name, })); }, [watchlist]); // 获取模板列表 const templates = eventType === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES; // 生成默认模板内容 const getDefaultContent = (type: EventType): string => { const templateList = type === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES; return templateList.map(t => t.content).join('\n'); }; // 弹窗打开时加载数据 useEffect(() => { if (isOpen) { // 加载自选股列表 dispatch(loadWatchlist()); // 加载全部股票列表(用于模糊搜索) dispatch(loadAllStocks()); } }, [isOpen, dispatch]); // 初始化表单数据 useEffect(() => { if (isOpen) { if (mode === 'edit' && editingEvent) { // 将 stocks 转换为代码数组(兼容对象和字符串格式) const stockCodes = (editingEvent.stocks || []).map(stock => typeof stock === 'string' ? stock : stock.code ); form.setFieldsValue({ title: editingEvent.title, date: dayjs(editingEvent.event_date || editingEvent.date), content: editingEvent.description || editingEvent.content || '', stocks: stockCodes, }); } else { // 新建模式,重置表单并预填充模板内容 form.resetFields(); form.setFieldsValue({ date: initialDate ? dayjs(initialDate) : dayjs(), stocks: [], content: getDefaultContent(eventType), }); } } }, [isOpen, mode, editingEvent, initialDate, form, eventType]); // 股票搜索(前端模糊搜索) const handleStockSearch = useCallback((value: string) => { setSearchText(value); if (!value || value.length < 1) { // 无搜索词时显示自选股列表 setStockOptions(watchlistOptions); return; } // 使用 stockService.fuzzySearch 进行前端模糊搜索 const results = stockService.fuzzySearch(value, allStocks, 10); const options: StockOption[] = results.map(stock => ({ value: stock.code, label: `${stock.name}(${stock.code})`, stock_code: stock.code, stock_name: stock.name, })); setStockOptions(options.length > 0 ? options : watchlistOptions); }, [allStocks, watchlistOptions]); // 保存数据 const handleSave = useCallback(async (): Promise => { try { const values = await form.validateFields(); setSaving(true); const base = getApiBase(); // 将选中的股票代码转换为包含名称的对象数组 const stocksWithNames = (values.stocks || []).map((code: string) => { const stockInfo = allStocks.find(s => s.code === code); const watchlistInfo = watchlist.find(s => s.stock_code === code); return { code, name: stockInfo?.name || watchlistInfo?.stock_name || code, }; }); // 构建请求数据 const requestData: Record = { title: values.title, content: values.content, date: values.date.format('YYYY-MM-DD'), type: eventType, stocks: stocksWithNames, status: 'active', }; // 根据 API 端点调整字段名 if (apiEndpoint === 'calendar/events') { requestData.description = values.content; requestData.event_date = values.date.format('YYYY-MM-DD'); delete requestData.content; delete requestData.date; } const url = mode === 'edit' && editingEvent ? `${base}/api/account/${apiEndpoint}/${editingEvent.id}` : `${base}/api/account/${apiEndpoint}`; const method = mode === 'edit' ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify(requestData), }); if (response.ok) { logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, { itemId: editingEvent?.id, title: values.title, }); message.success(mode === 'edit' ? '修改成功' : '添加成功'); onClose(); onSuccess(); loadAllData(); } else { throw new Error('保存失败'); } } catch (error) { if (error instanceof Error && error.message !== '保存失败') { // 表单验证错误,不显示额外提示 return; } logger.error('EventFormModal', 'handleSave', error, { itemId: editingEvent?.id, }); message.error('保存失败,请稍后重试'); } finally { setSaving(false); } }, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]); // 监听键盘快捷键 Ctrl + Enter useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (isOpen && (e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); handleSave(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleSave]); // 插入模板 const handleInsertTemplate = (template: Template): void => { const currentContent = form.getFieldValue('content') || ''; const newContent = currentContent ? `${currentContent}\n\n${template.content}` : template.content; form.setFieldsValue({ content: newContent }); }; // 获取标题 placeholder const getTitlePlaceholder = (): string => { if (eventType === 'plan') { return '例如:关注AI板块'; } return '例如:12月操作总结'; }; // 获取内容 placeholder const getContentPlaceholder = (): string => { if (eventType === 'plan') { return '计划模板:\n目标:\n策略:\n风险控制:\n时间规划:'; } return '复盘模板:\n操作回顾:\n盈亏分析:\n经验总结:\n后续调整:'; }; // 判断是否显示自选股列表 const isShowingWatchlist = !searchText && stockOptions === watchlistOptions; // 股票选择器选项配置(黑金主题) const selectProps: SelectProps = { mode: 'multiple', placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索', filterOption: false, onSearch: handleStockSearch, loading: watchlistLoading || allStocksLoading, notFoundContent: allStocksLoading ? (
加载中...
) : 暂无结果, options: stockOptions, style: { width: '100%', }, onFocus: () => { if (stockOptions.length === 0) { setStockOptions(watchlistOptions); } }, tagRender: (props) => { const { label: tagLabel, closable, onClose: onTagClose } = props; return ( {tagLabel} ); }, popupRender: (menu) => (
{isShowingWatchlist && watchlistOptions.length > 0 && ( <>
我的自选股
)} {menu} {!isShowingWatchlist && searchText && ( <>
搜索结果(输入代码或名称)
)}
), }; // 获取按钮文案 const getButtonText = (): string => { if (mode === 'edit') { return eventType === 'plan' ? '更新计划' : '更新复盘'; } return eventType === 'plan' ? '创建计划' : '创建复盘'; }; // 黑金主题样式 const modalStyles = { mask: { background: 'rgba(0, 0, 0, 0.7)', backdropFilter: 'blur(4px)', }, content: { background: 'linear-gradient(135deg, #1A1A2E 0%, #0F0F1A 100%)', border: '1px solid rgba(212, 175, 55, 0.2)', borderRadius: '12px', boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1)', }, header: { background: 'transparent', borderBottom: '1px solid rgba(212, 175, 55, 0.1)', padding: '16px 24px', }, body: { padding: '24px', paddingTop: '24px', }, footer: { background: 'transparent', borderTop: '1px solid rgba(212, 175, 55, 0.1)', padding: '16px 24px', }, }; return ( {`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`} } open={isOpen} onCancel={onClose} width={600} destroyOnHidden maskClosable={true} keyboard className="event-form-modal" styles={modalStyles} closeIcon={ } footer={
} >
{isOpen &&
{/* 标题 */} 标题 *} rules={[ { required: true, message: '请输入标题' }, { max: 50, message: '标题不能超过50个字符' }, ]} > {/* 日期 */} {eventType === 'plan' ? '计划日期' : '复盘日期'} *} rules={[{ required: true, message: '请选择日期' }]} > {/* 描述/内容 - 上下布局 */} {eventType === 'plan' ? '计划详情' : '复盘内容'} *} rules={[{ required: true, message: '请输入内容' }]} labelCol={{ span: 24 }} wrapperCol={{ span: 24 }} style={{ marginBottom: 8 }} >