refactor(Center): 重构投资规划中心日历与事件管理

This commit is contained in:
zdl
2025-12-23 17:44:35 +08:00
parent 39fb70a1eb
commit 12a57f2fa2
11 changed files with 764 additions and 234 deletions

View File

@@ -215,6 +215,14 @@ const planningSlice = createSlice({
state.allEvents = state.allEvents.filter(e => e.id !== action.payload);
state.lastUpdated = Date.now();
},
/** 乐观更新事件(编辑时使用) */
optimisticUpdateEvent: (state, action: PayloadAction<InvestmentEvent>) => {
const index = state.allEvents.findIndex(e => e.id === action.payload.id);
if (index !== -1) {
state.allEvents[index] = action.payload;
state.lastUpdated = Date.now();
}
},
},
extraReducers: (builder) => {
builder
@@ -277,6 +285,7 @@ export const {
optimisticAddEvent,
replaceEvent,
removeEvent,
optimisticUpdateEvent,
} = planningSlice.actions;
export default planningSlice.reducer;

View File

@@ -1,6 +1,8 @@
/**
* CalendarPanel - 投资日历面板组件
* 使用 FullCalendar 展示投资计划、复盘等事件
* 使用 Ant Design Calendar 展示投资计划、复盘等事件
*
* 聚合展示模式:每个日期格子显示各类型事件的汇总信息
*/
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
@@ -14,12 +16,8 @@ import {
ModalCloseButton,
Spinner,
Center,
VStack,
} from '@chakra-ui/react';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { DateClickArg } from '@fullcalendar/interaction';
import type { EventClickArg } from '@fullcalendar/core';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
@@ -28,7 +26,15 @@ import { selectAllEvents } from '@/store/slices/planningSlice';
import { usePlanningData } from './PlanningContext';
import { EventDetailModal } from './EventDetailModal';
import type { InvestmentEvent } from '@/types';
import './InvestmentCalendar.less';
// 使用新的公共日历组件
import {
BaseCalendar,
CalendarEventBlock,
type CellRenderInfo,
type CalendarEvent,
CALENDAR_COLORS,
} from '@components/Calendar';
// 懒加载投资日历组件
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
@@ -36,18 +42,12 @@ const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
dayjs.locale('zh-cn');
/**
* FullCalendar 事件类型
* 事件聚合信息(用于日历格子显示)
*/
interface CalendarEvent {
id: string;
title: string;
start: string;
date: string;
backgroundColor: string;
borderColor: string;
extendedProps: InvestmentEvent & {
isSystem: boolean;
};
interface EventSummary {
plans: InvestmentEvent[];
reviews: InvestmentEvent[];
systems: InvestmentEvent[];
}
/**
@@ -72,140 +72,123 @@ export const CalendarPanel: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
const [initialFilter, setInitialFilter] = useState<'all' | 'plan' | 'review' | 'system'>('all');
// 转换数据为 FullCalendar 格式(使用 useMemo 缓存
// 黑金主题色:计划用金色,复盘用青金色,系统事件用蓝色
const calendarEvents: CalendarEvent[] = useMemo(() =>
allEvents.map(event => ({
...event,
id: `${event.source || 'user'}-${event.id}`,
title: event.title,
start: event.event_date,
date: event.event_date,
backgroundColor: event.source === 'future' ? '#3B82F6' : event.type === 'plan' ? '#D4AF37' : '#10B981',
borderColor: event.source === 'future' ? '#3B82F6' : event.type === 'plan' ? '#D4AF37' : '#10B981',
extendedProps: {
...event,
isSystem: event.source === 'future',
// 按日期分组事件(用于聚合展示
const eventsByDate = useMemo(() => {
const map: Record<string, EventSummary> = {};
allEvents.forEach(event => {
const dateStr = event.event_date;
if (!map[dateStr]) {
map[dateStr] = { plans: [], reviews: [], systems: [] };
}
})), [allEvents]);
if (event.source === 'future') {
map[dateStr].systems.push(event);
} else if (event.type === 'plan') {
map[dateStr].plans.push(event);
} else if (event.type === 'review') {
map[dateStr].reviews.push(event);
}
});
return map;
}, [allEvents]);
// 抽取公共的打开事件详情函数
const openEventDetail = useCallback((date: Date | null): void => {
if (!date) return;
const clickedDate = dayjs(date);
setSelectedDate(clickedDate);
// 将事件摘要转换为 CalendarEvent 格式
const getCalendarEvents = useCallback((dateStr: string): CalendarEvent[] => {
const summary = eventsByDate[dateStr];
if (!summary) return [];
const events: CalendarEvent[] = [];
// 系统事件
if (summary.systems.length > 0) {
events.push({
id: `${dateStr}-system`,
type: 'system',
title: summary.systems[0]?.title || '',
date: dateStr,
count: summary.systems.length,
});
}
// 计划
if (summary.plans.length > 0) {
events.push({
id: `${dateStr}-plan`,
type: 'plan',
title: summary.plans[0]?.title || '',
date: dateStr,
count: summary.plans.length,
});
}
// 复盘
if (summary.reviews.length > 0) {
events.push({
id: `${dateStr}-review`,
type: 'review',
title: summary.reviews[0]?.title || '',
date: dateStr,
count: summary.reviews.length,
});
}
return events;
}, [eventsByDate]);
// 处理日期选择(点击日期空白区域)
const handleDateSelect = useCallback((date: Dayjs): void => {
setSelectedDate(date);
const dayEvents = allEvents.filter(event =>
dayjs(event.event_date).isSame(clickedDate, 'day')
dayjs(event.event_date).isSame(date, 'day')
);
setSelectedDateEvents(dayEvents);
setInitialFilter('all'); // 点击日期时显示全部
setIsDetailModalOpen(true);
}, [allEvents]);
// 处理日期点击
const handleDateClick = useCallback((info: DateClickArg): void => {
openEventDetail(info.date);
}, [openEventDetail]);
// 处理事件点击(点击具体事件类型)
const handleEventClick = useCallback((event: CalendarEvent): void => {
const date = dayjs(event.date);
setSelectedDate(date);
// 处理事件点击
const handleEventClick = useCallback((info: EventClickArg): void => {
openEventDetail(info.event.start);
}, [openEventDetail]);
const dayEvents = allEvents.filter(e =>
dayjs(e.event_date).isSame(date, 'day')
);
setSelectedDateEvents(dayEvents);
setInitialFilter(event.type as 'plan' | 'review' | 'system'); // 定位到对应 Tab
setIsDetailModalOpen(true);
}, [allEvents]);
// 自定义日期格子内容渲染
const renderCellContent = useCallback((date: Dayjs, _info: CellRenderInfo) => {
const dateStr = date.format('YYYY-MM-DD');
const events = getCalendarEvents(dateStr);
if (events.length === 0) return null;
return (
<VStack spacing={0} align="stretch" w="100%" mt={1}>
<CalendarEventBlock
events={events}
maxDisplay={3}
compact
onEventClick={handleEventClick}
/>
</VStack>
);
}, [getCalendarEvents, handleEventClick]);
return (
<Box>
<Box
height={{ base: '380px', md: '560px' }}
sx={{
// FullCalendar CSS Variables 覆盖(黑金主题)
// FullCalendar v6 使用 CSS-in-JS必须通过 CSS Variables 覆盖
'--fc-button-text-color': '#0A0A14',
'--fc-button-bg-color': '#D4AF37',
'--fc-button-border-color': '#D4AF37',
'--fc-button-hover-bg-color': '#B8960C',
'--fc-button-hover-border-color': '#B8960C',
'--fc-button-active-bg-color': '#B8960C',
'--fc-button-active-border-color': '#B8960C',
'--fc-today-bg-color': 'rgba(212, 175, 55, 0.1)',
'--fc-border-color': 'rgba(212, 175, 55, 0.2)',
'--fc-page-bg-color': 'transparent',
'--fc-neutral-bg-color': 'rgba(212, 175, 55, 0.05)',
// 直接样式覆盖作为后备(提高特异性)
'.fc .fc-button': {
backgroundColor: '#D4AF37 !important',
borderColor: '#D4AF37 !important',
color: '#0A0A14 !important',
fontWeight: '600 !important',
'&:hover': {
backgroundColor: '#B8960C !important',
borderColor: '#B8960C !important',
},
'&:disabled': {
backgroundColor: '#B8960C !important',
borderColor: '#B8960C !important',
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%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
fontWeight: '700',
},
}}
>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: ''
}}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
{/* 日历容器 - 使用 BaseCalendar */}
<Box height={{ base: '420px', md: '600px' }}>
<BaseCalendar
onSelect={handleDateSelect}
cellRender={renderCellContent}
height="100%"
dayMaxEvents={1}
moreLinkText="+更多"
buttonText={{
today: '今天',
month: '月',
week: '周'
}}
titleFormat={{ year: 'numeric', month: 'long' }}
showToolbar={true}
/>
</Box>
@@ -217,6 +200,7 @@ export const CalendarPanel: React.FC = () => {
events={selectedDateEvents}
borderColor={borderColor}
secondaryText={secondaryText}
initialFilter={initialFilter}
onNavigateToPlan={() => {
setViewMode('list');
setListTab(0);
@@ -249,7 +233,6 @@ export const CalendarPanel: React.FC = () => {
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -1,7 +1,8 @@
/* EventDetailModal.less - 事件详情弹窗黑金主题样式 */
// ==================== 变量定义 ====================
@color-bg-deep: #0A0A14;
// 与 GlassCard transparent 变体保持一致
@color-bg-deep: rgba(15, 15, 26, 0.95);
@color-bg-primary: #0F0F1A;
@color-bg-elevated: #1A1A2E;
@@ -27,11 +28,10 @@
.event-detail-modal {
// Modal 整体
.ant-modal-content {
background: linear-gradient(135deg, @color-bg-elevated 0%, @color-bg-primary 100%) !important;
background: @color-bg-deep !important;
border: 1px solid @color-line-default;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1);
backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 24px rgba(212, 175, 55, 0.08);
}
// Modal 头部
@@ -68,3 +68,61 @@
background: transparent;
}
}
// ==================== 响应式适配 ====================
@media (max-width: 768px) {
.event-detail-modal {
.ant-modal-content {
border-radius: 0;
min-height: 100vh;
}
.ant-modal-header {
padding: 12px 16px;
}
.ant-modal-title {
font-size: 16px;
}
.ant-modal-body {
padding: 12px 16px 20px;
}
}
.event-detail-modal-root {
.ant-modal {
max-width: 100vw !important;
margin: 0 !important;
top: 0 !important;
padding: 0 !important;
}
.ant-modal-centered .ant-modal {
top: 0 !important;
}
}
}
// ==================== 滚动条样式(备用,已在组件内定义) ====================
.event-detail-modal {
// 自定义滚动条
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.3);
border-radius: 3px;
transition: background 0.2s ease;
&:hover {
background: rgba(212, 175, 55, 0.5);
}
}
}

View File

@@ -1,18 +1,94 @@
/**
* EventDetailModal - 事件详情弹窗组件
* 用于展示某一天的所有投资事件
* 使用 Ant Design 实现
*
* 功能:
* - Tab 筛选(全部/计划/复盘/系统)
* - 两列网格布局
* - 响应式宽度
*/
import React from 'react';
import { Modal, Space } from 'antd';
import React, { useState, useMemo } from 'react';
import { Modal } from 'antd';
import {
Box,
Grid,
HStack,
Button,
Text,
Badge,
Icon,
useBreakpointValue,
} from '@chakra-ui/react';
import { Target, Heart, Calendar, LayoutGrid } from 'lucide-react';
import type { Dayjs } from 'dayjs';
import { EventCard } from './EventCard';
import { FUIEventCard } from './FUIEventCard';
import { EventEmptyState } from './EventEmptyState';
import type { InvestmentEvent } from '@/types';
import './EventDetailModal.less';
/**
* 筛选类型
*/
type FilterType = 'all' | 'plan' | 'review' | 'system';
/**
* 筛选器配置
*/
interface FilterOption {
key: FilterType;
label: string;
icon?: React.ElementType;
color: string;
}
const FILTER_OPTIONS: FilterOption[] = [
{
key: 'all',
label: '全部',
icon: LayoutGrid,
color: 'rgba(255, 255, 255, 0.8)',
},
{
key: 'plan',
label: '计划',
icon: Target,
color: '#D4AF37',
},
{
key: 'review',
label: '复盘',
icon: Heart,
color: '#10B981',
},
{
key: 'system',
label: '系统',
icon: Calendar,
color: '#3B82F6',
},
];
/**
* 根据 hex 颜色生成 rgba 颜色
*/
const hexToRgba = (hex: string, alpha: number): string => {
// 处理 rgba 格式
if (hex.startsWith('rgba')) {
return hex;
}
// 处理 hex 格式
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return hex;
};
/**
* EventDetailModal Props
*/
@@ -35,8 +111,27 @@ export interface EventDetailModalProps {
onNavigateToReview?: () => void;
/** 打开投资日历 */
onOpenInvestmentCalendar?: () => void;
/** 初始筛选类型(点击事件时定位到对应 Tab */
initialFilter?: FilterType;
}
/**
* 获取各类型事件数量
*/
const getEventCounts = (events: InvestmentEvent[]) => {
const counts = { all: events.length, plan: 0, review: 0, system: 0 };
events.forEach(event => {
if (event.source === 'future') {
counts.system++;
} else if (event.type === 'plan') {
counts.plan++;
} else if (event.type === 'review') {
counts.review++;
}
});
return counts;
};
/**
* EventDetailModal 组件
*/
@@ -45,26 +140,64 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
onClose,
selectedDate,
events,
borderColor,
secondaryText,
onNavigateToPlan,
onNavigateToReview,
onOpenInvestmentCalendar,
initialFilter,
}) => {
// 筛选状态
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
// 响应式弹窗宽度
const modalWidth = useBreakpointValue({ base: '100%', md: 600, lg: 800 }) || 600;
// 响应式网格列数
const gridColumns = useBreakpointValue({ base: 1, md: 2 }) || 1;
// 各类型事件数量
const eventCounts = useMemo(() => getEventCounts(events), [events]);
// 筛选后的事件
const filteredEvents = useMemo(() => {
if (activeFilter === 'all') return events;
if (activeFilter === 'system') return events.filter(e => e.source === 'future');
return events.filter(e => e.type === activeFilter && e.source !== 'future');
}, [events, activeFilter]);
// 弹窗打开时设置筛选(使用 initialFilter 或默认 'all'
React.useEffect(() => {
if (isOpen) {
setActiveFilter(initialFilter || 'all');
}
}, [isOpen, initialFilter]);
return (
<Modal
open={isOpen}
onCancel={onClose}
title={`${selectedDate?.format('YYYY年MM月DD日') || ''} 的事件`}
title={
<HStack spacing={2}>
<Text>{selectedDate?.format('YYYY年MM月DD日') || ''} </Text>
<Badge
bg="rgba(212, 175, 55, 0.15)"
color="#D4AF37"
fontSize="xs"
px={2}
borderRadius="full"
>
{events.length}
</Badge>
</HStack>
}
footer={null}
width={600}
width={modalWidth}
maskClosable={false}
keyboard={true}
centered
className="event-detail-modal"
rootClassName="event-detail-modal-root"
styles={{
body: { paddingTop: 16, paddingBottom: 24 },
body: { paddingTop: 8, paddingBottom: 24 },
}}
>
{events.length === 0 ? (
@@ -83,17 +216,86 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
}}
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{events.map((event, idx) => (
<EventCard
key={idx}
event={event}
variant="detail"
borderColor={borderColor}
secondaryText={secondaryText}
/>
))}
</Space>
<Box>
{/* Tab 筛选器 */}
<HStack spacing={2} mb={4} flexWrap="wrap">
{FILTER_OPTIONS.map(option => {
const count = eventCounts[option.key];
const isActive = activeFilter === option.key;
return (
<Button
key={option.key}
size="sm"
variant={isActive ? 'solid' : 'ghost'}
bg={isActive ? hexToRgba(option.color, 0.15) : 'transparent'}
color={isActive ? option.color : 'rgba(255, 255, 255, 0.6)'}
border="1px solid"
borderColor={isActive ? hexToRgba(option.color, 0.4) : 'rgba(255, 255, 255, 0.1)'}
_hover={{
bg: hexToRgba(option.color, 0.1),
borderColor: hexToRgba(option.color, 0.3),
}}
onClick={() => setActiveFilter(option.key)}
leftIcon={option.icon ? <Icon as={option.icon} boxSize={3.5} /> : undefined}
rightIcon={
count > 0 ? (
<Badge
bg={isActive ? hexToRgba(option.color, 0.25) : 'rgba(255, 255, 255, 0.1)'}
color={isActive ? option.color : 'rgba(255, 255, 255, 0.5)'}
fontSize="10px"
px={1.5}
borderRadius="full"
ml={1}
>
{count}
</Badge>
) : undefined
}
>
{option.label}
</Button>
);
})}
</HStack>
{/* 事件网格 */}
<Box
maxH="60vh"
overflowY="auto"
pr={2}
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255, 255, 255, 0.05)' },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(212, 175, 55, 0.3)',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(212, 175, 55, 0.5)' },
}}
>
{filteredEvents.length === 0 ? (
<Box textAlign="center" py={8}>
<Text color="rgba(255, 255, 255, 0.5)" fontSize="sm">
</Text>
</Box>
) : (
<Grid
templateColumns={`repeat(${gridColumns}, 1fr)`}
gap={4}
>
{filteredEvents.map((event, idx) => (
<FUIEventCard
key={event.id || idx}
event={event}
variant="modal"
/>
))}
</Grid>
)}
</Box>
</Box>
)}
</Modal>
);

