/** * 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="例如:关注半导体板块" /> 描述