refactor(Planning): 投资规划中心重构为 Redux 状态管理
- 新增 planningSlice 管理计划/复盘数据 - InvestmentPlanningCenter 改用 Redux 而非本地 state - 列表和日历视图共享同一数据源,保持同步 - 优化 Mock handlers,改进事件 ID 生成和调试日志 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,8 @@ import type { EventClickArg } from '@fullcalendar/core';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { selectAllEvents } from '@/store/slices/planningSlice';
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { EventDetailModal } from './EventDetailModal';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
@@ -53,8 +55,11 @@ interface CalendarEvent {
|
||||
* 日历视图面板,显示所有投资事件
|
||||
*/
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
// 从 Redux 获取数据(确保与列表视图同步)
|
||||
const allEvents = useAppSelector(selectAllEvents);
|
||||
|
||||
// UI 相关状态仍从 Context 获取
|
||||
const {
|
||||
allEvents,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
setViewMode,
|
||||
@@ -143,11 +148,33 @@ export const CalendarPanel: React.FC = () => {
|
||||
opacity: '1 !important',
|
||||
},
|
||||
},
|
||||
// 星期头(周日、周一等)- 金色
|
||||
'.fc-col-header-cell-cushion': {
|
||||
color: '#D4AF37 !important',
|
||||
fontWeight: '600 !important',
|
||||
fontSize: '14px',
|
||||
},
|
||||
// 日期数字 - 增强可见性
|
||||
'.fc-daygrid-day-number': {
|
||||
color: 'rgba(255, 255, 255, 0.9) !important',
|
||||
fontWeight: '600 !important',
|
||||
fontSize: '14px !important',
|
||||
padding: '4px 8px !important',
|
||||
},
|
||||
// 非当前月份日期(灰色)
|
||||
'.fc-day-other .fc-daygrid-day-number': {
|
||||
color: 'rgba(255, 255, 255, 0.35) !important',
|
||||
},
|
||||
// 今天日期高亮边框
|
||||
'.fc-daygrid-day.fc-day-today': {
|
||||
border: '2px solid #D4AF37 !important',
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.1) !important',
|
||||
},
|
||||
// 今天日期数字 - 金色
|
||||
'.fc-daygrid-day.fc-day-today .fc-daygrid-day-number': {
|
||||
color: '#D4AF37 !important',
|
||||
fontWeight: '700 !important',
|
||||
},
|
||||
// 标题金色渐变
|
||||
'.fc .fc-toolbar-title': {
|
||||
background: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
|
||||
|
||||
@@ -36,7 +36,7 @@ import 'dayjs/locale/zh-cn';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { fetchAllEvents } from '@/store/slices/planningSlice';
|
||||
import './EventFormModal.less';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
@@ -186,7 +186,6 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
label = '事件',
|
||||
apiEndpoint = 'investment-plans',
|
||||
}) => {
|
||||
const { loadAllData } = usePlanningData();
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm<FormData>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -336,7 +335,8 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
message.success(mode === 'edit' ? '修改成功' : '添加成功');
|
||||
onClose();
|
||||
onSuccess();
|
||||
loadAllData();
|
||||
// 使用 Redux 刷新数据,确保列表和日历同步
|
||||
dispatch(fetchAllEvents());
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
@@ -352,7 +352,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]);
|
||||
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, dispatch, allStocks, watchlist]);
|
||||
|
||||
// 监听键盘快捷键 Ctrl + Enter
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* EventPanel - 通用事件面板组件
|
||||
* EventPanel - 通用事件面板组件 (Redux 版本)
|
||||
* 用于显示、编辑和管理投资计划或复盘
|
||||
*
|
||||
* 通过 props 配置差异化行为:
|
||||
@@ -17,15 +17,22 @@ import {
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiFileText } from 'react-icons/fi';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import {
|
||||
fetchAllEvents,
|
||||
deleteEvent,
|
||||
selectPlans,
|
||||
selectReviews,
|
||||
selectPlanningLoading,
|
||||
} from '@/store/slices/planningSlice';
|
||||
import { EventFormModal } from './EventFormModal';
|
||||
import { FUIEventCard } from './FUIEventCard';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
/**
|
||||
* EventPanel Props
|
||||
@@ -51,15 +58,16 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
label,
|
||||
openModalTrigger,
|
||||
}) => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
} = usePlanningData();
|
||||
const dispatch = useAppDispatch();
|
||||
const toast = useToast();
|
||||
|
||||
// Redux 状态
|
||||
const plans = useAppSelector(selectPlans);
|
||||
const reviews = useAppSelector(selectReviews);
|
||||
const loading = useAppSelector(selectPlanningLoading);
|
||||
|
||||
// 根据类型选择事件列表
|
||||
const events = type === 'plan' ? plans : reviews;
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
@@ -69,9 +77,6 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
|
||||
const prevTriggerRef = useRef<number>(openModalTrigger || 0);
|
||||
|
||||
// 筛选事件列表(按类型过滤,排除系统事件)
|
||||
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
|
||||
|
||||
// 监听外部触发打开新建模态框(修复 bug:只在值变化时触发)
|
||||
useEffect(() => {
|
||||
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
|
||||
@@ -99,27 +104,18 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
// 删除数据 - 使用 Redux action
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
await dispatch(deleteEvent(id)).unwrap();
|
||||
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
@@ -130,11 +126,19 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新数据
|
||||
const handleRefresh = useCallback(() => {
|
||||
dispatch(fetchAllEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// 使用 useCallback 优化回调函数
|
||||
const handleEdit = useCallback((item: InvestmentEvent) => {
|
||||
handleOpenModal(item);
|
||||
}, []);
|
||||
|
||||
// 颜色主题
|
||||
const secondaryText = 'rgba(255, 255, 255, 0.6)';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
@@ -172,7 +176,7 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
mode={modalMode}
|
||||
eventType={type}
|
||||
editingEvent={editingItem}
|
||||
onSuccess={loadAllData}
|
||||
onSuccess={handleRefresh}
|
||||
label={label}
|
||||
apiEndpoint="investment-plans"
|
||||
/>
|
||||
|
||||
@@ -124,22 +124,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 星期头
|
||||
// 星期头(周日、周一等)
|
||||
.fc-col-header-cell-cushion {
|
||||
color: @text-secondary;
|
||||
font-weight: 500;
|
||||
color: @gold-400 !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// 日期数字
|
||||
// 日期数字(1日、2日等)- 增强可见性
|
||||
.fc-daygrid-day-number {
|
||||
color: @text-primary;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 14px !important;
|
||||
padding: 4px 8px !important;
|
||||
}
|
||||
|
||||
// 当前月份的日期更亮
|
||||
.fc-day.fc-day-future .fc-daygrid-day-number,
|
||||
.fc-day.fc-day-past .fc-daygrid-day-number {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
// 非当前月份的日期(灰色)
|
||||
.fc-day-other .fc-daygrid-day-number {
|
||||
color: rgba(255, 255, 255, 0.35) !important;
|
||||
}
|
||||
|
||||
// 今天高亮
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: @today-bg !important;
|
||||
border: 2px solid @gold-400 !important;
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
color: @gold-400 !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 事件样式
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
|
||||
* InvestmentPlanningCenter - 投资规划中心主组件 (Redux 版本)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||
* - 使用 TypeScript 提供类型安全
|
||||
* 使用 Redux 管理数据,确保列表和日历视图数据同步
|
||||
*
|
||||
* 组件架构:
|
||||
* - InvestmentPlanningCenter (主组件)
|
||||
* - CalendarPanel (日历面板,懒加载)
|
||||
* - EventPanel (通用事件面板,用于计划和复盘)
|
||||
* - PlanningContext (数据共享层)
|
||||
* - PlanningContext (UI 状态共享)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, Suspense, lazy } from 'react';
|
||||
import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -30,7 +28,6 @@ import {
|
||||
Center,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
@@ -42,9 +39,15 @@ import { Target } from 'lucide-react';
|
||||
import GlassCard from '@components/GlassCard';
|
||||
|
||||
import { PlanningDataProvider } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import type { PlanningContextValue } from '@/types';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import {
|
||||
fetchAllEvents,
|
||||
selectAllEvents,
|
||||
selectPlanningLoading,
|
||||
selectPlans,
|
||||
selectReviews,
|
||||
} from '@/store/slices/planningSlice';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
// 懒加载子面板组件(实现代码分割)
|
||||
@@ -68,64 +71,45 @@ const PanelLoadingFallback: React.FC = () => (
|
||||
* InvestmentPlanningCenter 主组件
|
||||
*/
|
||||
const InvestmentPlanningCenter: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const toast = useToast();
|
||||
|
||||
// Redux 状态
|
||||
const allEvents = useAppSelector(selectAllEvents);
|
||||
const loading = useAppSelector(selectPlanningLoading);
|
||||
const plans = useAppSelector(selectPlans);
|
||||
const reviews = useAppSelector(selectReviews);
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 全局数据状态
|
||||
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
// UI 状态
|
||||
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('list');
|
||||
const [listTab, setListTab] = useState<number>(0); // 0: 我的计划, 1: 我的复盘
|
||||
const [openPlanModalTrigger, setOpenPlanModalTrigger] = useState<number>(0);
|
||||
const [openReviewModalTrigger, setOpenReviewModalTrigger] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
||||
*/
|
||||
const loadAllData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllEvents(data.data || []);
|
||||
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
dispatch(fetchAllEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// 提供给子组件的 Context 值(使用 useMemo 缓存,避免子组件不必要的重渲染)
|
||||
// 刷新数据的方法(供子组件调用)
|
||||
const loadAllData = async (): Promise<void> => {
|
||||
await dispatch(fetchAllEvents());
|
||||
};
|
||||
|
||||
// 提供给子组件的 Context 值
|
||||
const contextValue: PlanningContextValue = useMemo(
|
||||
() => ({
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
setAllEvents: () => {}, // Redux 管理,不需要 setter
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
setLoading: () => {}, // Redux 管理,不需要 setter
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
@@ -138,7 +122,6 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
}),
|
||||
[
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
@@ -150,15 +133,6 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
]
|
||||
);
|
||||
|
||||
// 计算各类型事件数量(使用 useMemo 缓存,避免每次渲染重复遍历数组)
|
||||
const { planCount, reviewCount } = useMemo(
|
||||
() => ({
|
||||
planCount: allEvents.filter(e => e.type === 'plan').length,
|
||||
reviewCount: allEvents.filter(e => e.type === 'review').length,
|
||||
}),
|
||||
[allEvents]
|
||||
);
|
||||
|
||||
// 金色主题色
|
||||
const goldAccent = 'rgba(212, 175, 55, 0.9)';
|
||||
|
||||
@@ -245,7 +219,7 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Box as={Target} boxSize={{ base: 3, md: 4 }} mr={1} />
|
||||
我的计划 ({planCount})
|
||||
我的计划 ({plans.length})
|
||||
</Tab>
|
||||
<Tab
|
||||
fontSize={{ base: '11px', md: 'sm' }}
|
||||
@@ -260,7 +234,7 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
|
||||
我的复盘 ({reviewCount})
|
||||
我的复盘 ({reviews.length})
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user