refactor(Center): 重构投资规划中心日历与事件管理
This commit is contained in:
@@ -215,6 +215,14 @@ const planningSlice = createSlice({
|
|||||||
state.allEvents = state.allEvents.filter(e => e.id !== action.payload);
|
state.allEvents = state.allEvents.filter(e => e.id !== action.payload);
|
||||||
state.lastUpdated = Date.now();
|
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) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
@@ -277,6 +285,7 @@ export const {
|
|||||||
optimisticAddEvent,
|
optimisticAddEvent,
|
||||||
replaceEvent,
|
replaceEvent,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
|
optimisticUpdateEvent,
|
||||||
} = planningSlice.actions;
|
} = planningSlice.actions;
|
||||||
|
|
||||||
export default planningSlice.reducer;
|
export default planningSlice.reducer;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* CalendarPanel - 投资日历面板组件
|
* CalendarPanel - 投资日历面板组件
|
||||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
* 使用 Ant Design Calendar 展示投资计划、复盘等事件
|
||||||
|
*
|
||||||
|
* 聚合展示模式:每个日期格子显示各类型事件的汇总信息
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
|
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
|
||||||
@@ -14,12 +16,8 @@ import {
|
|||||||
ModalCloseButton,
|
ModalCloseButton,
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
|
VStack,
|
||||||
} from '@chakra-ui/react';
|
} 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, { Dayjs } from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
@@ -28,7 +26,15 @@ import { selectAllEvents } from '@/store/slices/planningSlice';
|
|||||||
import { usePlanningData } from './PlanningContext';
|
import { usePlanningData } from './PlanningContext';
|
||||||
import { EventDetailModal } from './EventDetailModal';
|
import { EventDetailModal } from './EventDetailModal';
|
||||||
import type { InvestmentEvent } from '@/types';
|
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'));
|
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
|
||||||
@@ -36,18 +42,12 @@ const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
|
|||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FullCalendar 事件类型
|
* 事件聚合信息(用于日历格子显示)
|
||||||
*/
|
*/
|
||||||
interface CalendarEvent {
|
interface EventSummary {
|
||||||
id: string;
|
plans: InvestmentEvent[];
|
||||||
title: string;
|
reviews: InvestmentEvent[];
|
||||||
start: string;
|
systems: InvestmentEvent[];
|
||||||
date: string;
|
|
||||||
backgroundColor: string;
|
|
||||||
borderColor: string;
|
|
||||||
extendedProps: InvestmentEvent & {
|
|
||||||
isSystem: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,140 +72,123 @@ export const CalendarPanel: React.FC = () => {
|
|||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||||
|
const [initialFilter, setInitialFilter] = useState<'all' | 'plan' | 'review' | 'system'>('all');
|
||||||
|
|
||||||
// 转换数据为 FullCalendar 格式(使用 useMemo 缓存)
|
// 按日期分组事件(用于聚合展示)
|
||||||
// 黑金主题色:计划用金色,复盘用青金色,系统事件用蓝色
|
const eventsByDate = useMemo(() => {
|
||||||
const calendarEvents: CalendarEvent[] = useMemo(() =>
|
const map: Record<string, EventSummary> = {};
|
||||||
allEvents.map(event => ({
|
allEvents.forEach(event => {
|
||||||
...event,
|
const dateStr = event.event_date;
|
||||||
id: `${event.source || 'user'}-${event.id}`,
|
if (!map[dateStr]) {
|
||||||
title: event.title,
|
map[dateStr] = { plans: [], reviews: [], systems: [] };
|
||||||
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',
|
|
||||||
}
|
}
|
||||||
})), [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]);
|
||||||
|
|
||||||
// 抽取公共的打开事件详情函数
|
// 将事件摘要转换为 CalendarEvent 格式
|
||||||
const openEventDetail = useCallback((date: Date | null): void => {
|
const getCalendarEvents = useCallback((dateStr: string): CalendarEvent[] => {
|
||||||
if (!date) return;
|
const summary = eventsByDate[dateStr];
|
||||||
const clickedDate = dayjs(date);
|
if (!summary) return [];
|
||||||
setSelectedDate(clickedDate);
|
|
||||||
|
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 =>
|
const dayEvents = allEvents.filter(event =>
|
||||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
dayjs(event.event_date).isSame(date, 'day')
|
||||||
);
|
);
|
||||||
setSelectedDateEvents(dayEvents);
|
setSelectedDateEvents(dayEvents);
|
||||||
|
setInitialFilter('all'); // 点击日期时显示全部
|
||||||
setIsDetailModalOpen(true);
|
setIsDetailModalOpen(true);
|
||||||
}, [allEvents]);
|
}, [allEvents]);
|
||||||
|
|
||||||
// 处理日期点击
|
// 处理事件点击(点击具体事件类型)
|
||||||
const handleDateClick = useCallback((info: DateClickArg): void => {
|
const handleEventClick = useCallback((event: CalendarEvent): void => {
|
||||||
openEventDetail(info.date);
|
const date = dayjs(event.date);
|
||||||
}, [openEventDetail]);
|
setSelectedDate(date);
|
||||||
|
|
||||||
// 处理事件点击
|
const dayEvents = allEvents.filter(e =>
|
||||||
const handleEventClick = useCallback((info: EventClickArg): void => {
|
dayjs(e.event_date).isSame(date, 'day')
|
||||||
openEventDetail(info.event.start);
|
);
|
||||||
}, [openEventDetail]);
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box
|
{/* 日历容器 - 使用 BaseCalendar */}
|
||||||
height={{ base: '380px', md: '560px' }}
|
<Box height={{ base: '420px', md: '600px' }}>
|
||||||
sx={{
|
<BaseCalendar
|
||||||
// FullCalendar CSS Variables 覆盖(黑金主题)
|
onSelect={handleDateSelect}
|
||||||
// FullCalendar v6 使用 CSS-in-JS,必须通过 CSS Variables 覆盖
|
cellRender={renderCellContent}
|
||||||
'--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}
|
|
||||||
height="100%"
|
height="100%"
|
||||||
dayMaxEvents={1}
|
showToolbar={true}
|
||||||
moreLinkText="+更多"
|
|
||||||
buttonText={{
|
|
||||||
today: '今天',
|
|
||||||
month: '月',
|
|
||||||
week: '周'
|
|
||||||
}}
|
|
||||||
titleFormat={{ year: 'numeric', month: 'long' }}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -217,6 +200,7 @@ export const CalendarPanel: React.FC = () => {
|
|||||||
events={selectedDateEvents}
|
events={selectedDateEvents}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
secondaryText={secondaryText}
|
secondaryText={secondaryText}
|
||||||
|
initialFilter={initialFilter}
|
||||||
onNavigateToPlan={() => {
|
onNavigateToPlan={() => {
|
||||||
setViewMode('list');
|
setViewMode('list');
|
||||||
setListTab(0);
|
setListTab(0);
|
||||||
@@ -249,7 +233,6 @@ export const CalendarPanel: React.FC = () => {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* EventDetailModal.less - 事件详情弹窗黑金主题样式 */
|
/* EventDetailModal.less - 事件详情弹窗黑金主题样式 */
|
||||||
|
|
||||||
// ==================== 变量定义 ====================
|
// ==================== 变量定义 ====================
|
||||||
@color-bg-deep: #0A0A14;
|
// 与 GlassCard transparent 变体保持一致
|
||||||
|
@color-bg-deep: rgba(15, 15, 26, 0.95);
|
||||||
@color-bg-primary: #0F0F1A;
|
@color-bg-primary: #0F0F1A;
|
||||||
@color-bg-elevated: #1A1A2E;
|
@color-bg-elevated: #1A1A2E;
|
||||||
|
|
||||||
@@ -27,11 +28,10 @@
|
|||||||
.event-detail-modal {
|
.event-detail-modal {
|
||||||
// Modal 整体
|
// Modal 整体
|
||||||
.ant-modal-content {
|
.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: 1px solid @color-line-default;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal 头部
|
// Modal 头部
|
||||||
@@ -68,3 +68,61 @@
|
|||||||
background: transparent;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,94 @@
|
|||||||
/**
|
/**
|
||||||
* EventDetailModal - 事件详情弹窗组件
|
* EventDetailModal - 事件详情弹窗组件
|
||||||
* 用于展示某一天的所有投资事件
|
* 用于展示某一天的所有投资事件
|
||||||
* 使用 Ant Design 实现
|
*
|
||||||
|
* 功能:
|
||||||
|
* - Tab 筛选(全部/计划/复盘/系统)
|
||||||
|
* - 两列网格布局
|
||||||
|
* - 响应式宽度
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Modal, Space } from 'antd';
|
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 type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
import { EventCard } from './EventCard';
|
import { FUIEventCard } from './FUIEventCard';
|
||||||
import { EventEmptyState } from './EventEmptyState';
|
import { EventEmptyState } from './EventEmptyState';
|
||||||
import type { InvestmentEvent } from '@/types';
|
import type { InvestmentEvent } from '@/types';
|
||||||
import './EventDetailModal.less';
|
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
|
* EventDetailModal Props
|
||||||
*/
|
*/
|
||||||
@@ -35,8 +111,27 @@ export interface EventDetailModalProps {
|
|||||||
onNavigateToReview?: () => void;
|
onNavigateToReview?: () => void;
|
||||||
/** 打开投资日历 */
|
/** 打开投资日历 */
|
||||||
onOpenInvestmentCalendar?: () => 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 组件
|
* EventDetailModal 组件
|
||||||
*/
|
*/
|
||||||
@@ -45,26 +140,64 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
borderColor,
|
|
||||||
secondaryText,
|
|
||||||
onNavigateToPlan,
|
onNavigateToPlan,
|
||||||
onNavigateToReview,
|
onNavigateToReview,
|
||||||
onOpenInvestmentCalendar,
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onCancel={onClose}
|
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}
|
footer={null}
|
||||||
width={600}
|
width={modalWidth}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
keyboard={true}
|
keyboard={true}
|
||||||
centered
|
centered
|
||||||
className="event-detail-modal"
|
className="event-detail-modal"
|
||||||
rootClassName="event-detail-modal-root"
|
rootClassName="event-detail-modal-root"
|
||||||
styles={{
|
styles={{
|
||||||
body: { paddingTop: 16, paddingBottom: 24 },
|
body: { paddingTop: 8, paddingBottom: 24 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
@@ -83,17 +216,86 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<Box>
|
||||||
{events.map((event, idx) => (
|
{/* Tab 筛选器 */}
|
||||||
<EventCard
|
<HStack spacing={2} mb={4} flexWrap="wrap">
|
||||||
key={idx}
|
{FILTER_OPTIONS.map(option => {
|
||||||
event={event}
|
const count = eventCounts[option.key];
|
||||||
variant="detail"
|
const isActive = activeFilter === option.key;
|
||||||
borderColor={borderColor}
|
|
||||||
secondaryText={secondaryText}
|
return (
|
||||||
/>
|
<Button
|
||||||
))}
|
key={option.key}
|
||||||
</Space>
|
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>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
optimisticAddEvent,
|
optimisticAddEvent,
|
||||||
replaceEvent,
|
replaceEvent,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
|
optimisticUpdateEvent,
|
||||||
} from '@/store/slices/planningSlice';
|
} from '@/store/slices/planningSlice';
|
||||||
import './EventFormModal.less';
|
import './EventFormModal.less';
|
||||||
import type { InvestmentEvent, EventType } from '@/types';
|
import type { InvestmentEvent, EventType } from '@/types';
|
||||||
@@ -373,26 +374,51 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 编辑模式:传统更新 =====
|
// ===== 编辑模式:乐观更新 =====
|
||||||
const response = await fetch(url, {
|
if (editingEvent) {
|
||||||
method: 'PUT',
|
// 构建更新后的事件对象
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const updatedEvent: InvestmentEvent = {
|
||||||
credentials: 'include',
|
...editingEvent,
|
||||||
body: JSON.stringify(requestData),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
logger.info('EventFormModal', `更新${label}成功`, {
|
|
||||||
itemId: editingEvent?.id,
|
|
||||||
title: values.title,
|
title: values.title,
|
||||||
});
|
content: values.content || '',
|
||||||
message.success('修改成功');
|
description: values.content || '',
|
||||||
onClose();
|
date: values.date.format('YYYY-MM-DD'),
|
||||||
onSuccess();
|
event_date: values.date.format('YYYY-MM-DD'),
|
||||||
// 使用 Redux 刷新数据,确保列表和日历同步
|
stocks: stocksWithNames,
|
||||||
dispatch(fetchAllEvents());
|
updated_at: new Date().toISOString(),
|
||||||
} else {
|
};
|
||||||
throw new Error('保存失败');
|
|
||||||
|
// ① 立即更新 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) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message !== '保存失败') {
|
if (error instanceof Error && error.message !== '保存失败') {
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ import { FiFileText } from 'react-icons/fi';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
import {
|
import {
|
||||||
fetchAllEvents,
|
fetchAllEvents,
|
||||||
deleteEvent,
|
removeEvent,
|
||||||
selectPlans,
|
selectPlans,
|
||||||
selectReviews,
|
selectReviews,
|
||||||
selectPlanningLoading,
|
selectPlanningLoading,
|
||||||
} from '@/store/slices/planningSlice';
|
} from '@/store/slices/planningSlice';
|
||||||
|
import { getApiBase } from '@/utils/apiConfig';
|
||||||
import { EventFormModal } from './EventFormModal';
|
import { EventFormModal } from './EventFormModal';
|
||||||
import { FUIEventCard } from './FUIEventCard';
|
import { FUIEventCard } from './FUIEventCard';
|
||||||
import type { InvestmentEvent } from '@/types';
|
import type { InvestmentEvent } from '@/types';
|
||||||
@@ -104,22 +105,37 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除数据 - 使用 Redux action
|
// 删除数据 - 乐观更新模式
|
||||||
const handleDelete = async (id: number): Promise<void> => {
|
const handleDelete = async (id: number): Promise<void> => {
|
||||||
if (!window.confirm('确定要删除吗?')) return;
|
if (!window.confirm('确定要删除吗?')) return;
|
||||||
|
|
||||||
|
// ① 立即从 UI 移除
|
||||||
|
dispatch(removeEvent(id));
|
||||||
|
|
||||||
|
// ② 后台发送 API 请求
|
||||||
try {
|
try {
|
||||||
await dispatch(deleteEvent(id)).unwrap();
|
const base = getApiBase();
|
||||||
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
|
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||||||
toast({
|
method: 'DELETE',
|
||||||
title: '删除成功',
|
credentials: 'include',
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
|
||||||
|
toast({
|
||||||
|
title: '删除成功',
|
||||||
|
status: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('删除失败');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
|
// ③ 失败回滚 - 重新加载数据
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
logger.error('EventPanel', 'handleDelete rollback', error, { itemId: id });
|
||||||
toast({
|
toast({
|
||||||
title: '删除失败',
|
title: '删除失败,请重试',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,29 +56,47 @@ const FUI_THEME = {
|
|||||||
export interface FUIEventCardProps {
|
export interface FUIEventCardProps {
|
||||||
/** 事件数据 */
|
/** 事件数据 */
|
||||||
event: InvestmentEvent;
|
event: InvestmentEvent;
|
||||||
|
/** 显示变体: list(列表视图) | modal(弹窗只读) */
|
||||||
|
variant?: 'list' | 'modal';
|
||||||
/** 主题颜色 */
|
/** 主题颜色 */
|
||||||
colorScheme?: string;
|
colorScheme?: string;
|
||||||
/** 显示标签(用于 aria-label) */
|
/** 显示标签(用于 aria-label) */
|
||||||
label?: string;
|
label?: string;
|
||||||
/** 编辑回调 */
|
/** 编辑回调 (modal 模式不显示) */
|
||||||
onEdit?: (event: InvestmentEvent) => void;
|
onEdit?: (event: InvestmentEvent) => void;
|
||||||
/** 删除回调 */
|
/** 删除回调 (modal 模式不显示) */
|
||||||
onDelete?: (id: number) => void;
|
onDelete?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 描述最大显示行数 */
|
/** 描述最大显示行数 */
|
||||||
const MAX_LINES = 3;
|
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 组件
|
* FUIEventCard 组件
|
||||||
*/
|
*/
|
||||||
export const FUIEventCard = memo<FUIEventCardProps>(({
|
export const FUIEventCard = memo<FUIEventCardProps>(({
|
||||||
event,
|
event,
|
||||||
|
variant = 'list',
|
||||||
colorScheme = 'orange',
|
colorScheme = 'orange',
|
||||||
label = '复盘',
|
label = '复盘',
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isModalVariant = variant === 'modal';
|
||||||
|
const typeBadge = getTypeBadge(event);
|
||||||
// 展开/收起状态
|
// 展开/收起状态
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isOverflow, setIsOverflow] = useState(false);
|
const [isOverflow, setIsOverflow] = useState(false);
|
||||||
@@ -115,7 +133,7 @@ export const FUIEventCard = memo<FUIEventCardProps>(({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VStack align="stretch" spacing={3}>
|
<VStack align="stretch" spacing={3}>
|
||||||
{/* 头部区域:图标 + 标题 + 操作按钮 */}
|
{/* 头部区域:图标 + 标题 + 操作按钮/类型徽章 */}
|
||||||
<Flex justify="space-between" align="start" gap={2}>
|
<Flex justify="space-between" align="start" gap={2}>
|
||||||
<HStack spacing={2} flex={1}>
|
<HStack spacing={2} flex={1}>
|
||||||
<Box
|
<Box
|
||||||
@@ -134,32 +152,46 @@ export const FUIEventCard = memo<FUIEventCardProps>(({
|
|||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* 编辑/删除按钮 */}
|
{/* modal 模式: 显示类型徽章 */}
|
||||||
{(onEdit || onDelete) && (
|
{isModalVariant ? (
|
||||||
<HStack spacing={0}>
|
<Tag
|
||||||
{onEdit && (
|
size="sm"
|
||||||
<IconButton
|
bg={typeBadge.bg}
|
||||||
icon={<FiEdit2 size={14} />}
|
color={typeBadge.color}
|
||||||
size="xs"
|
border="1px solid"
|
||||||
variant="ghost"
|
borderColor={`${typeBadge.color}40`}
|
||||||
color={FUI_THEME.text.secondary}
|
flexShrink={0}
|
||||||
_hover={{ color: FUI_THEME.accent, bg: 'rgba(212, 175, 55, 0.1)' }}
|
>
|
||||||
onClick={() => onEdit(event)}
|
<TagLabel fontSize="xs">{typeBadge.label}</TagLabel>
|
||||||
aria-label={`编辑${label}`}
|
</Tag>
|
||||||
/>
|
) : (
|
||||||
)}
|
/* list 模式: 显示编辑/删除按钮 */
|
||||||
{onDelete && (
|
(onEdit || onDelete) && (
|
||||||
<IconButton
|
<HStack spacing={0}>
|
||||||
icon={<FiTrash2 size={14} />}
|
{onEdit && (
|
||||||
size="xs"
|
<IconButton
|
||||||
variant="ghost"
|
icon={<FiEdit2 size={14} />}
|
||||||
color={FUI_THEME.text.secondary}
|
size="xs"
|
||||||
_hover={{ color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' }}
|
variant="ghost"
|
||||||
onClick={() => onDelete(event.id)}
|
color={FUI_THEME.text.secondary}
|
||||||
aria-label={`删除${label}`}
|
_hover={{ color: FUI_THEME.accent, bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||||
/>
|
onClick={() => onEdit(event)}
|
||||||
)}
|
aria-label={`编辑${label}`}
|
||||||
</HStack>
|
/>
|
||||||
|
)}
|
||||||
|
{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>
|
</Flex>
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import {
|
|||||||
TabPanels,
|
TabPanels,
|
||||||
Tab,
|
Tab,
|
||||||
TabPanel,
|
TabPanel,
|
||||||
Spinner,
|
|
||||||
Center,
|
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
@@ -39,6 +37,7 @@ import { Target } from 'lucide-react';
|
|||||||
import GlassCard from '@components/GlassCard';
|
import GlassCard from '@components/GlassCard';
|
||||||
|
|
||||||
import { PlanningDataProvider } from './PlanningContext';
|
import { PlanningDataProvider } from './PlanningContext';
|
||||||
|
import { EventPanelSkeleton, CalendarPanelSkeleton } from './skeletons';
|
||||||
import type { PlanningContextValue } from '@/types';
|
import type { PlanningContextValue } from '@/types';
|
||||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +47,6 @@ import {
|
|||||||
selectPlans,
|
selectPlans,
|
||||||
selectReviews,
|
selectReviews,
|
||||||
} from '@/store/slices/planningSlice';
|
} from '@/store/slices/planningSlice';
|
||||||
import './InvestmentCalendar.less';
|
|
||||||
|
|
||||||
// 懒加载子面板组件(实现代码分割)
|
// 懒加载子面板组件(实现代码分割)
|
||||||
const CalendarPanel = lazy(() =>
|
const CalendarPanel = lazy(() =>
|
||||||
@@ -58,14 +56,6 @@ const EventPanel = lazy(() =>
|
|||||||
import('./EventPanel').then(module => ({ default: module.EventPanel }))
|
import('./EventPanel').then(module => ({ default: module.EventPanel }))
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 面板加载占位符
|
|
||||||
*/
|
|
||||||
const PanelLoadingFallback: React.FC = () => (
|
|
||||||
<Center py={12}>
|
|
||||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InvestmentPlanningCenter 主组件
|
* InvestmentPlanningCenter 主组件
|
||||||
@@ -193,7 +183,7 @@ const InvestmentPlanningCenter: React.FC = () => {
|
|||||||
<Box>
|
<Box>
|
||||||
{viewMode === 'calendar' ? (
|
{viewMode === 'calendar' ? (
|
||||||
/* 日历视图 */
|
/* 日历视图 */
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
<Suspense fallback={<CalendarPanelSkeleton />}>
|
||||||
<CalendarPanel />
|
<CalendarPanel />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
@@ -262,7 +252,7 @@ const InvestmentPlanningCenter: React.FC = () => {
|
|||||||
<TabPanels>
|
<TabPanels>
|
||||||
{/* 计划列表面板 */}
|
{/* 计划列表面板 */}
|
||||||
<TabPanel px={0}>
|
<TabPanel px={0}>
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
<Suspense fallback={<EventPanelSkeleton />}>
|
||||||
<EventPanel
|
<EventPanel
|
||||||
type="plan"
|
type="plan"
|
||||||
colorScheme="orange"
|
colorScheme="orange"
|
||||||
@@ -274,7 +264,7 @@ const InvestmentPlanningCenter: React.FC = () => {
|
|||||||
|
|
||||||
{/* 复盘列表面板 */}
|
{/* 复盘列表面板 */}
|
||||||
<TabPanel px={0}>
|
<TabPanel px={0}>
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
<Suspense fallback={<EventPanelSkeleton />}>
|
||||||
<EventPanel
|
<EventPanel
|
||||||
type="review"
|
type="review"
|
||||||
colorScheme="orange"
|
colorScheme="orange"
|
||||||
|
|||||||
@@ -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;
|
||||||
112
src/views/Center/components/skeletons/EventPanelSkeleton.tsx
Normal file
112
src/views/Center/components/skeletons/EventPanelSkeleton.tsx
Normal 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;
|
||||||
6
src/views/Center/components/skeletons/index.ts
Normal file
6
src/views/Center/components/skeletons/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Center 模块骨架屏组件统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { EventPanelSkeleton } from './EventPanelSkeleton';
|
||||||
|
export { CalendarPanelSkeleton } from './CalendarPanelSkeleton';
|
||||||
Reference in New Issue
Block a user