View File

@@ -41,6 +41,7 @@ import {
optimisticAddEvent,
replaceEvent,
removeEvent,
optimisticUpdateEvent,
} from '@/store/slices/planningSlice';
import './EventFormModal.less';
import type { InvestmentEvent, EventType } from '@/types';
@@ -373,26 +374,51 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
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,
// ===== 编辑模式:乐观更新 =====
if (editingEvent) {
// 构建更新后的事件对象
const updatedEvent: InvestmentEvent = {
...editingEvent,
title: values.title,
});
message.success('修改成功');
onClose();
onSuccess();
// 使用 Redux 刷新数据,确保列表和日历同步
dispatch(fetchAllEvents());
} else {
throw new Error('保存失败');
content: values.content || '',
description: values.content || '',
date: values.date.format('YYYY-MM-DD'),
event_date: values.date.format('YYYY-MM-DD'),
stocks: stocksWithNames,
updated_at: new Date().toISOString(),
};
// ① 立即更新 UI
dispatch(optimisticUpdateEvent(updatedEvent));
setSaving(false);
onClose(); // 立即关闭弹窗
// ② 后台发送 API 请求
try {
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('修改成功');
onSuccess();
} else {
throw new Error('保存失败');
}
} catch (error) {
// ③ 失败回滚 - 重新加载数据
dispatch(fetchAllEvents());
logger.error('EventFormModal', 'handleSave edit rollback', error);
message.error('修改失败,请重试');
}
return;
}
} catch (error) {
if (error instanceof Error && error.message !== '保存失败') {

View File

@@ -24,11 +24,12 @@ import { FiFileText } from 'react-icons/fi';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import {
fetchAllEvents,
deleteEvent,
removeEvent,
selectPlans,
selectReviews,
selectPlanningLoading,
} from '@/store/slices/planningSlice';
import { getApiBase } from '@/utils/apiConfig';
import { EventFormModal } from './EventFormModal';
import { FUIEventCard } from './FUIEventCard';
import type { InvestmentEvent } from '@/types';
@@ -104,22 +105,37 @@ export const EventPanel: React.FC<EventPanelProps> = ({
setEditingItem(null);
};
// 删除数据 - 使用 Redux action
// 删除数据 - 乐观更新模式
const handleDelete = async (id: number): Promise<void> => {
if (!window.confirm('确定要删除吗?')) return;
// ① 立即从 UI 移除
dispatch(removeEvent(id));
// ② 后台发送 API 请求
try {
await dispatch(deleteEvent(id)).unwrap();
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
const base = getApiBase();
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
} else {
throw new Error('删除失败');
}
} catch (error) {
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
// ③ 失败回滚 - 重新加载数据
dispatch(fetchAllEvents());
logger.error('EventPanel', 'handleDelete rollback', error, { itemId: id });
toast({
title: '删除失败',
title: '删除失败,请重试',
status: 'error',
duration: 3000,
});

View File

@@ -56,29 +56,47 @@ const FUI_THEME = {
export interface FUIEventCardProps {
/** 事件数据 */
event: InvestmentEvent;
/** 显示变体: list(列表视图) | modal(弹窗只读) */
variant?: 'list' | 'modal';
/** 主题颜色 */
colorScheme?: string;
/** 显示标签(用于 aria-label */
label?: string;
/** 编辑回调 */
/** 编辑回调 (modal 模式不显示) */
onEdit?: (event: InvestmentEvent) => void;
/** 删除回调 */
/** 删除回调 (modal 模式不显示) */
onDelete?: (id: number) => void;
}
/** 描述最大显示行数 */
const MAX_LINES = 3;
/**
* 获取事件类型徽章配置
*/
const getTypeBadge = (event: InvestmentEvent) => {
if (event.source === 'future') {
return { label: '系统事件', color: '#3B82F6', bg: 'rgba(59, 130, 246, 0.15)' };
}
if (event.type === 'plan') {
return { label: '我的计划', color: '#D4AF37', bg: 'rgba(212, 175, 55, 0.15)' };
}
return { label: '我的复盘', color: '#10B981', bg: 'rgba(16, 185, 129, 0.15)' };
};
/**
* FUIEventCard 组件
*/
export const FUIEventCard = memo<FUIEventCardProps>(({
event,
variant = 'list',
colorScheme = 'orange',
label = '复盘',
onEdit,
onDelete,
}) => {
const isModalVariant = variant === 'modal';
const typeBadge = getTypeBadge(event);
// 展开/收起状态
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflow, setIsOverflow] = useState(false);
@@ -115,7 +133,7 @@ export const FUIEventCard = memo<FUIEventCardProps>(({
}}
>
<VStack align="stretch" spacing={3}>
{/* 头部区域:图标 + 标题 + 操作按钮 */}
{/* 头部区域:图标 + 标题 + 操作按钮/类型徽章 */}
<Flex justify="space-between" align="start" gap={2}>
<HStack spacing={2} flex={1}>
<Box
@@ -134,32 +152,46 @@ export const FUIEventCard = memo<FUIEventCardProps>(({
</Text>
</HStack>
{/* 编辑/删除按钮 */}
{(onEdit || onDelete) && (
<HStack spacing={0}>
{onEdit && (
<IconButton
icon={<FiEdit2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: FUI_THEME.accent, bg: 'rgba(212, 175, 55, 0.1)' }}
onClick={() => onEdit(event)}
aria-label={`编辑${label}`}
/>
)}
{onDelete && (
<IconButton
icon={<FiTrash2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' }}
onClick={() => onDelete(event.id)}
aria-label={`删除${label}`}
/>
)}
</HStack>
{/* modal 模式: 显示类型徽章 */}
{isModalVariant ? (
<Tag
size="sm"
bg={typeBadge.bg}
color={typeBadge.color}
border="1px solid"
borderColor={`${typeBadge.color}40`}
flexShrink={0}
>
<TagLabel fontSize="xs">{typeBadge.label}</TagLabel>
</Tag>
) : (
/* list 模式: 显示编辑/删除按钮 */
(onEdit || onDelete) && (
<HStack spacing={0}>
{onEdit && (
<IconButton
icon={<FiEdit2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: FUI_THEME.accent, bg: 'rgba(212, 175, 55, 0.1)' }}
onClick={() => onEdit(event)}
aria-label={`编辑${label}`}
/>
)}
{onDelete && (
<IconButton
icon={<FiTrash2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' }}
onClick={() => onDelete(event.id)}
aria-label={`删除${label}`}
/>
)}
</HStack>
)
)}
</Flex>

View File

@@ -24,8 +24,6 @@ import {
TabPanels,
Tab,
TabPanel,
Spinner,
Center,
Button,
ButtonGroup,
} from '@chakra-ui/react';
@@ -39,6 +37,7 @@ import { Target } from 'lucide-react';
import GlassCard from '@components/GlassCard';
import { PlanningDataProvider } from './PlanningContext';
import { EventPanelSkeleton, CalendarPanelSkeleton } from './skeletons';
import type { PlanningContextValue } from '@/types';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import {
@@ -48,7 +47,6 @@ import {
selectPlans,
selectReviews,
} from '@/store/slices/planningSlice';
import './InvestmentCalendar.less';
// 懒加载子面板组件(实现代码分割)
const CalendarPanel = lazy(() =>
@@ -58,14 +56,6 @@ const EventPanel = lazy(() =>
import('./EventPanel').then(module => ({ default: module.EventPanel }))
);
/**
* 面板加载占位符
*/
const PanelLoadingFallback: React.FC = () => (
<Center py={12}>
<Spinner size="xl" color="purple.500" thickness="4px" />
</Center>
);
/**
* InvestmentPlanningCenter 主组件
@@ -193,7 +183,7 @@ const InvestmentPlanningCenter: React.FC = () => {
<Box>
{viewMode === 'calendar' ? (
/* 日历视图 */
<Suspense fallback={<PanelLoadingFallback />}>
<Suspense fallback={<CalendarPanelSkeleton />}>
<CalendarPanel />
</Suspense>
) : (
@@ -262,7 +252,7 @@ const InvestmentPlanningCenter: React.FC = () => {
<TabPanels>
{/* 计划列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<Suspense fallback={<EventPanelSkeleton />}>
<EventPanel
type="plan"
colorScheme="orange"
@@ -274,7 +264,7 @@ const InvestmentPlanningCenter: React.FC = () => {
{/* 复盘列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<Suspense fallback={<EventPanelSkeleton />}>
<EventPanel
type="review"
colorScheme="orange"

View File

@@ -0,0 +1,96 @@
/**
* CalendarPanelSkeleton - 日历面板骨架屏组件
* 用于视图切换时的加载占位
*/
import React, { memo } from 'react';
import {
Box,
HStack,
SimpleGrid,
Skeleton,
} from '@chakra-ui/react';
// 骨架屏主题配色(黑金主题)
const SKELETON_THEME = {
startColor: 'rgba(26, 32, 44, 0.6)',
endColor: 'rgba(212, 175, 55, 0.2)',
};
/**
* CalendarPanelSkeleton 组件
* 模拟 FullCalendar 的布局结构
*/
export const CalendarPanelSkeleton: React.FC = memo(() => {
return (
<Box height={{ base: '380px', md: '560px' }}>
{/* 工具栏骨架 */}
<HStack justify="space-between" mb={4}>
{/* 左侧:导航按钮 */}
<HStack spacing={2}>
<Skeleton
height="32px"
width="32px"
borderRadius="md"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="32px"
width="32px"
borderRadius="md"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="32px"
width="60px"
borderRadius="md"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
{/* 中间:月份标题 */}
<Skeleton
height="28px"
width="150px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
{/* 右侧占位(保持对称) */}
<Box width="126px" />
</HStack>
{/* 日历网格骨架 */}
<SimpleGrid columns={7} spacing={1}>
{/* 星期头(周日 - 周六) */}
{[...Array(7)].map((_, i) => (
<Skeleton
key={`header-${i}`}
height="30px"
borderRadius="sm"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
))}
{/* 日期格子5行 x 7列 = 35个 */}
{[...Array(35)].map((_, i) => (
<Skeleton
key={`cell-${i}`}
height="85px"
borderRadius="sm"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
))}
</SimpleGrid>
</Box>
);
});
CalendarPanelSkeleton.displayName = 'CalendarPanelSkeleton';
export default CalendarPanelSkeleton;

View File

@@ -0,0 +1,112 @@
/**
* EventPanelSkeleton - 事件列表骨架屏组件
* 用于视图切换时的加载占位
*/
import React, { memo } from 'react';
import {
Box,
VStack,
HStack,
Skeleton,
SkeletonText,
} from '@chakra-ui/react';
// 骨架屏主题配色(黑金主题)
const SKELETON_THEME = {
startColor: 'rgba(26, 32, 44, 0.6)',
endColor: 'rgba(212, 175, 55, 0.2)',
cardBg: 'rgba(26, 26, 46, 0.7)',
cardBorder: 'rgba(212, 175, 55, 0.15)',
};
/**
* 单个事件卡片骨架
*/
const EventCardSkeleton: React.FC = () => (
<Box
p={4}
borderRadius="lg"
bg={SKELETON_THEME.cardBg}
border="1px solid"
borderColor={SKELETON_THEME.cardBorder}
>
{/* 头部:图标 + 标题 */}
<HStack spacing={3} mb={3}>
<Skeleton
height="16px"
width="16px"
borderRadius="sm"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="16px"
width="180px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
{/* 日期行 */}
<HStack spacing={2} mb={2}>
<Skeleton
height="12px"
width="12px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="12px"
width="120px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
{/* 描述内容 */}
<SkeletonText
noOfLines={2}
spacing={2}
skeletonHeight={3}
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
{/* 股票标签行 */}
<HStack spacing={2} mt={3}>
<Skeleton
height="20px"
width="60px"
borderRadius="full"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="20px"
width="80px"
borderRadius="full"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
</Box>
);
/**
* EventPanelSkeleton 组件
* 显示多个卡片骨架屏
*/
export const EventPanelSkeleton: React.FC = memo(() => {
return (
<VStack spacing={4} align="stretch" py={2}>
{[1, 2, 3, 4].map(i => (
<EventCardSkeleton key={i} />
))}
</VStack>
);
});
EventPanelSkeleton.displayName = 'EventPanelSkeleton';
export default EventPanelSkeleton;

View File

@@ -0,0 +1,6 @@
/**
* Center 模块骨架屏组件统一导出
*/
export { EventPanelSkeleton } from './EventPanelSkeleton';
export { CalendarPanelSkeleton } from './CalendarPanelSkeleton';