diff --git a/src/views/Dashboard/components/CalendarPanel.tsx b/src/views/Dashboard/components/CalendarPanel.tsx new file mode 100644 index 00000000..4732d8fb --- /dev/null +++ b/src/views/Dashboard/components/CalendarPanel.tsx @@ -0,0 +1,504 @@ +/** + * CalendarPanel - 投资日历面板组件 + * 使用 FullCalendar 展示投资计划、复盘等事件 + */ + +import React, { useState } from 'react'; +import { + Box, + Button, + Badge, + IconButton, + Flex, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + useDisclosure, + VStack, + HStack, + Text, + Spinner, + Center, + Tooltip, + Icon, + Input, + FormControl, + FormLabel, + Textarea, + Select, + Tag, + TagLabel, + TagLeftIcon, +} from '@chakra-ui/react'; +import { + FiPlus, + FiEdit2, + FiTrash2, + FiStar, + FiTrendingUp, +} from 'react-icons/fi'; +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import { DateClickArg } from '@fullcalendar/interaction'; +import { EventClickArg } from '@fullcalendar/common'; +import dayjs, { Dayjs } from 'dayjs'; +import 'dayjs/locale/zh-cn'; + +import { usePlanningData } from './PlanningContext'; +import type { InvestmentEvent, EventType } from '@/types'; +import { logger } from '@/utils/logger'; +import { getApiBase } from '@/utils/apiConfig'; + +dayjs.locale('zh-cn'); + +/** + * 新事件表单数据类型 + */ +interface NewEventForm { + title: string; + description: string; + type: EventType; + importance: number; + stocks: string; +} + +/** + * FullCalendar 事件类型 + */ +interface CalendarEvent { + id: string; + title: string; + start: string; + date: string; + backgroundColor: string; + borderColor: string; + extendedProps: InvestmentEvent & { + isSystem: boolean; + }; +} + +/** + * CalendarPanel 组件 + * 日历视图面板,显示所有投资事件 + */ +export const CalendarPanel: React.FC = () => { + const { + allEvents, + loadAllData, + loading, + setActiveTab, + toast, + borderColor, + secondaryText, + } = usePlanningData(); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure(); + + const [selectedDate, setSelectedDate] = useState(null); + const [selectedDateEvents, setSelectedDateEvents] = useState([]); + const [newEvent, setNewEvent] = useState({ + title: '', + description: '', + type: 'plan', + importance: 3, + stocks: '', + }); + + // 转换数据为 FullCalendar 格式 + const calendarEvents: CalendarEvent[] = 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' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169', + borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169', + extendedProps: { + ...event, + isSystem: event.source === 'future', + } + })); + + // 处理日期点击 + const handleDateClick = (info: DateClickArg): void => { + const clickedDate = dayjs(info.date); + setSelectedDate(clickedDate); + + const dayEvents = allEvents.filter(event => + dayjs(event.event_date).isSame(clickedDate, 'day') + ); + setSelectedDateEvents(dayEvents); + onOpen(); + }; + + // 处理事件点击 + const handleEventClick = (info: EventClickArg): void => { + const event = info.event; + const clickedDate = dayjs(event.start); + setSelectedDate(clickedDate); + + const dayEvents = allEvents.filter(ev => + dayjs(ev.event_date).isSame(clickedDate, 'day') + ); + setSelectedDateEvents(dayEvents); + onOpen(); + }; + + // 添加新事件 + const handleAddEvent = async (): Promise => { + try { + const base = getApiBase(); + + const eventData = { + ...newEvent, + event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')), + stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s), + }; + + const response = await fetch(base + '/api/account/calendar/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(eventData), + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + logger.info('CalendarPanel', '添加事件成功', { + eventTitle: eventData.title, + eventDate: eventData.event_date + }); + toast({ + title: '添加成功', + description: '投资计划已添加', + status: 'success', + duration: 3000, + }); + onAddClose(); + loadAllData(); + setNewEvent({ + title: '', + description: '', + type: 'plan', + importance: 3, + stocks: '', + }); + } + } + } catch (error) { + logger.error('CalendarPanel', 'handleAddEvent', error, { + eventTitle: newEvent?.title + }); + toast({ + title: '添加失败', + description: '无法添加投资计划', + status: 'error', + duration: 3000, + }); + } + }; + + // 删除事件 + const handleDeleteEvent = async (eventId: number): Promise => { + if (!eventId) { + logger.warn('CalendarPanel', '删除事件失败: 缺少事件 ID', { eventId }); + toast({ + title: '无法删除', + description: '缺少事件 ID', + status: 'error', + duration: 3000, + }); + return; + } + try { + const base = getApiBase(); + + const response = await fetch(base + `/api/account/calendar/events/${eventId}`, { + method: 'DELETE', + credentials: 'include', + }); + + if (response.ok) { + logger.info('CalendarPanel', '删除事件成功', { eventId }); + toast({ + title: '删除成功', + status: 'success', + duration: 2000, + }); + loadAllData(); + } + } catch (error) { + logger.error('CalendarPanel', 'handleDeleteEvent', error, { eventId }); + toast({ + title: '删除失败', + status: 'error', + duration: 3000, + }); + } + }; + + // 跳转到计划或复盘标签页 + const handleViewDetails = (event: InvestmentEvent): void => { + if (event.type === 'plan') { + setActiveTab(1); // 跳转到"我的计划"标签页 + } else if (event.type === 'review') { + setActiveTab(2); // 跳转到"我的复盘"标签页 + } + onClose(); + }; + + return ( + + + + + + {loading ? ( +
+ +
+ ) : ( + + + + )} + + {/* 查看事件详情 Modal */} + {isOpen && ( + + + + + {selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件 + + + + {selectedDateEvents.length === 0 ? ( +
+ + 当天没有事件 + + +
+ ) : ( + + {selectedDateEvents.map((event, idx) => ( + + + + + + {event.title} + + {event.source === 'future' ? ( + 系统事件 + ) : event.type === 'plan' ? ( + 我的计划 + ) : ( + 我的复盘 + )} + + {event.importance && ( + + + + 重要度: {event.importance}/5 + + + )} + + + {!event.source || event.source === 'user' ? ( + <> + + } + size="sm" + variant="ghost" + colorScheme="blue" + onClick={() => handleViewDetails(event)} + aria-label="查看详情" + /> + + } + size="sm" + variant="ghost" + colorScheme="red" + onClick={() => handleDeleteEvent(event.id)} + aria-label="删除事件" + /> + + ) : null} + + + + {event.description && ( + + {event.description} + + )} + + {event.stocks && event.stocks.length > 0 && ( + + 相关股票: + {event.stocks.map((stock, i) => ( + + + {stock} + + ))} + + )} + + ))} + + )} +
+ + + +
+
+ )} + + {/* 添加投资计划 Modal */} + {isAddOpen && ( + + + + + 添加投资计划 + + + + + + 标题 + setNewEvent({ ...newEvent, title: e.target.value })} + placeholder="例如:关注半导体板块" + /> + + + + 描述 +