feat: 创建 CalendarPanel.tsx 新建: src/views/Dashboard/components/CalendarPanel.tsx │ │

│ │                                                                                                                                                                     │ │
│ │ - 复制原文件第 194-606 行代码                                                                                                                                       │ │
│ │ - 添加类型注解(Props、State、Event handlers)                                                                                                                      │ │
│ │ - 使用 usePlanningData() Hook                                                                                                                                       │ │
│ │ - FullCalendar 只在此文件导入(实现代码分割)
This commit is contained in:
zdl
2025-11-18 13:47:56 +08:00
parent 46639030bb
commit fc1f667700

View File

@@ -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<Dayjs | null>(null);
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
const [newEvent, setNewEvent] = useState<NewEventForm>({
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<void> => {
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<void> => {
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 (
<Box>
<Flex justify="flex-end" mb={4}>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => {
if (!selectedDate) setSelectedDate(dayjs());
onAddOpen();
}}
>
</Button>
</Flex>
{loading ? (
<Center h="560px">
<Spinner size="xl" color="purple.500" />
</Center>
) : (
<Box height={{ base: '500px', md: '600px' }}>
<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%"
dayMaxEvents={3}
moreLinkText="更多"
buttonText={{
today: '今天',
month: '月',
week: '周'
}}
/>
</Box>
)}
{/* 查看事件详情 Modal */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{selectedDate && selectedDate.format('YYYY年MM月DD日')}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selectedDateEvents.length === 0 ? (
<Center py={8}>
<VStack>
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => {
onClose();
onAddOpen();
}}
>
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={4}>
{selectedDateEvents.map((event, idx) => (
<Box
key={idx}
p={4}
borderRadius="md"
border="1px"
borderColor={borderColor}
>
<Flex justify="space-between" align="start" mb={2}>
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold" fontSize="lg">
{event.title}
</Text>
{event.source === 'future' ? (
<Badge colorScheme="blue" variant="subtle"></Badge>
) : event.type === 'plan' ? (
<Badge colorScheme="purple" variant="subtle"></Badge>
) : (
<Badge colorScheme="green" variant="subtle"></Badge>
)}
</HStack>
{event.importance && (
<HStack spacing={2}>
<Icon as={FiStar} color="yellow.500" />
<Text fontSize="sm" color={secondaryText}>
: {event.importance}/5
</Text>
</HStack>
)}
</VStack>
<HStack>
{!event.source || event.source === 'user' ? (
<>
<Tooltip label="查看详情">
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
colorScheme="blue"
onClick={() => handleViewDetails(event)}
aria-label="查看详情"
/>
</Tooltip>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDeleteEvent(event.id)}
aria-label="删除事件"
/>
</>
) : null}
</HStack>
</Flex>
{event.description && (
<Text fontSize="sm" color={secondaryText} mb={2}>
{event.description}
</Text>
)}
{event.stocks && event.stocks.length > 0 && (
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={secondaryText}>:</Text>
{event.stocks.map((stock, i) => (
<Tag key={i} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</HStack>
)}
</Box>
))}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}></Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 添加投资计划 Modal */}
{isAddOpen && (
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={newEvent.title}
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
placeholder="例如:关注半导体板块"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={newEvent.description}
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
placeholder="详细描述您的投资计划..."
rows={3}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={newEvent.type}
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value as EventType })}
>
<option value="plan"></option>
<option value="review"></option>
<option value="reminder"></option>
<option value="analysis"></option>
</Select>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={newEvent.importance}
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
>
<option value={5}> </option>
<option value={4}> </option>
<option value={3}> </option>
<option value={2}> </option>
<option value={1}> </option>
</Select>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Input
value={newEvent.stocks}
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
placeholder="例如600519,000858,002415"
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onAddClose}>
</Button>
<Button
colorScheme="purple"
onClick={handleAddEvent}
isDisabled={!newEvent.title}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};