refactor: EventFormModal 从 Chakra UI 迁移到 Ant Design
- 使用 Ant Design Form/Modal/Select 组件 - 简化字段: 标题、日期、内容、关联股票 - 新增计划/复盘模板系统 - 股票选择支持前端模糊搜索 + 自选股快捷选择 - 新增响应式样式 (EventFormModal.less) - EventPanel: 移除不再需要的 props
This commit is contained in:
198
src/views/Dashboard/components/EventFormModal.less
Normal file
198
src/views/Dashboard/components/EventFormModal.less
Normal file
@@ -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); }
|
||||||
|
}
|
||||||
@@ -1,66 +1,135 @@
|
|||||||
/**
|
/**
|
||||||
* EventFormModal - 通用事件表单弹窗组件
|
* EventFormModal - 通用事件表单弹窗组件 (Ant Design 重构版)
|
||||||
* 用于新建/编辑投资计划、复盘、日历事件等
|
* 用于新建/编辑投资计划、复盘
|
||||||
*
|
*
|
||||||
* 通过 props 配置差异化行为:
|
* 功能特性:
|
||||||
* - 字段显示控制(日期选择器、类型、状态、重要度、标签等)
|
* - 使用 Ant Design 组件
|
||||||
* - API 端点配置(investment-plans 或 calendar/events)
|
* - 简化字段:标题、日期、描述、关联股票
|
||||||
* - 主题颜色和标签文案
|
* - 计划/复盘模板系统
|
||||||
|
* - 股票多选组件带智能搜索
|
||||||
|
* - Ctrl + Enter 快捷键保存
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Modal,
|
Modal,
|
||||||
ModalOverlay,
|
Form,
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalFooter,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
Input,
|
Input,
|
||||||
InputGroup,
|
DatePicker,
|
||||||
InputLeftElement,
|
|
||||||
FormControl,
|
|
||||||
FormLabel,
|
|
||||||
Textarea,
|
|
||||||
Select,
|
Select,
|
||||||
|
Button,
|
||||||
Tag,
|
Tag,
|
||||||
TagLabel,
|
Divider,
|
||||||
TagLeftIcon,
|
message,
|
||||||
TagCloseButton,
|
Space,
|
||||||
} from '@chakra-ui/react';
|
Spin,
|
||||||
|
} from 'antd';
|
||||||
|
import type { SelectProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
FiSave,
|
BulbOutlined,
|
||||||
FiCalendar,
|
StarOutlined,
|
||||||
FiTrendingUp,
|
} from '@ant-design/icons';
|
||||||
FiHash,
|
|
||||||
} from 'react-icons/fi';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { useAppDispatch } from '@/store/hooks';
|
||||||
import { usePlanningData } from './PlanningContext';
|
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 { logger } from '@/utils/logger';
|
||||||
import { getApiBase } from '@/utils/apiConfig';
|
import { getApiBase } from '@/utils/apiConfig';
|
||||||
|
import { loadWatchlist, loadAllStocks } from '@/store/slices/stockSlice';
|
||||||
|
import { stockService } from '@/services/stockService';
|
||||||
|
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票选项接口
|
||||||
|
*/
|
||||||
|
interface StockOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表单数据接口
|
* 表单数据接口
|
||||||
*/
|
*/
|
||||||
interface FormData {
|
interface FormData {
|
||||||
date: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
|
date: dayjs.Dayjs;
|
||||||
content: string;
|
content: string;
|
||||||
type: EventType;
|
|
||||||
stocks: string[];
|
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<EventFormModalProps> = ({
|
|||||||
initialDate,
|
initialDate,
|
||||||
editingEvent,
|
editingEvent,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
colorScheme = 'purple',
|
|
||||||
label = '事件',
|
label = '事件',
|
||||||
showDatePicker = true,
|
|
||||||
showTypeSelect = false,
|
|
||||||
showStatusSelect = true,
|
|
||||||
showImportance = false,
|
|
||||||
showTags = true,
|
|
||||||
stockInputMode = 'tag',
|
|
||||||
apiEndpoint = 'investment-plans',
|
apiEndpoint = 'investment-plans',
|
||||||
}) => {
|
}) => {
|
||||||
const { toast, secondaryText } = usePlanningData();
|
const { loadAllData } = usePlanningData();
|
||||||
|
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 [formData, setFormData] = useState<FormData>({
|
const watchlist = useSelector((state: RootState) => state.stock.watchlist);
|
||||||
date: initialDate || dayjs().format('YYYY-MM-DD'),
|
const allStocks = useSelector((state: RootState) => state.stock.allStocks);
|
||||||
title: '',
|
const watchlistLoading = useSelector((state: RootState) => state.stock.loading.watchlist);
|
||||||
content: '',
|
const allStocksLoading = useSelector((state: RootState) => state.stock.loading.allStocks);
|
||||||
type: eventType,
|
|
||||||
stocks: [],
|
|
||||||
tags: [],
|
|
||||||
status: 'active',
|
|
||||||
importance: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 股票和标签输入框
|
// 将自选股转换为 StockOption 格式
|
||||||
const [stockInput, setStockInput] = useState<string>('');
|
const watchlistOptions = useMemo<StockOption[]>(() => {
|
||||||
const [tagInput, setTagInput] = useState<string>('');
|
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<boolean>(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(() => {
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
if (mode === 'edit' && editingEvent) {
|
if (mode === 'edit' && editingEvent) {
|
||||||
setFormData({
|
form.setFieldsValue({
|
||||||
date: dayjs(editingEvent.event_date || editingEvent.date).format('YYYY-MM-DD'),
|
|
||||||
title: editingEvent.title,
|
title: editingEvent.title,
|
||||||
|
date: dayjs(editingEvent.event_date || editingEvent.date),
|
||||||
content: editingEvent.description || editingEvent.content || '',
|
content: editingEvent.description || editingEvent.content || '',
|
||||||
type: editingEvent.type || eventType,
|
|
||||||
stocks: editingEvent.stocks || [],
|
stocks: editingEvent.stocks || [],
|
||||||
tags: editingEvent.tags || [],
|
|
||||||
status: editingEvent.status || 'active',
|
|
||||||
importance: editingEvent.importance || 3,
|
|
||||||
});
|
});
|
||||||
// 如果是文本模式,将股票数组转为逗号分隔
|
|
||||||
if (stockInputMode === 'text' && editingEvent.stocks) {
|
|
||||||
setStockInput(editingEvent.stocks.join(','));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 新建模式,重置表单
|
// 新建模式,重置表单并预填充模板内容
|
||||||
setFormData({
|
form.resetFields();
|
||||||
date: initialDate || dayjs().format('YYYY-MM-DD'),
|
form.setFieldsValue({
|
||||||
title: '',
|
date: initialDate ? dayjs(initialDate) : dayjs(),
|
||||||
content: '',
|
|
||||||
type: eventType,
|
|
||||||
stocks: [],
|
stocks: [],
|
||||||
tags: [],
|
content: getDefaultContent(eventType),
|
||||||
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<void> => {
|
const handleSave = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
const base = getApiBase();
|
const base = getApiBase();
|
||||||
|
|
||||||
// 构建请求数据
|
// 构建请求数据
|
||||||
let requestData: Record<string, unknown> = { ...formData };
|
const requestData: Record<string, unknown> = {
|
||||||
|
title: values.title,
|
||||||
// 如果是文本模式,解析股票输入
|
content: values.content,
|
||||||
if (stockInputMode === 'text' && stockInput) {
|
date: values.date.format('YYYY-MM-DD'),
|
||||||
requestData.stocks = stockInput.split(',').map(s => s.trim()).filter(s => s);
|
type: eventType,
|
||||||
}
|
stocks: values.stocks || [],
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
// 根据 API 端点调整字段名
|
// 根据 API 端点调整字段名
|
||||||
if (apiEndpoint === 'calendar/events') {
|
if (apiEndpoint === 'calendar/events') {
|
||||||
requestData = {
|
requestData.description = values.content;
|
||||||
title: formData.title,
|
requestData.event_date = values.date.format('YYYY-MM-DD');
|
||||||
description: formData.content,
|
delete requestData.content;
|
||||||
type: formData.type,
|
delete requestData.date;
|
||||||
importance: formData.importance,
|
|
||||||
stocks: requestData.stocks,
|
|
||||||
event_date: formData.date,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = mode === 'edit' && editingEvent
|
const url = mode === 'edit' && editingEvent
|
||||||
@@ -221,274 +315,247 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
|
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
|
||||||
itemId: editingEvent?.id,
|
itemId: editingEvent?.id,
|
||||||
title: formData.title,
|
title: values.title,
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: mode === 'edit' ? '更新成功' : '创建成功',
|
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
});
|
});
|
||||||
|
message.success(mode === 'edit' ? '修改成功' : '添加成功');
|
||||||
onClose();
|
onClose();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
loadAllData();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('保存失败');
|
throw new Error('保存失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message !== '保存失败') {
|
||||||
|
// 表单验证错误,不显示额外提示
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error('EventFormModal', 'handleSave', error, {
|
logger.error('EventFormModal', 'handleSave', error, {
|
||||||
itemId: editingEvent?.id,
|
itemId: editingEvent?.id,
|
||||||
title: formData?.title
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: '保存失败',
|
|
||||||
description: '无法保存数据',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
});
|
});
|
||||||
|
message.error('保存失败,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData]);
|
||||||
|
|
||||||
// 添加股票(Tag 模式)
|
// 监听键盘快捷键 Ctrl + Enter
|
||||||
const handleAddStock = (): void => {
|
useEffect(() => {
|
||||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
setFormData({
|
if (isOpen && (e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
...formData,
|
e.preventDefault();
|
||||||
stocks: [...formData.stocks, stockInput.trim()],
|
handleSave();
|
||||||
});
|
|
||||||
setStockInput('');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 移除股票(Tag 模式)
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
const handleRemoveStock = (index: number): void => {
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
setFormData({
|
}, [isOpen, handleSave]);
|
||||||
...formData,
|
|
||||||
stocks: formData.stocks.filter((_, i) => i !== index),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加标签
|
// 插入模板
|
||||||
const handleAddTag = (): void => {
|
const handleInsertTemplate = (template: Template): void => {
|
||||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
const currentContent = form.getFieldValue('content') || '';
|
||||||
setFormData({
|
const newContent = currentContent
|
||||||
...formData,
|
? `${currentContent}\n\n${template.content}`
|
||||||
tags: [...formData.tags, tagInput.trim()],
|
: template.content;
|
||||||
});
|
form.setFieldsValue({ content: newContent });
|
||||||
setTagInput('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 移除标签
|
|
||||||
const handleRemoveTag = (index: number): void => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
tags: formData.tags.filter((_, i) => i !== index),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取标题 placeholder
|
// 获取标题 placeholder
|
||||||
const getTitlePlaceholder = (): string => {
|
const getTitlePlaceholder = (): string => {
|
||||||
switch (formData.type) {
|
if (eventType === 'plan') {
|
||||||
case 'plan':
|
return '例如:关注AI板块';
|
||||||
return '例如:布局新能源板块';
|
|
||||||
case 'review':
|
|
||||||
return '例如:本周操作复盘';
|
|
||||||
case 'reminder':
|
|
||||||
return '例如:关注半导体板块';
|
|
||||||
case 'analysis':
|
|
||||||
return '例如:行业分析任务';
|
|
||||||
default:
|
|
||||||
return '请输入标题';
|
|
||||||
}
|
}
|
||||||
|
return '例如:12月操作总结';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取内容 placeholder
|
// 获取内容 placeholder
|
||||||
const getContentPlaceholder = (): string => {
|
const getContentPlaceholder = (): string => {
|
||||||
switch (formData.type) {
|
if (eventType === 'plan') {
|
||||||
case 'plan':
|
return '计划模板:\n目标:\n策略:\n风险控制:\n时间规划:';
|
||||||
return '详细描述您的投资计划...';
|
|
||||||
case 'review':
|
|
||||||
return '详细记录您的投资复盘...';
|
|
||||||
default:
|
|
||||||
return '详细描述...';
|
|
||||||
}
|
}
|
||||||
|
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' }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
<span style={{ marginLeft: 8 }}>加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : '暂无结果',
|
||||||
|
options: stockOptions,
|
||||||
|
onFocus: () => {
|
||||||
|
if (stockOptions.length === 0) {
|
||||||
|
setStockOptions(watchlistOptions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tagRender: (props) => {
|
||||||
|
const { label: tagLabel, closable, onClose: onTagClose } = props;
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color="blue"
|
||||||
|
closable={closable}
|
||||||
|
onClose={onTagClose}
|
||||||
|
style={{ marginRight: 3 }}
|
||||||
|
>
|
||||||
|
{tagLabel}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
popupRender: (menu) => (
|
||||||
|
<>
|
||||||
|
{isShowingWatchlist && watchlistOptions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '4px 8px 0' }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#999' }}>
|
||||||
|
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} />
|
||||||
|
我的自选股
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '4px 0 0' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{menu}
|
||||||
|
{!isShowingWatchlist && searchText && (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
<div style={{ padding: '0 8px 4px' }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#999' }}>
|
||||||
|
<BulbOutlined style={{ marginRight: 4 }} />
|
||||||
|
搜索结果(输入代码或名称)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取按钮文案
|
||||||
|
const getButtonText = (): string => {
|
||||||
|
if (mode === 'edit') {
|
||||||
|
return eventType === 'plan' ? '更新计划' : '更新复盘';
|
||||||
|
}
|
||||||
|
return eventType === 'plan' ? '创建计划' : '创建复盘';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size={{ base: 'full', md: 'xl' }} closeOnOverlayClick={false} closeOnEsc={true} scrollBehavior="inside">
|
<Modal
|
||||||
<ModalOverlay />
|
title={`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
||||||
<ModalContent mx={{ base: 0, md: 4 }} my={{ base: 0, md: 16 }} borderRadius={{ base: 0, md: 'md' }}>
|
open={isOpen}
|
||||||
<ModalHeader>
|
onCancel={onClose}
|
||||||
{mode === 'edit' ? '编辑' : '新建'}投资{label}
|
width={600}
|
||||||
</ModalHeader>
|
destroyOnClose
|
||||||
<ModalCloseButton />
|
maskClosable={true}
|
||||||
<ModalBody>
|
keyboard
|
||||||
<VStack spacing={4}>
|
className="event-form-modal"
|
||||||
{/* 日期选择器 */}
|
footer={
|
||||||
{showDatePicker && (
|
<div className="modal-footer">
|
||||||
<FormControl isRequired>
|
|
||||||
<FormLabel>日期</FormLabel>
|
|
||||||
<InputGroup>
|
|
||||||
<InputLeftElement pointerEvents="none">
|
|
||||||
<Icon as={FiCalendar} color={secondaryText} />
|
|
||||||
</InputLeftElement>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.date}
|
|
||||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 标题 */}
|
|
||||||
<FormControl isRequired>
|
|
||||||
<FormLabel>标题</FormLabel>
|
|
||||||
<Input
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
placeholder={getTitlePlaceholder()}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{/* 内容/描述 */}
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>内容</FormLabel>
|
|
||||||
<Textarea
|
|
||||||
value={formData.content}
|
|
||||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
|
||||||
placeholder={getContentPlaceholder()}
|
|
||||||
rows={6}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{/* 类型选择 */}
|
|
||||||
{showTypeSelect && (
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>类型</FormLabel>
|
|
||||||
<Select
|
|
||||||
value={formData.type}
|
|
||||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as EventType })}
|
|
||||||
>
|
|
||||||
<option value="plan">投资计划</option>
|
|
||||||
<option value="review">投资复盘</option>
|
|
||||||
<option value="reminder">提醒事项</option>
|
|
||||||
<option value="analysis">分析任务</option>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 状态选择 */}
|
|
||||||
{showStatusSelect && (
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>状态</FormLabel>
|
|
||||||
<Select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
|
||||||
>
|
|
||||||
<option value="active">进行中</option>
|
|
||||||
<option value="completed">已完成</option>
|
|
||||||
<option value="cancelled">已取消</option>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 重要度选择 */}
|
|
||||||
{showImportance && (
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>重要度</FormLabel>
|
|
||||||
<Select
|
|
||||||
value={formData.importance}
|
|
||||||
onChange={(e) => setFormData({ ...formData, importance: parseInt(e.target.value) })}
|
|
||||||
>
|
|
||||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
|
||||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
|
||||||
<option value={3}>⭐⭐⭐ 一般</option>
|
|
||||||
<option value={2}>⭐⭐ 次要</option>
|
|
||||||
<option value={1}>⭐ 不重要</option>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 股票输入 - Tag 模式 */}
|
|
||||||
{stockInputMode === 'tag' && (
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>相关股票</FormLabel>
|
|
||||||
<HStack>
|
|
||||||
<Input
|
|
||||||
value={stockInput}
|
|
||||||
onChange={(e) => setStockInput(e.target.value)}
|
|
||||||
placeholder="输入股票代码"
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
|
||||||
/>
|
|
||||||
<Button onClick={handleAddStock}>添加</Button>
|
|
||||||
</HStack>
|
|
||||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
|
||||||
{formData.stocks.map((stock, idx) => (
|
|
||||||
<Tag key={idx} size="sm" colorScheme="blue">
|
|
||||||
<TagLeftIcon as={FiTrendingUp} />
|
|
||||||
<TagLabel>{stock}</TagLabel>
|
|
||||||
<TagCloseButton onClick={() => handleRemoveStock(idx)} />
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 股票输入 - 文本模式 */}
|
|
||||||
{stockInputMode === 'text' && (
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
|
||||||
<Input
|
|
||||||
value={stockInput}
|
|
||||||
onChange={(e) => setStockInput(e.target.value)}
|
|
||||||
placeholder="例如:600519,000858,002415"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 标签输入 */}
|
|
||||||
{showTags && (
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>标签</FormLabel>
|
|
||||||
<HStack>
|
|
||||||
<Input
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
|
||||||
placeholder="输入标签"
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
|
||||||
/>
|
|
||||||
<Button onClick={handleAddTag}>添加</Button>
|
|
||||||
</HStack>
|
|
||||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
|
||||||
{formData.tags.map((tag, idx) => (
|
|
||||||
<Tag key={idx} size="sm" colorScheme={colorScheme}>
|
|
||||||
<TagLeftIcon as={FiHash} />
|
|
||||||
<TagLabel>{tag}</TagLabel>
|
|
||||||
<TagCloseButton onClick={() => handleRemoveTag(idx)} />
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
colorScheme={colorScheme}
|
type="primary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
isDisabled={!formData.title || (showDatePicker && !formData.date)}
|
loading={saving}
|
||||||
isLoading={saving}
|
disabled={saving}
|
||||||
leftIcon={<FiSave />}
|
|
||||||
>
|
>
|
||||||
保存
|
{getButtonText()}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</div>
|
||||||
</ModalContent>
|
}
|
||||||
|
>
|
||||||
|
<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 }}>标题 <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入标题' },
|
||||||
|
{ max: 50, message: '标题不能超过50个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={getTitlePlaceholder()}
|
||||||
|
maxLength={50}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 日期 */}
|
||||||
|
<Form.Item
|
||||||
|
name="date"
|
||||||
|
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||||
|
rules={[{ required: true, message: '请选择日期' }]}
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
placeholder="选择日期"
|
||||||
|
allowClear={false}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 描述/内容 - 上下布局 */}
|
||||||
|
<Form.Item
|
||||||
|
name="content"
|
||||||
|
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#ff4d4f' }}>*</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' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Space wrap size="small" className="template-buttons">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Button
|
||||||
|
key={template.label}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleInsertTemplate(template)}
|
||||||
|
>
|
||||||
|
{template.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关联股票 */}
|
||||||
|
<Form.Item
|
||||||
|
name="stocks"
|
||||||
|
label={<span style={{ fontWeight: 600 }}>关联股票</span>}
|
||||||
|
>
|
||||||
|
<Select {...selectProps} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>}
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,185 +8,25 @@
|
|||||||
* - label: 显示文案
|
* - label: 显示文案
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, memo, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Badge,
|
|
||||||
IconButton,
|
|
||||||
Flex,
|
|
||||||
Grid,
|
Grid,
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
|
||||||
Text,
|
Text,
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Icon,
|
Icon,
|
||||||
Tag,
|
|
||||||
TagLabel,
|
|
||||||
TagLeftIcon,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import { FiFileText } from 'react-icons/fi';
|
||||||
FiEdit2,
|
|
||||||
FiTrash2,
|
|
||||||
FiFileText,
|
|
||||||
FiCalendar,
|
|
||||||
FiTrendingUp,
|
|
||||||
FiHash,
|
|
||||||
FiCheckCircle,
|
|
||||||
FiXCircle,
|
|
||||||
FiAlertCircle,
|
|
||||||
} from 'react-icons/fi';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import 'dayjs/locale/zh-cn';
|
|
||||||
|
|
||||||
import { usePlanningData } from './PlanningContext';
|
import { usePlanningData } from './PlanningContext';
|
||||||
import { EventFormModal } from './EventFormModal';
|
import { EventFormModal } from './EventFormModal';
|
||||||
import type { InvestmentEvent, EventStatus } from '@/types';
|
import { EventCard } from './EventCard';
|
||||||
|
import type { InvestmentEvent } from '@/types';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { getApiBase } from '@/utils/apiConfig';
|
import { getApiBase } from '@/utils/apiConfig';
|
||||||
|
|
||||||
dayjs.locale('zh-cn');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 状态信息接口
|
|
||||||
*/
|
|
||||||
interface StatusInfo {
|
|
||||||
icon: React.ComponentType;
|
|
||||||
color: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取状态信息
|
|
||||||
*/
|
|
||||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
|
||||||
case 'cancelled':
|
|
||||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
|
||||||
default:
|
|
||||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EventCard Props
|
|
||||||
*/
|
|
||||||
interface EventCardProps {
|
|
||||||
item: InvestmentEvent;
|
|
||||||
colorScheme: string;
|
|
||||||
label: string;
|
|
||||||
textColor: string;
|
|
||||||
secondaryText: string;
|
|
||||||
cardBg: string;
|
|
||||||
onEdit: (item: InvestmentEvent) => void;
|
|
||||||
onDelete: (id: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EventCard 组件(使用 memo 优化渲染性能)
|
|
||||||
*/
|
|
||||||
const EventCard = memo<EventCardProps>(({
|
|
||||||
item,
|
|
||||||
colorScheme,
|
|
||||||
label,
|
|
||||||
textColor,
|
|
||||||
secondaryText,
|
|
||||||
cardBg,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}) => {
|
|
||||||
const statusInfo = getStatusInfo(item.status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
bg={cardBg}
|
|
||||||
shadow="sm"
|
|
||||||
_hover={{ shadow: 'md' }}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
|
||||||
<CardBody p={{ base: 2, md: 3 }}>
|
|
||||||
<VStack align="stretch" spacing={{ base: 2, md: 3 }}>
|
|
||||||
<Flex justify="space-between" align="start">
|
|
||||||
<VStack align="start" spacing={1} flex={1}>
|
|
||||||
<HStack spacing={{ base: 1, md: 2 }}>
|
|
||||||
<Icon as={FiFileText} color={`${colorScheme}.500`} boxSize={{ base: 4, md: 5 }} />
|
|
||||||
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
|
|
||||||
{item.title}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={{ base: 1, md: 2 }}>
|
|
||||||
<Icon as={FiCalendar} boxSize={{ base: 2.5, md: 3 }} color={secondaryText} />
|
|
||||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={secondaryText}>
|
|
||||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
|
||||||
</Text>
|
|
||||||
<Badge
|
|
||||||
colorScheme={statusInfo.color}
|
|
||||||
variant="subtle"
|
|
||||||
fontSize={{ base: 'xs', md: 'sm' }}
|
|
||||||
>
|
|
||||||
{statusInfo.text}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
<HStack spacing={{ base: 0, md: 1 }}>
|
|
||||||
<IconButton
|
|
||||||
icon={<FiEdit2 />}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => onEdit(item)}
|
|
||||||
aria-label={`编辑${label}`}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={<FiTrash2 />}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
colorScheme="red"
|
|
||||||
onClick={() => onDelete(item.id)}
|
|
||||||
aria-label={`删除${label}`}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{(item.content || item.description) && (
|
|
||||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={textColor} noOfLines={3}>
|
|
||||||
{item.content || item.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap" gap={1}>
|
|
||||||
{item.stocks && item.stocks.length > 0 && (
|
|
||||||
<>
|
|
||||||
{item.stocks.map((stock, idx) => (
|
|
||||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
|
||||||
<TagLeftIcon as={FiTrendingUp} />
|
|
||||||
<TagLabel>{stock}</TagLabel>
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{item.tags && item.tags.length > 0 && (
|
|
||||||
<>
|
|
||||||
{item.tags.map((tag, idx) => (
|
|
||||||
<Tag key={idx} size="sm" colorScheme={colorScheme} variant="subtle">
|
|
||||||
<TagLeftIcon as={FiHash} />
|
|
||||||
<TagLabel>{tag}</TagLabel>
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
EventCard.displayName = 'EventCard';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventPanel Props
|
* EventPanel Props
|
||||||
*/
|
*/
|
||||||
@@ -314,7 +154,8 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
{events.map(event => (
|
{events.map(event => (
|
||||||
<EventCard
|
<EventCard
|
||||||
key={event.id}
|
key={event.id}
|
||||||
item={event}
|
event={event}
|
||||||
|
variant="list"
|
||||||
colorScheme={colorScheme}
|
colorScheme={colorScheme}
|
||||||
label={label}
|
label={label}
|
||||||
textColor={textColor}
|
textColor={textColor}
|
||||||
@@ -336,14 +177,7 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
eventType={type}
|
eventType={type}
|
||||||
editingEvent={editingItem}
|
editingEvent={editingItem}
|
||||||
onSuccess={loadAllData}
|
onSuccess={loadAllData}
|
||||||
colorScheme={colorScheme}
|
|
||||||
label={label}
|
label={label}
|
||||||
showDatePicker={true}
|
|
||||||
showTypeSelect={false}
|
|
||||||
showStatusSelect={true}
|
|
||||||
showImportance={false}
|
|
||||||
showTags={true}
|
|
||||||
stockInputMode="tag"
|
|
||||||
apiEndpoint="investment-plans"
|
apiEndpoint="investment-plans"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user