diff --git a/src/views/Dashboard/components/EventFormModal.less b/src/views/Dashboard/components/EventFormModal.less new file mode 100644 index 00000000..6b389f05 --- /dev/null +++ b/src/views/Dashboard/components/EventFormModal.less @@ -0,0 +1,198 @@ +/* EventFormModal.less - 投资计划/复盘弹窗响应式样式 */ + +// ==================== 变量定义 ==================== +@mobile-breakpoint: 768px; +@modal-border-radius-mobile: 12px; +@modal-border-radius-desktop: 8px; + +// 间距 +@spacing-xs: 4px; +@spacing-sm: 8px; +@spacing-md: 12px; +@spacing-lg: 16px; +@spacing-xl: 20px; +@spacing-xxl: 24px; + +// 字体大小 +@font-size-xs: 12px; +@font-size-sm: 14px; +@font-size-md: 16px; + +// 颜色 +@color-border: #f0f0f0; +@color-text-secondary: #999; +@color-error: #ff4d4f; + +// ==================== 主样式 ==================== +.event-form-modal { + // Modal 整体 + .ant-modal-content { + border-radius: @modal-border-radius-desktop; + } + + // Modal 标题放大加粗 + .ant-modal-title { + font-size: 20px; + font-weight: 700; + } + + .ant-modal-body { + padding: @spacing-xxl; + padding-top: 36px; // 增加标题与表单间距 + } + + .ant-form-item { + margin-bottom: @spacing-xl; + } + + // 表单标签加粗,左对齐 + .ant-form-item-label { + text-align: left !important; + + > label { + font-weight: 600 !important; + color: #333; + } + } + + // 字符计数样式 + .ant-input-textarea-show-count::after { + font-size: @font-size-xs; + color: @color-text-secondary; + } + + // 日期选择器全宽 + .ant-picker { + width: 100%; + } + + // 股票标签样式 + .ant-tag { + margin: 2px; + border-radius: @spacing-xs; + } + + // 模板按钮组 + .template-buttons { + .ant-btn { + font-size: @font-size-xs; + } + } + + // 底部操作栏布局 + .modal-footer { + display: flex; + justify-content: flex-end; + } + + // 加载状态 + .ant-btn-loading { + opacity: 0.8; + } + + // 错误状态动画 + .ant-form-item-has-error { + .ant-input, + .ant-picker, + .ant-select-selector { + animation: shake 0.3s ease-in-out; + } + } +} + +// ==================== 移动端适配 ==================== +@media (max-width: @mobile-breakpoint) { + .event-form-modal { + // Modal 整体尺寸 + .ant-modal { + width: calc(100vw - 32px) !important; + max-width: 100% !important; + margin: @spacing-lg auto; + top: 0; + padding-bottom: 0; + } + + .ant-modal-content { + max-height: calc(100vh - 32px); + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: @modal-border-radius-mobile; + } + + // Modal 头部 + .ant-modal-header { + padding: @spacing-md @spacing-lg; + flex-shrink: 0; + } + + .ant-modal-title { + font-size: @font-size-md; + } + + // Modal 内容区域 + .ant-modal-body { + padding: @spacing-lg; + overflow-y: auto; + flex: 1; + -webkit-overflow-scrolling: touch; + } + + // Modal 底部 + .ant-modal-footer { + padding: @spacing-md @spacing-lg; + flex-shrink: 0; + border-top: 1px solid @color-border; + } + + // 表单项间距 + .ant-form-item { + margin-bottom: @spacing-lg; + } + + // 表单标签 + .ant-form-item-label > label { + font-size: @font-size-sm; + height: auto; + } + + // 输入框字体 - iOS 防止缩放需要 16px + .ant-input, + .ant-picker-input > input, + .ant-select-selection-search-input { + font-size: @font-size-md !important; + } + + // 文本域高度 + .ant-input-textarea textarea { + font-size: @font-size-md !important; + min-height: 120px; + } + + // 模板按钮组 + .template-buttons .ant-btn { + font-size: @font-size-xs; + padding: 2px @spacing-sm; + height: 26px; + } + + // 股票选择器 + .ant-select-selector { + min-height: 40px !important; + } + + // 底部按钮 + .ant-modal-footer .ant-btn { + font-size: @font-size-md; + height: 40px; + border-radius: @spacing-sm; + } + } +} + +// ==================== 动画 ==================== +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-4px); } + 75% { transform: translateX(4px); } +} diff --git a/src/views/Dashboard/components/EventFormModal.tsx b/src/views/Dashboard/components/EventFormModal.tsx index 4cb4b949..a6cba50b 100644 --- a/src/views/Dashboard/components/EventFormModal.tsx +++ b/src/views/Dashboard/components/EventFormModal.tsx @@ -1,66 +1,135 @@ /** - * EventFormModal - 通用事件表单弹窗组件 - * 用于新建/编辑投资计划、复盘、日历事件等 + * EventFormModal - 通用事件表单弹窗组件 (Ant Design 重构版) + * 用于新建/编辑投资计划、复盘 * - * 通过 props 配置差异化行为: - * - 字段显示控制(日期选择器、类型、状态、重要度、标签等) - * - API 端点配置(investment-plans 或 calendar/events) - * - 主题颜色和标签文案 + * 功能特性: + * - 使用 Ant Design 组件 + * - 简化字段:标题、日期、描述、关联股票 + * - 计划/复盘模板系统 + * - 股票多选组件带智能搜索 + * - Ctrl + Enter 快捷键保存 */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { - Button, Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalFooter, - ModalBody, - ModalCloseButton, - VStack, - HStack, - Icon, + Form, Input, - InputGroup, - InputLeftElement, - FormControl, - FormLabel, - Textarea, + DatePicker, Select, + Button, Tag, - TagLabel, - TagLeftIcon, - TagCloseButton, -} from '@chakra-ui/react'; + Divider, + message, + Space, + Spin, +} from 'antd'; +import type { SelectProps } from 'antd'; import { - FiSave, - FiCalendar, - FiTrendingUp, - FiHash, -} from 'react-icons/fi'; + 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 type { InvestmentEvent, EventType, EventStatus } from '@/types'; +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 { - date: string; title: string; + date: dayjs.Dayjs; content: string; - type: EventType; stocks: string[]; - tags: string[]; - status: EventStatus; - importance: number; +} + +/** + * 模板类型 + */ +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; + }; + }; } /** @@ -112,95 +181,120 @@ export const EventFormModal: React.FC = ({ initialDate, editingEvent, onSuccess, - colorScheme = 'purple', label = '事件', - showDatePicker = true, - showTypeSelect = false, - showStatusSelect = true, - showImportance = false, - showTags = true, - stockInputMode = 'tag', apiEndpoint = 'investment-plans', }) => { - const { toast, secondaryText } = usePlanningData(); + 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); - // 表单数据 - const [formData, setFormData] = useState({ - date: initialDate || dayjs().format('YYYY-MM-DD'), - title: '', - content: '', - type: eventType, - stocks: [], - tags: [], - status: 'active', - importance: 3, - }); + // 从 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); - // 股票和标签输入框 - const [stockInput, setStockInput] = useState(''); - const [tagInput, setTagInput] = useState(''); + // 将自选股转换为 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 [saving, setSaving] = useState(false); + // 获取模板列表 + 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 (mode === 'edit' && editingEvent) { - setFormData({ - date: dayjs(editingEvent.event_date || editingEvent.date).format('YYYY-MM-DD'), - title: editingEvent.title, - content: editingEvent.description || editingEvent.content || '', - type: editingEvent.type || eventType, - stocks: editingEvent.stocks || [], - tags: editingEvent.tags || [], - status: editingEvent.status || 'active', - importance: editingEvent.importance || 3, - }); - // 如果是文本模式,将股票数组转为逗号分隔 - if (stockInputMode === 'text' && editingEvent.stocks) { - setStockInput(editingEvent.stocks.join(',')); + if (isOpen) { + if (mode === 'edit' && editingEvent) { + form.setFieldsValue({ + title: editingEvent.title, + date: dayjs(editingEvent.event_date || editingEvent.date), + content: editingEvent.description || editingEvent.content || '', + stocks: editingEvent.stocks || [], + }); + } else { + // 新建模式,重置表单并预填充模板内容 + form.resetFields(); + form.setFieldsValue({ + date: initialDate ? dayjs(initialDate) : dayjs(), + stocks: [], + content: getDefaultContent(eventType), + }); } - } else { - // 新建模式,重置表单 - setFormData({ - date: initialDate || dayjs().format('YYYY-MM-DD'), - title: '', - content: '', - type: eventType, - stocks: [], - tags: [], - status: 'active', - importance: 3, - }); - setStockInput(''); - setTagInput(''); } - }, [mode, editingEvent, eventType, initialDate, stockInputMode]); + }, [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 = async (): Promise => { + const handleSave = useCallback(async (): Promise => { try { + const values = await form.validateFields(); setSaving(true); + const base = getApiBase(); // 构建请求数据 - let requestData: Record = { ...formData }; - - // 如果是文本模式,解析股票输入 - if (stockInputMode === 'text' && stockInput) { - requestData.stocks = stockInput.split(',').map(s => s.trim()).filter(s => s); - } + const requestData: Record = { + title: values.title, + content: values.content, + date: values.date.format('YYYY-MM-DD'), + type: eventType, + stocks: values.stocks || [], + status: 'active', + }; // 根据 API 端点调整字段名 if (apiEndpoint === 'calendar/events') { - requestData = { - title: formData.title, - description: formData.content, - type: formData.type, - importance: formData.importance, - stocks: requestData.stocks, - event_date: formData.date, - }; + requestData.description = values.content; + requestData.event_date = values.date.format('YYYY-MM-DD'); + delete requestData.content; + delete requestData.date; } const url = mode === 'edit' && editingEvent @@ -221,274 +315,247 @@ export const EventFormModal: React.FC = ({ if (response.ok) { logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, { itemId: editingEvent?.id, - title: formData.title, - }); - toast({ - title: mode === 'edit' ? '更新成功' : '创建成功', - status: 'success', - duration: 2000, + 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, - title: formData?.title - }); - toast({ - title: '保存失败', - description: '无法保存数据', - status: 'error', - duration: 3000, }); + message.error('保存失败,请稍后重试'); } finally { setSaving(false); } - }; + }, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData]); - // 添加股票(Tag 模式) - const handleAddStock = (): void => { - if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) { - setFormData({ - ...formData, - stocks: [...formData.stocks, stockInput.trim()], - }); - setStockInput(''); - } - }; + // 监听键盘快捷键 Ctrl + Enter + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isOpen && (e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } + }; - // 移除股票(Tag 模式) - const handleRemoveStock = (index: number): void => { - setFormData({ - ...formData, - stocks: formData.stocks.filter((_, i) => i !== index), - }); - }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, handleSave]); - // 添加标签 - const handleAddTag = (): void => { - if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) { - setFormData({ - ...formData, - tags: [...formData.tags, tagInput.trim()], - }); - setTagInput(''); - } - }; - - // 移除标签 - const handleRemoveTag = (index: number): void => { - setFormData({ - ...formData, - tags: formData.tags.filter((_, i) => i !== index), - }); + // 插入模板 + 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 => { - switch (formData.type) { - case 'plan': - return '例如:布局新能源板块'; - case 'review': - return '例如:本周操作复盘'; - case 'reminder': - return '例如:关注半导体板块'; - case 'analysis': - return '例如:行业分析任务'; - default: - return '请输入标题'; + if (eventType === 'plan') { + return '例如:关注AI板块'; } + return '例如:12月操作总结'; }; // 获取内容 placeholder const getContentPlaceholder = (): string => { - switch (formData.type) { - case 'plan': - return '详细描述您的投资计划...'; - case 'review': - return '详细记录您的投资复盘...'; - default: - return '详细描述...'; + 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, + 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' ? '创建计划' : '创建复盘'; }; return ( - - - - - {mode === 'edit' ? '编辑' : '新建'}投资{label} - - - - - {/* 日期选择器 */} - {showDatePicker && ( - - 日期 - - - - - setFormData({ ...formData, date: e.target.value })} - /> - - - )} - - {/* 标题 */} - - 标题 - setFormData({ ...formData, title: e.target.value })} - placeholder={getTitlePlaceholder()} - /> - - - {/* 内容/描述 */} - - 内容 -