- planningSlice: 添加 optimisticAddEvent、replaceEvent、removeEvent reducers - EventFormModal: 新建模式使用乐观更新,立即关闭弹窗显示数据 - account.js: Mock 数据按日期倒序排序,最新事件在前 乐观更新流程: 1. 创建临时事件(负数 ID)立即更新 UI 2. 后台发送 API 请求 3. 成功后替换为真实数据,失败则回滚 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
754 lines
23 KiB
TypeScript
754 lines
23 KiB
TypeScript
/**
|
||
* 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 {
|
||
fetchAllEvents,
|
||
optimisticAddEvent,
|
||
replaceEvent,
|
||
removeEvent,
|
||
} from '@/store/slices/planningSlice';
|
||
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<EventFormModalProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
mode,
|
||
eventType = 'plan',
|
||
initialDate,
|
||
editingEvent,
|
||
onSuccess,
|
||
label = '事件',
|
||
apiEndpoint = 'investment-plans',
|
||
}) => {
|
||
const dispatch = useAppDispatch();
|
||
const [form] = Form.useForm<FormData>();
|
||
const [saving, setSaving] = useState(false);
|
||
const [stockOptions, setStockOptions] = useState<StockOption[]>([]);
|
||
const [searchText, setSearchText] = useState('');
|
||
const modalContentRef = useRef<HTMLDivElement>(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<StockOption[]>(() => {
|
||
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<void> => {
|
||
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<string, unknown> = {
|
||
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}`;
|
||
|
||
// ===== 新建模式:乐观更新 =====
|
||
if (mode === 'create') {
|
||
const tempId = -Date.now(); // 负数临时 ID,避免与服务器 ID 冲突
|
||
const tempEvent: InvestmentEvent = {
|
||
id: tempId,
|
||
title: values.title,
|
||
content: values.content || '',
|
||
description: values.content || '',
|
||
date: values.date.format('YYYY-MM-DD'),
|
||
event_date: values.date.format('YYYY-MM-DD'),
|
||
type: eventType,
|
||
stocks: stocksWithNames,
|
||
status: 'active',
|
||
source: 'user',
|
||
created_at: new Date().toISOString(),
|
||
updated_at: new Date().toISOString(),
|
||
};
|
||
|
||
// ① 立即更新 UI
|
||
dispatch(optimisticAddEvent(tempEvent));
|
||
setSaving(false);
|
||
onClose(); // 立即关闭弹窗
|
||
|
||
// ② 后台发送 API 请求
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(requestData),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok && data.success) {
|
||
// ③ 用真实数据替换临时数据
|
||
dispatch(replaceEvent({ tempId, realEvent: data.data }));
|
||
logger.info('EventFormModal', `创建${label}成功`, { title: values.title });
|
||
message.success('添加成功');
|
||
onSuccess();
|
||
} else {
|
||
throw new Error(data.error || '创建失败');
|
||
}
|
||
} catch (error) {
|
||
// ④ 失败回滚
|
||
dispatch(removeEvent(tempId));
|
||
logger.error('EventFormModal', 'handleSave optimistic rollback', error);
|
||
message.error('创建失败,请重试');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ===== 编辑模式:传统更新 =====
|
||
const response = await fetch(url, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(requestData),
|
||
});
|
||
|
||
if (response.ok) {
|
||
logger.info('EventFormModal', `更新${label}成功`, {
|
||
itemId: editingEvent?.id,
|
||
title: values.title,
|
||
});
|
||
message.success('修改成功');
|
||
onClose();
|
||
onSuccess();
|
||
// 使用 Redux 刷新数据,确保列表和日历同步
|
||
dispatch(fetchAllEvents());
|
||
} 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, dispatch, 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<string[]> = {
|
||
mode: 'multiple',
|
||
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
|
||
filterOption: false,
|
||
onSearch: handleStockSearch,
|
||
loading: watchlistLoading || allStocksLoading,
|
||
notFoundContent: allStocksLoading ? (
|
||
<div style={{ textAlign: 'center', padding: '8px', color: 'rgba(255,255,255,0.6)' }}>
|
||
<Spin size="small" />
|
||
<span style={{ marginLeft: 8 }}>加载中...</span>
|
||
</div>
|
||
) : <span style={{ color: 'rgba(255,255,255,0.4)' }}>暂无结果</span>,
|
||
options: stockOptions,
|
||
style: {
|
||
width: '100%',
|
||
},
|
||
onFocus: () => {
|
||
if (stockOptions.length === 0) {
|
||
setStockOptions(watchlistOptions);
|
||
}
|
||
},
|
||
tagRender: (props) => {
|
||
const { label: tagLabel, closable, onClose: onTagClose } = props;
|
||
return (
|
||
<Tag
|
||
closable={closable}
|
||
onClose={onTagClose}
|
||
style={{
|
||
marginRight: 3,
|
||
background: 'rgba(212, 175, 55, 0.15)',
|
||
border: '1px solid rgba(212, 175, 55, 0.3)',
|
||
color: '#D4AF37',
|
||
}}
|
||
>
|
||
{tagLabel}
|
||
</Tag>
|
||
);
|
||
},
|
||
popupRender: (menu) => (
|
||
<div style={{
|
||
background: '#1A1A2E',
|
||
border: '1px solid rgba(212, 175, 55, 0.2)',
|
||
borderRadius: '8px',
|
||
}}>
|
||
{isShowingWatchlist && watchlistOptions.length > 0 && (
|
||
<>
|
||
<div style={{ padding: '8px 12px 4px' }}>
|
||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.6)' }}>
|
||
<StarOutlined style={{ marginRight: 4, color: '#D4AF37' }} />
|
||
我的自选股
|
||
</span>
|
||
</div>
|
||
<Divider style={{ margin: '4px 0 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
|
||
</>
|
||
)}
|
||
{menu}
|
||
{!isShowingWatchlist && searchText && (
|
||
<>
|
||
<Divider style={{ margin: '8px 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
|
||
<div style={{ padding: '0 12px 8px' }}>
|
||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.4)' }}>
|
||
<BulbOutlined style={{ marginRight: 4 }} />
|
||
搜索结果(输入代码或名称)
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
),
|
||
};
|
||
|
||
// 获取按钮文案
|
||
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 (
|
||
<Modal
|
||
title={
|
||
<span style={{
|
||
fontSize: '20px',
|
||
fontWeight: 700,
|
||
background: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}>
|
||
{`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
||
</span>
|
||
}
|
||
open={isOpen}
|
||
onCancel={onClose}
|
||
width={600}
|
||
destroyOnHidden
|
||
maskClosable={true}
|
||
keyboard
|
||
className="event-form-modal"
|
||
styles={modalStyles}
|
||
closeIcon={
|
||
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: '16px' }}>✕</span>
|
||
}
|
||
footer={
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleSave}
|
||
loading={saving}
|
||
disabled={saving}
|
||
style={{
|
||
background: 'linear-gradient(135deg, #D4AF37 0%, #B8960C 100%)',
|
||
border: 'none',
|
||
color: '#0A0A14',
|
||
fontWeight: 600,
|
||
height: '40px',
|
||
padding: '0 24px',
|
||
borderRadius: '8px',
|
||
}}
|
||
>
|
||
{getButtonText()}
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<ConfigProvider
|
||
theme={{
|
||
algorithm: theme.darkAlgorithm,
|
||
token: {
|
||
colorPrimary: '#D4AF37',
|
||
colorBgContainer: 'rgba(26, 26, 46, 0.8)',
|
||
colorBorder: 'rgba(212, 175, 55, 0.2)',
|
||
colorBorderSecondary: 'rgba(212, 175, 55, 0.1)',
|
||
colorText: 'rgba(255, 255, 255, 0.95)',
|
||
colorTextSecondary: 'rgba(255, 255, 255, 0.6)',
|
||
colorTextPlaceholder: 'rgba(255, 255, 255, 0.4)',
|
||
colorBgElevated: '#1A1A2E',
|
||
colorFillSecondary: 'rgba(212, 175, 55, 0.1)',
|
||
},
|
||
components: {
|
||
Input: {
|
||
activeBorderColor: '#D4AF37',
|
||
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
|
||
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
|
||
},
|
||
Select: {
|
||
optionActiveBg: 'rgba(212, 175, 55, 0.1)',
|
||
optionSelectedBg: 'rgba(212, 175, 55, 0.2)',
|
||
optionSelectedColor: '#D4AF37',
|
||
},
|
||
DatePicker: {
|
||
activeBorderColor: '#D4AF37',
|
||
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
|
||
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<div ref={modalContentRef}>
|
||
{isOpen && <Form
|
||
form={form}
|
||
layout="horizontal"
|
||
labelCol={{ span: 4 }}
|
||
wrapperCol={{ span: 20 }}
|
||
labelAlign="left"
|
||
requiredMark={false}
|
||
initialValues={{
|
||
date: dayjs(),
|
||
stocks: [],
|
||
}}
|
||
>
|
||
{/* 标题 */}
|
||
<Form.Item
|
||
name="title"
|
||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>标题 <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||
rules={[
|
||
{ required: true, message: '请输入标题' },
|
||
{ max: 50, message: '标题不能超过50个字符' },
|
||
]}
|
||
>
|
||
<Input
|
||
placeholder={getTitlePlaceholder()}
|
||
maxLength={50}
|
||
showCount
|
||
styles={{
|
||
input: {
|
||
background: 'rgba(26, 26, 46, 0.8)',
|
||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||
color: 'rgba(255, 255, 255, 0.95)',
|
||
},
|
||
count: {
|
||
color: 'rgba(255, 255, 255, 0.4)',
|
||
},
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
|
||
{/* 日期 */}
|
||
<Form.Item
|
||
name="date"
|
||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||
rules={[{ required: true, message: '请选择日期' }]}
|
||
>
|
||
<DatePicker
|
||
style={{
|
||
width: '100%',
|
||
background: 'rgba(26, 26, 46, 0.8)',
|
||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||
}}
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择日期"
|
||
allowClear={false}
|
||
/>
|
||
</Form.Item>
|
||
|
||
{/* 描述/内容 - 上下布局 */}
|
||
<Form.Item
|
||
name="content"
|
||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||
rules={[{ required: true, message: '请输入内容' }]}
|
||
labelCol={{ span: 24 }}
|
||
wrapperCol={{ span: 24 }}
|
||
style={{ marginBottom: 8 }}
|
||
>
|
||
<TextArea
|
||
placeholder={getContentPlaceholder()}
|
||
rows={8}
|
||
showCount
|
||
maxLength={2000}
|
||
style={{
|
||
resize: 'vertical',
|
||
background: 'rgba(26, 26, 46, 0.8)',
|
||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||
color: 'rgba(255, 255, 255, 0.95)',
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
|
||
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
|
||
<div style={{ marginBottom: 24 }}>
|
||
<Space wrap size="small">
|
||
{templates.map((template) => (
|
||
<Button
|
||
key={template.label}
|
||
size="small"
|
||
onClick={() => handleInsertTemplate(template)}
|
||
style={{
|
||
background: 'rgba(212, 175, 55, 0.1)',
|
||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||
color: 'rgba(255, 255, 255, 0.6)',
|
||
}}
|
||
>
|
||
{template.label}
|
||
</Button>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
|
||
{/* 关联股票 */}
|
||
<Form.Item
|
||
name="stocks"
|
||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>关联股票</span>}
|
||
>
|
||
<Select {...selectProps} />
|
||
</Form.Item>
|
||
</Form>}
|
||
</div>
|
||
</ConfigProvider>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default EventFormModal;
|