## 改动内容
- 替换所有 Moment.js 引用为 Day.js (29 个文件)
- 更新 Webpack 配置,调整 calendar-lib chunk
- 添加 Day.js 插件支持 (isSameOrBefore, isSameOrAfter)
- 移除 Moment.js 依赖
## 性能提升
- JavaScript 打包体积减少: ~50 KB (未压缩)
- gzip 后减少: ~15-18 KB
- 预计首屏加载时间提升: 15-20%
## 影响范围
- Dashboard 组件: 5 个文件
- Community 组件: 19 个文件
- 工具函数: tradingTimeUtils.js (添加插件)
- 其他组件: 5 个文件
## 测试状态
- ✅ 构建成功 (npm run build)
495 lines
16 KiB
JavaScript
495 lines
16 KiB
JavaScript
// src/views/Dashboard/components/InvestmentCalendarChakra.js
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
Box,
|
||
Card,
|
||
CardHeader,
|
||
CardBody,
|
||
Heading,
|
||
VStack,
|
||
HStack,
|
||
Text,
|
||
Button,
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalFooter,
|
||
ModalBody,
|
||
ModalCloseButton,
|
||
useDisclosure,
|
||
Badge,
|
||
IconButton,
|
||
Flex,
|
||
Grid,
|
||
useColorModeValue,
|
||
Divider,
|
||
Tooltip,
|
||
Icon,
|
||
Input,
|
||
FormControl,
|
||
FormLabel,
|
||
Textarea,
|
||
Select,
|
||
useToast,
|
||
Spinner,
|
||
Center,
|
||
Tag,
|
||
TagLabel,
|
||
TagLeftIcon,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
FiCalendar,
|
||
FiClock,
|
||
FiStar,
|
||
FiTrendingUp,
|
||
FiPlus,
|
||
FiEdit2,
|
||
FiTrash2,
|
||
FiSave,
|
||
FiX,
|
||
} from 'react-icons/fi';
|
||
import FullCalendar from '@fullcalendar/react';
|
||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||
import interactionPlugin from '@fullcalendar/interaction';
|
||
import dayjs from 'dayjs';
|
||
import 'dayjs/locale/zh-cn';
|
||
import { logger } from '../../../utils/logger';
|
||
import { getApiBase } from '../../../utils/apiConfig';
|
||
import './InvestmentCalendar.css';
|
||
|
||
dayjs.locale('zh-cn');
|
||
|
||
export default function InvestmentCalendarChakra() {
|
||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||
const toast = useToast();
|
||
|
||
// 颜色主题
|
||
const bgColor = useColorModeValue('white', 'gray.800');
|
||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||
const textColor = useColorModeValue('gray.700', 'white');
|
||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||
|
||
const [events, setEvents] = useState([]);
|
||
const [selectedDate, setSelectedDate] = useState(null);
|
||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [newEvent, setNewEvent] = useState({
|
||
title: '',
|
||
description: '',
|
||
type: 'plan',
|
||
importance: 3,
|
||
stocks: '',
|
||
});
|
||
|
||
// 加载事件数据
|
||
const loadEvents = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const base = getApiBase();
|
||
|
||
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
|
||
const userResponse = await fetch(base + '/api/account/calendar/events', {
|
||
credentials: 'include'
|
||
});
|
||
|
||
if (userResponse.ok) {
|
||
const userData = await userResponse.json();
|
||
if (userData.success) {
|
||
const allEvents = (userData.data || []).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' : '#8B5CF6',
|
||
borderColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||
extendedProps: {
|
||
...event,
|
||
isSystem: event.source === 'future',
|
||
}
|
||
}));
|
||
|
||
setEvents(allEvents);
|
||
logger.debug('InvestmentCalendar', '日历事件加载成功', {
|
||
count: allEvents.length
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('InvestmentCalendar', 'loadEvents', error);
|
||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []); // ✅ 移除 toast 依赖
|
||
|
||
useEffect(() => {
|
||
loadEvents();
|
||
}, [loadEvents]);
|
||
|
||
// 根据重要性获取颜色
|
||
const getEventColor = (importance) => {
|
||
if (importance >= 5) return '#E53E3E'; // 红色
|
||
if (importance >= 4) return '#ED8936'; // 橙色
|
||
if (importance >= 3) return '#ECC94B'; // 黄色
|
||
if (importance >= 2) return '#48BB78'; // 绿色
|
||
return '#3182CE'; // 蓝色
|
||
};
|
||
|
||
// 处理日期点击
|
||
const handleDateClick = (info) => {
|
||
const clickedDate = dayjs(info.date);
|
||
setSelectedDate(clickedDate);
|
||
|
||
// 筛选当天的事件
|
||
const dayEvents = events.filter(event =>
|
||
dayjs(event.start).isSame(clickedDate, 'day')
|
||
);
|
||
setSelectedDateEvents(dayEvents);
|
||
onOpen();
|
||
};
|
||
|
||
// 处理事件点击
|
||
const handleEventClick = (info) => {
|
||
const event = info.event;
|
||
const clickedDate = dayjs(event.start);
|
||
setSelectedDate(clickedDate);
|
||
setSelectedDateEvents([{
|
||
title: event.title,
|
||
start: event.start,
|
||
extendedProps: {
|
||
...event.extendedProps,
|
||
},
|
||
}]);
|
||
onOpen();
|
||
};
|
||
|
||
// 添加新事件
|
||
const handleAddEvent = async () => {
|
||
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('InvestmentCalendar', '添加事件成功', {
|
||
eventTitle: eventData.title,
|
||
eventDate: eventData.event_date
|
||
});
|
||
toast({
|
||
title: '添加成功',
|
||
description: '投资计划已添加',
|
||
status: 'success',
|
||
duration: 3000,
|
||
});
|
||
onAddClose();
|
||
loadEvents();
|
||
setNewEvent({
|
||
title: '',
|
||
description: '',
|
||
type: 'plan',
|
||
importance: 3,
|
||
stocks: '',
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('InvestmentCalendar', 'handleAddEvent', error, {
|
||
eventTitle: newEvent?.title
|
||
});
|
||
toast({
|
||
title: '添加失败',
|
||
description: '无法添加投资计划',
|
||
status: 'error',
|
||
duration: 3000,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 删除用户事件
|
||
const handleDeleteEvent = async (eventId) => {
|
||
if (!eventId) {
|
||
logger.warn('InvestmentCalendar', '删除事件失败', '缺少事件 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('InvestmentCalendar', '删除事件成功', { eventId });
|
||
toast({
|
||
title: '删除成功',
|
||
status: 'success',
|
||
duration: 2000,
|
||
});
|
||
loadEvents();
|
||
}
|
||
} catch (error) {
|
||
logger.error('InvestmentCalendar', 'handleDeleteEvent', error, { eventId });
|
||
toast({
|
||
title: '删除失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card bg={bgColor} shadow="md">
|
||
<CardHeader pb={4}>
|
||
<Flex justify="space-between" align="center">
|
||
<HStack>
|
||
<Icon as={FiCalendar} color="orange.500" boxSize={5} />
|
||
<Heading size="md">投资日历</Heading>
|
||
</HStack>
|
||
<Button
|
||
size="sm"
|
||
colorScheme="blue"
|
||
leftIcon={<FiPlus />}
|
||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||
>
|
||
添加计划
|
||
</Button>
|
||
</Flex>
|
||
</CardHeader>
|
||
<CardBody pt={0}>
|
||
{loading ? (
|
||
<Center h="560px">
|
||
<Spinner size="xl" color="blue.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={events}
|
||
dateClick={handleDateClick}
|
||
eventClick={handleEventClick}
|
||
height="100%"
|
||
dayMaxEvents={3}
|
||
moreLinkText="更多"
|
||
buttonText={{
|
||
today: '今天',
|
||
month: '月',
|
||
week: '周'
|
||
}}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</CardBody>
|
||
|
||
{/* 查看事件详情 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="blue"
|
||
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.extendedProps?.isSystem ? (
|
||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||
) : (
|
||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||
)}
|
||
</HStack>
|
||
<HStack spacing={2}>
|
||
<Icon as={FiStar} color="yellow.500" />
|
||
<Text fontSize="sm" color={secondaryText}>
|
||
重要度: {event.extendedProps?.importance || 3}/5
|
||
</Text>
|
||
</HStack>
|
||
</VStack>
|
||
{!event.extendedProps?.isSystem && (
|
||
<IconButton
|
||
icon={<FiTrash2 />}
|
||
size="sm"
|
||
variant="ghost"
|
||
colorScheme="red"
|
||
onClick={() => handleDeleteEvent(event.extendedProps?.id)}
|
||
/>
|
||
)}
|
||
</Flex>
|
||
|
||
{event.extendedProps?.description && (
|
||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||
{event.extendedProps.description}
|
||
</Text>
|
||
)}
|
||
|
||
{event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && (
|
||
<HStack spacing={2} flexWrap="wrap">
|
||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||
{event.extendedProps.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 })}
|
||
>
|
||
<option value="plan">投资计划</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="blue"
|
||
onClick={handleAddEvent}
|
||
isDisabled={!newEvent.title}
|
||
>
|
||
添加
|
||
</Button>
|
||
</ModalFooter>
|
||
</ModalContent>
|
||
</Modal>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|