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.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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 !== '保存失败') {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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