feat: 创建 CalendarPanel.tsx 新建: src/views/Dashboard/components/CalendarPanel.tsx │ │
│ │ │ │ │ │ - 复制原文件第 194-606 行代码 │ │ │ │ - 添加类型注解(Props、State、Event handlers) │ │ │ │ - 使用 usePlanningData() Hook │ │ │ │ - FullCalendar 只在此文件导入(实现代码分割)
This commit is contained in:
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user