refactor(Center): 全面优化个人中心模块
- 目录重命名:Dashboard → Center(匹配路由 /home/center) - 删除遗留代码:Default.js、InvestmentPlansAndReviews.js、InvestmentCalendarChakra.js(共 2596 行) - 创建 src/types/center.ts 类型定义(15+ 接口) - 性能优化: - 创建 useCenterColors Hook 封装 7 个 useColorModeValue - 创建 utils/formatters.ts 提取纯函数 - 修复 loadRealtimeQuotes 的 useCallback 依赖项 - InvestmentPlanningCenter 添加 useMemo 缓存 - TypeScript 迁移:Center.js → Center.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
203
src/views/Center/components/CalendarPanel.tsx
Normal file
203
src/views/Center/components/CalendarPanel.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* CalendarPanel - 投资日历面板组件
|
||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
||||
*/
|
||||
|
||||
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { DateClickArg } from '@fullcalendar/interaction';
|
||||
import type { EventClickArg } from '@fullcalendar/core';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { EventDetailModal } from './EventDetailModal';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
// 懒加载投资日历组件
|
||||
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 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,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
} = usePlanningData();
|
||||
|
||||
// 弹窗状态(统一使用 useState)
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
|
||||
// 转换数据为 FullCalendar 格式(使用 useMemo 缓存)
|
||||
const calendarEvents: CalendarEvent[] = useMemo(() =>
|
||||
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',
|
||||
}
|
||||
})), [allEvents]);
|
||||
|
||||
// 抽取公共的打开事件详情函数
|
||||
const openEventDetail = useCallback((date: Date | null): void => {
|
||||
if (!date) return;
|
||||
const clickedDate = dayjs(date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
setIsDetailModalOpen(true);
|
||||
}, [allEvents]);
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = useCallback((info: DateClickArg): void => {
|
||||
openEventDetail(info.date);
|
||||
}, [openEventDetail]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((info: EventClickArg): void => {
|
||||
openEventDetail(info.event.start);
|
||||
}, [openEventDetail]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
height={{ base: '380px', md: '560px' }}
|
||||
sx={{
|
||||
// FullCalendar 按钮样式覆盖(与日历视图按钮颜色一致)
|
||||
'.fc .fc-button': {
|
||||
backgroundColor: '#805AD5 !important',
|
||||
borderColor: '#805AD5 !important',
|
||||
color: '#fff !important',
|
||||
'&:hover': {
|
||||
backgroundColor: '#6B46C1 !important',
|
||||
borderColor: '#6B46C1 !important',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#6B46C1 !important',
|
||||
borderColor: '#6B46C1 !important',
|
||||
opacity: '1 !important',
|
||||
},
|
||||
},
|
||||
// 今天日期高亮边框
|
||||
'.fc-daygrid-day.fc-day-today': {
|
||||
border: '2px solid #805AD5 !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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={1}
|
||||
moreLinkText="+更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
titleFormat={{ year: 'numeric', month: 'long' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 查看事件详情 Modal */}
|
||||
<EventDetailModal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
selectedDate={selectedDate}
|
||||
events={selectedDateEvents}
|
||||
borderColor={borderColor}
|
||||
secondaryText={secondaryText}
|
||||
onNavigateToPlan={() => {
|
||||
setViewMode('list');
|
||||
setListTab(0);
|
||||
}}
|
||||
onNavigateToReview={() => {
|
||||
setViewMode('list');
|
||||
setListTab(1);
|
||||
}}
|
||||
onOpenInvestmentCalendar={() => {
|
||||
setIsInvestmentCalendarOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 投资日历 Modal */}
|
||||
{isInvestmentCalendarOpen && (
|
||||
<Modal
|
||||
isOpen={isInvestmentCalendarOpen}
|
||||
onClose={() => setIsInvestmentCalendarOpen(false)}
|
||||
size={{ base: 'full', md: '6xl' }}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW={{ base: '100%', md: '1200px' }} mx={{ base: 0, md: 4 }}>
|
||||
<ModalHeader fontSize={{ base: 'md', md: 'lg' }} py={{ base: 3, md: 4 }}>投资日历</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<Suspense fallback={<Center py={{ base: 6, md: 8 }}><Spinner size={{ base: 'lg', md: 'xl' }} color="blue.500" /></Center>}>
|
||||
<InvestmentCalendar />
|
||||
</Suspense>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
296
src/views/Center/components/EventCard.tsx
Normal file
296
src/views/Center/components/EventCard.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* EventCard - 统一的投资事件卡片组件
|
||||
*
|
||||
* 通过 variant 属性控制两种显示模式:
|
||||
* - list: 列表模式(EventPanel 中使用,带编辑/删除按钮)
|
||||
* - detail: 详情模式(日历弹窗中使用,显示类型徽章)
|
||||
*
|
||||
* 两种模式都支持:
|
||||
* - 标题显示
|
||||
* - 描述内容展开/收起
|
||||
* - 股票标签显示
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiFileText,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import type { InvestmentEvent, EventType, EventSource } from '@/types';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 卡片变体类型
|
||||
*/
|
||||
export type EventCardVariant = 'list' | 'detail';
|
||||
|
||||
/**
|
||||
* 类型信息接口
|
||||
*/
|
||||
interface TypeInfo {
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类型信息
|
||||
*/
|
||||
const getTypeInfo = (type?: EventType, source?: EventSource): TypeInfo => {
|
||||
if (source === 'future') {
|
||||
return { color: 'blue', text: '系统事件' };
|
||||
}
|
||||
if (type === 'plan') {
|
||||
return { color: 'purple', text: '我的计划' };
|
||||
}
|
||||
if (type === 'review') {
|
||||
return { color: 'green', text: '我的复盘' };
|
||||
}
|
||||
return { color: 'gray', text: '未知类型' };
|
||||
};
|
||||
|
||||
/**
|
||||
* EventCard Props
|
||||
*/
|
||||
export interface EventCardProps {
|
||||
/** 事件数据 */
|
||||
event: InvestmentEvent;
|
||||
/** 卡片变体: list(列表模式) | detail(详情模式) */
|
||||
variant?: EventCardVariant;
|
||||
/** 主题颜色(list 模式) */
|
||||
colorScheme?: string;
|
||||
/** 显示标签(用于 aria-label) */
|
||||
label?: string;
|
||||
/** 主要文本颜色 */
|
||||
textColor?: string;
|
||||
/** 次要文本颜色 */
|
||||
secondaryText?: string;
|
||||
/** 卡片背景色(list 模式) */
|
||||
cardBg?: string;
|
||||
/** 边框颜色(detail 模式) */
|
||||
borderColor?: string;
|
||||
/** 编辑回调(list 模式) */
|
||||
onEdit?: (event: InvestmentEvent) => void;
|
||||
/** 删除回调(list 模式) */
|
||||
onDelete?: (id: number) => void;
|
||||
}
|
||||
|
||||
/** 描述最大显示行数 */
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* EventCard 组件
|
||||
*/
|
||||
export const EventCard = memo<EventCardProps>(({
|
||||
event,
|
||||
variant = 'list',
|
||||
colorScheme = 'purple',
|
||||
label = '事件',
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
// 展开/收起状态
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isOverflow, setIsOverflow] = useState(false);
|
||||
const descriptionRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// 默认颜色值(使用 hooks)
|
||||
const defaultTextColor = useColorModeValue('gray.700', 'white');
|
||||
const defaultSecondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const defaultCardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const defaultBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 使用传入的值或默认值
|
||||
const finalTextColor = textColor || defaultTextColor;
|
||||
const finalSecondaryText = secondaryText || defaultSecondaryText;
|
||||
const finalCardBg = cardBg || defaultCardBg;
|
||||
const finalBorderColor = borderColor || defaultBorderColor;
|
||||
|
||||
// 获取描述内容
|
||||
const description = event.description || event.content || '';
|
||||
|
||||
// 检测描述是否溢出
|
||||
useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (el && description) {
|
||||
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
|
||||
const maxHeight = lineHeight * MAX_LINES;
|
||||
setIsOverflow(el.scrollHeight > maxHeight + 5);
|
||||
} else {
|
||||
setIsOverflow(false);
|
||||
}
|
||||
}, [description]);
|
||||
|
||||
// 获取类型信息
|
||||
const typeInfo = getTypeInfo(event.type, event.source);
|
||||
|
||||
// 是否为 list 模式
|
||||
const isListMode = variant === 'list';
|
||||
|
||||
// 渲染容器
|
||||
const renderContainer = (children: React.ReactNode) => {
|
||||
if (isListMode) {
|
||||
return (
|
||||
<Card
|
||||
bg={finalCardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody p={{ base: 2, md: 3 }}>
|
||||
{children}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
p={{ base: 3, md: 4 }}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={finalBorderColor}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return renderContainer(
|
||||
<VStack align="stretch" spacing={{ base: 2, md: 3 }}>
|
||||
{/* 头部区域:标题 + 徽章 + 操作按钮 */}
|
||||
<Flex justify="space-between" align="start" gap={{ base: 1, md: 2 }}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
{/* 标题行 */}
|
||||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
|
||||
<Icon as={FiFileText} color={`${colorScheme}.500`} boxSize={{ base: 4, md: 5 }} />
|
||||
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
|
||||
{event.title}
|
||||
</Text>
|
||||
{/* detail 模式显示类型徽章 */}
|
||||
{!isListMode && (
|
||||
<Badge colorScheme={typeInfo.color} variant="subtle" fontSize={{ base: 'xs', md: 'sm' }}>
|
||||
{typeInfo.text}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* list 模式显示日期 */}
|
||||
{isListMode && (
|
||||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
|
||||
<Icon as={FiCalendar} boxSize={{ base: 2.5, md: 3 }} color={finalSecondaryText} />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
|
||||
{dayjs(event.event_date || event.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* list 模式显示编辑/删除按钮 */}
|
||||
{isListMode && (onEdit || onDelete) && (
|
||||
<HStack spacing={{ base: 0, md: 1 }}>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onEdit(event)}
|
||||
aria-label={`编辑${label}`}
|
||||
/>
|
||||
)}
|
||||
{onDelete && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => onDelete(event.id)}
|
||||
aria-label={`删除${label}`}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 描述内容(可展开/收起) */}
|
||||
{description && (
|
||||
<Box>
|
||||
<Text
|
||||
ref={descriptionRef}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
color={finalTextColor}
|
||||
noOfLines={isExpanded ? undefined : MAX_LINES}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
{isOverflow && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme={colorScheme}
|
||||
mt={1}
|
||||
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 股票标签 */}
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap" gap={1}>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
|
||||
相关股票:
|
||||
</Text>
|
||||
{event.stocks.map((stock, idx) => {
|
||||
// 兼容两种格式:对象 {code, name} 或字符串
|
||||
const stockCode = typeof stock === 'string' ? stock : stock.code;
|
||||
const stockName = typeof stock === 'string' ? stock : stock.name;
|
||||
const displayText = typeof stock === 'string' ? stock : `${stock.name}(${stock.code})`;
|
||||
return (
|
||||
<Tag key={stockCode || idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{displayText}</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
|
||||
EventCard.displayName = 'EventCard';
|
||||
|
||||
export default EventCard;
|
||||
99
src/views/Center/components/EventDetailModal.tsx
Normal file
99
src/views/Center/components/EventDetailModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* EventDetailModal - 事件详情弹窗组件
|
||||
* 用于展示某一天的所有投资事件
|
||||
* 使用 Ant Design 实现
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Space } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { EventCard } from './EventCard';
|
||||
import { EventEmptyState } from './EventEmptyState';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
|
||||
/**
|
||||
* EventDetailModal Props
|
||||
*/
|
||||
export interface EventDetailModalProps {
|
||||
/** 是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 选中的日期 */
|
||||
selectedDate: Dayjs | null;
|
||||
/** 选中日期的事件列表 */
|
||||
events: InvestmentEvent[];
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 次要文字颜色 */
|
||||
secondaryText?: string;
|
||||
/** 导航到计划列表 */
|
||||
onNavigateToPlan?: () => void;
|
||||
/** 导航到复盘列表 */
|
||||
onNavigateToReview?: () => void;
|
||||
/** 打开投资日历 */
|
||||
onOpenInvestmentCalendar?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventDetailModal 组件
|
||||
*/
|
||||
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedDate,
|
||||
events,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
onNavigateToPlan,
|
||||
onNavigateToReview,
|
||||
onOpenInvestmentCalendar,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
title={`${selectedDate?.format('YYYY年MM月DD日') || ''} 的事件`}
|
||||
footer={null}
|
||||
width={600}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
centered
|
||||
styles={{
|
||||
body: { paddingTop: 16, paddingBottom: 24 },
|
||||
}}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<EventEmptyState
|
||||
onNavigateToPlan={() => {
|
||||
onClose();
|
||||
onNavigateToPlan?.();
|
||||
}}
|
||||
onNavigateToReview={() => {
|
||||
onClose();
|
||||
onNavigateToReview?.();
|
||||
}}
|
||||
onOpenInvestmentCalendar={() => {
|
||||
onClose();
|
||||
onOpenInvestmentCalendar?.();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{events.map((event, idx) => (
|
||||
<EventCard
|
||||
key={idx}
|
||||
event={event}
|
||||
variant="detail"
|
||||
borderColor={borderColor}
|
||||
secondaryText={secondaryText}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
94
src/views/Center/components/EventEmptyState.tsx
Normal file
94
src/views/Center/components/EventEmptyState.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* EventEmptyState - 事件空状态组件
|
||||
* 用于展示日历无事件时的引导提示
|
||||
* 使用 Ant Design 实现
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography, Space, Empty } from 'antd';
|
||||
import { CalendarOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text, Link } = Typography;
|
||||
|
||||
/**
|
||||
* EventEmptyState Props
|
||||
*/
|
||||
export interface EventEmptyStateProps {
|
||||
/** 空状态提示文字 */
|
||||
message?: string;
|
||||
/** 导航到计划列表 */
|
||||
onNavigateToPlan?: () => void;
|
||||
/** 导航到复盘列表 */
|
||||
onNavigateToReview?: () => void;
|
||||
/** 打开投资日历 */
|
||||
onOpenInvestmentCalendar?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventEmptyState 组件
|
||||
*/
|
||||
export const EventEmptyState: React.FC<EventEmptyStateProps> = ({
|
||||
message = '当天暂无事件',
|
||||
onNavigateToPlan,
|
||||
onNavigateToReview,
|
||||
onOpenInvestmentCalendar,
|
||||
}) => {
|
||||
// 是否显示引导链接
|
||||
const showGuide = onNavigateToPlan || onNavigateToReview || onOpenInvestmentCalendar;
|
||||
|
||||
// 渲染描述内容
|
||||
const renderDescription = () => {
|
||||
if (!showGuide) {
|
||||
return <Text type="secondary">{message}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={4} style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary">{message}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
可在
|
||||
{onNavigateToPlan && (
|
||||
<Link
|
||||
style={{ margin: '0 4px', color: '#8B5CF6' }}
|
||||
onClick={onNavigateToPlan}
|
||||
>
|
||||
计划
|
||||
</Link>
|
||||
)}
|
||||
{onNavigateToPlan && onNavigateToReview && '或'}
|
||||
{onNavigateToReview && (
|
||||
<Link
|
||||
style={{ margin: '0 4px', color: '#38A169' }}
|
||||
onClick={onNavigateToReview}
|
||||
>
|
||||
复盘
|
||||
</Link>
|
||||
)}
|
||||
添加
|
||||
{onOpenInvestmentCalendar && (
|
||||
<>
|
||||
,或关注
|
||||
<Link
|
||||
style={{ margin: '0 4px', color: '#3182CE' }}
|
||||
onClick={onOpenInvestmentCalendar}
|
||||
>
|
||||
投资日历
|
||||
</Link>
|
||||
中的未来事件
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Empty
|
||||
image={<CalendarOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />}
|
||||
imageStyle={{ height: 60 }}
|
||||
description={renderDescription()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventEmptyState;
|
||||
198
src/views/Center/components/EventFormModal.less
Normal file
198
src/views/Center/components/EventFormModal.less
Normal file
@@ -0,0 +1,198 @@
|
||||
/* EventFormModal.less - 投资计划/复盘弹窗响应式样式 */
|
||||
|
||||
// ==================== 变量定义 ====================
|
||||
@mobile-breakpoint: 768px;
|
||||
@modal-border-radius-mobile: 12px;
|
||||
@modal-border-radius-desktop: 8px;
|
||||
|
||||
// 间距
|
||||
@spacing-xs: 4px;
|
||||
@spacing-sm: 8px;
|
||||
@spacing-md: 12px;
|
||||
@spacing-lg: 16px;
|
||||
@spacing-xl: 20px;
|
||||
@spacing-xxl: 24px;
|
||||
|
||||
// 字体大小
|
||||
@font-size-xs: 12px;
|
||||
@font-size-sm: 14px;
|
||||
@font-size-md: 16px;
|
||||
|
||||
// 颜色
|
||||
@color-border: #f0f0f0;
|
||||
@color-text-secondary: #999;
|
||||
@color-error: #ff4d4f;
|
||||
|
||||
// ==================== 主样式 ====================
|
||||
.event-form-modal {
|
||||
// Modal 整体
|
||||
.ant-modal-content {
|
||||
border-radius: @modal-border-radius-desktop;
|
||||
}
|
||||
|
||||
// Modal 标题放大加粗
|
||||
.ant-modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: @spacing-xxl;
|
||||
padding-top: 36px; // 增加标题与表单间距
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: @spacing-xl;
|
||||
}
|
||||
|
||||
// 表单标签加粗,左对齐
|
||||
.ant-form-item-label {
|
||||
text-align: left !important;
|
||||
|
||||
> label {
|
||||
font-weight: 600 !important;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
// 字符计数样式
|
||||
.ant-input-textarea-show-count::after {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
// 日期选择器全宽
|
||||
.ant-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 股票标签样式
|
||||
.ant-tag {
|
||||
margin: 2px;
|
||||
border-radius: @spacing-xs;
|
||||
}
|
||||
|
||||
// 模板按钮组
|
||||
.template-buttons {
|
||||
.ant-btn {
|
||||
font-size: @font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作栏布局
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.ant-btn-loading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// 错误状态动画
|
||||
.ant-form-item-has-error {
|
||||
.ant-input,
|
||||
.ant-picker,
|
||||
.ant-select-selector {
|
||||
animation: shake 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 移动端适配 ====================
|
||||
@media (max-width: @mobile-breakpoint) {
|
||||
.event-form-modal {
|
||||
// Modal 整体尺寸
|
||||
.ant-modal {
|
||||
width: calc(100vw - 32px) !important;
|
||||
max-width: 100% !important;
|
||||
margin: @spacing-lg auto;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: @modal-border-radius-mobile;
|
||||
}
|
||||
|
||||
// Modal 头部
|
||||
.ant-modal-header {
|
||||
padding: @spacing-md @spacing-lg;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: @font-size-md;
|
||||
}
|
||||
|
||||
// Modal 内容区域
|
||||
.ant-modal-body {
|
||||
padding: @spacing-lg;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// Modal 底部
|
||||
.ant-modal-footer {
|
||||
padding: @spacing-md @spacing-lg;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid @color-border;
|
||||
}
|
||||
|
||||
// 表单项间距
|
||||
.ant-form-item {
|
||||
margin-bottom: @spacing-lg;
|
||||
}
|
||||
|
||||
// 表单标签
|
||||
.ant-form-item-label > label {
|
||||
font-size: @font-size-sm;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// 输入框字体 - iOS 防止缩放需要 16px
|
||||
.ant-input,
|
||||
.ant-picker-input > input,
|
||||
.ant-select-selection-search-input {
|
||||
font-size: @font-size-md !important;
|
||||
}
|
||||
|
||||
// 文本域高度
|
||||
.ant-input-textarea textarea {
|
||||
font-size: @font-size-md !important;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
// 模板按钮组
|
||||
.template-buttons .ant-btn {
|
||||
font-size: @font-size-xs;
|
||||
padding: 2px @spacing-sm;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
// 股票选择器
|
||||
.ant-select-selector {
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
.ant-modal-footer .ant-btn {
|
||||
font-size: @font-size-md;
|
||||
height: 40px;
|
||||
border-radius: @spacing-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 动画 ====================
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
577
src/views/Center/components/EventFormModal.tsx
Normal file
577
src/views/Center/components/EventFormModal.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* EventFormModal - 通用事件表单弹窗组件 (Ant Design 重构版)
|
||||
* 用于新建/编辑投资计划、复盘
|
||||
*
|
||||
* 功能特性:
|
||||
* - 使用 Ant Design 组件
|
||||
* - 简化字段:标题、日期、描述、关联股票
|
||||
* - 计划/复盘模板系统
|
||||
* - 股票多选组件带智能搜索
|
||||
* - Ctrl + Enter 快捷键保存
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
DatePicker,
|
||||
Select,
|
||||
Button,
|
||||
Tag,
|
||||
Divider,
|
||||
message,
|
||||
Space,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import type { SelectProps } from 'antd';
|
||||
import {
|
||||
BulbOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import './EventFormModal.less';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import { loadWatchlist, loadAllStocks } from '@/store/slices/stockSlice';
|
||||
import { stockService } from '@/services/stockService';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
/**
|
||||
* 股票选项接口
|
||||
*/
|
||||
interface StockOption {
|
||||
value: string;
|
||||
label: string;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单数据接口
|
||||
*/
|
||||
interface FormData {
|
||||
title: string;
|
||||
date: dayjs.Dayjs;
|
||||
content: string;
|
||||
stocks: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板类型
|
||||
*/
|
||||
interface Template {
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划模板
|
||||
*/
|
||||
const PLAN_TEMPLATES: Template[] = [
|
||||
{
|
||||
label: '目标',
|
||||
content: '【投资目标】\n\n',
|
||||
},
|
||||
{
|
||||
label: '策略',
|
||||
content: '【交易策略】\n\n',
|
||||
},
|
||||
{
|
||||
label: '风险控制',
|
||||
content: '【风险控制】\n- 止损位:\n- 仓位控制:\n',
|
||||
},
|
||||
{
|
||||
label: '时间规划',
|
||||
content: '【时间规划】\n- 建仓时机:\n- 持仓周期:\n',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 复盘模板
|
||||
*/
|
||||
const REVIEW_TEMPLATES: Template[] = [
|
||||
{
|
||||
label: '操作回顾',
|
||||
content: '【操作回顾】\n- 买入操作:\n- 卖出操作:\n',
|
||||
},
|
||||
{
|
||||
label: '盈亏分析',
|
||||
content: '【盈亏分析】\n- 盈亏金额:\n- 收益率:\n- 主要原因:\n',
|
||||
},
|
||||
{
|
||||
label: '经验总结',
|
||||
content: '【经验总结】\n- 做对的地方:\n- 做错的地方:\n',
|
||||
},
|
||||
{
|
||||
label: '后续调整',
|
||||
content: '【后续调整】\n- 策略调整:\n- 仓位调整:\n',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Redux state 类型
|
||||
*/
|
||||
interface RootState {
|
||||
stock: {
|
||||
watchlist: Array<{ stock_code: string; stock_name: string }>;
|
||||
allStocks: Array<{ code: string; name: string }>;
|
||||
loading: {
|
||||
watchlist: boolean;
|
||||
allStocks: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFormModal Props
|
||||
*/
|
||||
export interface EventFormModalProps {
|
||||
/** 弹窗是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 模式:新建或编辑 */
|
||||
mode: 'create' | 'edit';
|
||||
/** 事件类型(新建时使用) */
|
||||
eventType?: EventType;
|
||||
/** 初始日期(新建时使用,如从日历点击) */
|
||||
initialDate?: string;
|
||||
/** 编辑时的原始事件数据 */
|
||||
editingEvent?: InvestmentEvent | null;
|
||||
/** 保存成功回调 */
|
||||
onSuccess: () => void;
|
||||
/** 主题颜色 */
|
||||
colorScheme?: string;
|
||||
/** 显示标签(如 "计划"、"复盘"、"事件") */
|
||||
label?: string;
|
||||
/** 是否显示日期选择器 */
|
||||
showDatePicker?: boolean;
|
||||
/** 是否显示类型选择 */
|
||||
showTypeSelect?: boolean;
|
||||
/** 是否显示状态选择 */
|
||||
showStatusSelect?: boolean;
|
||||
/** 是否显示重要度选择 */
|
||||
showImportance?: boolean;
|
||||
/** 是否显示标签输入 */
|
||||
showTags?: boolean;
|
||||
/** 股票输入方式:'tag' 为标签形式,'text' 为逗号分隔文本 */
|
||||
stockInputMode?: 'tag' | 'text';
|
||||
/** API 端点 */
|
||||
apiEndpoint?: 'investment-plans' | 'calendar/events';
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFormModal 组件
|
||||
*/
|
||||
export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
eventType = 'plan',
|
||||
initialDate,
|
||||
editingEvent,
|
||||
onSuccess,
|
||||
label = '事件',
|
||||
apiEndpoint = 'investment-plans',
|
||||
}) => {
|
||||
const { loadAllData } = usePlanningData();
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm<FormData>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [stockOptions, setStockOptions] = useState<StockOption[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 从 Redux 获取自选股和全部股票列表
|
||||
const watchlist = useSelector((state: RootState) => state.stock.watchlist);
|
||||
const allStocks = useSelector((state: RootState) => state.stock.allStocks);
|
||||
const watchlistLoading = useSelector((state: RootState) => state.stock.loading.watchlist);
|
||||
const allStocksLoading = useSelector((state: RootState) => state.stock.loading.allStocks);
|
||||
|
||||
// 将自选股转换为 StockOption 格式
|
||||
const watchlistOptions = useMemo<StockOption[]>(() => {
|
||||
return watchlist.map(item => ({
|
||||
value: item.stock_code,
|
||||
label: `${item.stock_name}(${item.stock_code})`,
|
||||
stock_code: item.stock_code,
|
||||
stock_name: item.stock_name,
|
||||
}));
|
||||
}, [watchlist]);
|
||||
|
||||
// 获取模板列表
|
||||
const templates = eventType === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES;
|
||||
|
||||
// 生成默认模板内容
|
||||
const getDefaultContent = (type: EventType): string => {
|
||||
const templateList = type === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES;
|
||||
return templateList.map(t => t.content).join('\n');
|
||||
};
|
||||
|
||||
// 弹窗打开时加载数据
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 加载自选股列表
|
||||
dispatch(loadWatchlist());
|
||||
// 加载全部股票列表(用于模糊搜索)
|
||||
dispatch(loadAllStocks());
|
||||
}
|
||||
}, [isOpen, dispatch]);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && editingEvent) {
|
||||
// 将 stocks 转换为代码数组(兼容对象和字符串格式)
|
||||
const stockCodes = (editingEvent.stocks || []).map(stock =>
|
||||
typeof stock === 'string' ? stock : stock.code
|
||||
);
|
||||
form.setFieldsValue({
|
||||
title: editingEvent.title,
|
||||
date: dayjs(editingEvent.event_date || editingEvent.date),
|
||||
content: editingEvent.description || editingEvent.content || '',
|
||||
stocks: stockCodes,
|
||||
});
|
||||
} else {
|
||||
// 新建模式,重置表单并预填充模板内容
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
date: initialDate ? dayjs(initialDate) : dayjs(),
|
||||
stocks: [],
|
||||
content: getDefaultContent(eventType),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, editingEvent, initialDate, form, eventType]);
|
||||
|
||||
// 股票搜索(前端模糊搜索)
|
||||
const handleStockSearch = useCallback((value: string) => {
|
||||
setSearchText(value);
|
||||
|
||||
if (!value || value.length < 1) {
|
||||
// 无搜索词时显示自选股列表
|
||||
setStockOptions(watchlistOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 stockService.fuzzySearch 进行前端模糊搜索
|
||||
const results = stockService.fuzzySearch(value, allStocks, 10);
|
||||
const options: StockOption[] = results.map(stock => ({
|
||||
value: stock.code,
|
||||
label: `${stock.name}(${stock.code})`,
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
}));
|
||||
|
||||
setStockOptions(options.length > 0 ? options : watchlistOptions);
|
||||
}, [allStocks, watchlistOptions]);
|
||||
|
||||
// 保存数据
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
const base = getApiBase();
|
||||
|
||||
// 将选中的股票代码转换为包含名称的对象数组
|
||||
const stocksWithNames = (values.stocks || []).map((code: string) => {
|
||||
const stockInfo = allStocks.find(s => s.code === code);
|
||||
const watchlistInfo = watchlist.find(s => s.stock_code === code);
|
||||
return {
|
||||
code,
|
||||
name: stockInfo?.name || watchlistInfo?.stock_name || code,
|
||||
};
|
||||
});
|
||||
|
||||
// 构建请求数据
|
||||
const requestData: Record<string, unknown> = {
|
||||
title: values.title,
|
||||
content: values.content,
|
||||
date: values.date.format('YYYY-MM-DD'),
|
||||
type: eventType,
|
||||
stocks: stocksWithNames,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// 根据 API 端点调整字段名
|
||||
if (apiEndpoint === 'calendar/events') {
|
||||
requestData.description = values.content;
|
||||
requestData.event_date = values.date.format('YYYY-MM-DD');
|
||||
delete requestData.content;
|
||||
delete requestData.date;
|
||||
}
|
||||
|
||||
const url = mode === 'edit' && editingEvent
|
||||
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
|
||||
: `${base}/api/account/${apiEndpoint}`;
|
||||
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
|
||||
itemId: editingEvent?.id,
|
||||
title: values.title,
|
||||
});
|
||||
message.success(mode === 'edit' ? '修改成功' : '添加成功');
|
||||
onClose();
|
||||
onSuccess();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message !== '保存失败') {
|
||||
// 表单验证错误,不显示额外提示
|
||||
return;
|
||||
}
|
||||
logger.error('EventFormModal', 'handleSave', error, {
|
||||
itemId: editingEvent?.id,
|
||||
});
|
||||
message.error('保存失败,请稍后重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]);
|
||||
|
||||
// 监听键盘快捷键 Ctrl + Enter
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isOpen && (e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, handleSave]);
|
||||
|
||||
// 插入模板
|
||||
const handleInsertTemplate = (template: Template): void => {
|
||||
const currentContent = form.getFieldValue('content') || '';
|
||||
const newContent = currentContent
|
||||
? `${currentContent}\n\n${template.content}`
|
||||
: template.content;
|
||||
form.setFieldsValue({ content: newContent });
|
||||
};
|
||||
|
||||
// 获取标题 placeholder
|
||||
const getTitlePlaceholder = (): string => {
|
||||
if (eventType === 'plan') {
|
||||
return '例如:关注AI板块';
|
||||
}
|
||||
return '例如:12月操作总结';
|
||||
};
|
||||
|
||||
// 获取内容 placeholder
|
||||
const getContentPlaceholder = (): string => {
|
||||
if (eventType === 'plan') {
|
||||
return '计划模板:\n目标:\n策略:\n风险控制:\n时间规划:';
|
||||
}
|
||||
return '复盘模板:\n操作回顾:\n盈亏分析:\n经验总结:\n后续调整:';
|
||||
};
|
||||
|
||||
// 判断是否显示自选股列表
|
||||
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
|
||||
|
||||
// 股票选择器选项配置
|
||||
const selectProps: SelectProps<string[]> = {
|
||||
mode: 'multiple',
|
||||
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
|
||||
filterOption: false,
|
||||
onSearch: handleStockSearch,
|
||||
loading: watchlistLoading || allStocksLoading,
|
||||
notFoundContent: allStocksLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '8px' }}>
|
||||
<Spin size="small" />
|
||||
<span style={{ marginLeft: 8 }}>加载中...</span>
|
||||
</div>
|
||||
) : '暂无结果',
|
||||
options: stockOptions,
|
||||
onFocus: () => {
|
||||
if (stockOptions.length === 0) {
|
||||
setStockOptions(watchlistOptions);
|
||||
}
|
||||
},
|
||||
tagRender: (props) => {
|
||||
const { label: tagLabel, closable, onClose: onTagClose } = props;
|
||||
return (
|
||||
<Tag
|
||||
color="blue"
|
||||
closable={closable}
|
||||
onClose={onTagClose}
|
||||
style={{ marginRight: 3 }}
|
||||
>
|
||||
{tagLabel}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
popupRender: (menu) => (
|
||||
<>
|
||||
{isShowingWatchlist && watchlistOptions.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px 0' }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} />
|
||||
我的自选股
|
||||
</span>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0 0' }} />
|
||||
</>
|
||||
)}
|
||||
{menu}
|
||||
{!isShowingWatchlist && searchText && (
|
||||
<>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<div style={{ padding: '0 8px 4px' }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
<BulbOutlined style={{ marginRight: 4 }} />
|
||||
搜索结果(输入代码或名称)
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
// 获取按钮文案
|
||||
const getButtonText = (): string => {
|
||||
if (mode === 'edit') {
|
||||
return eventType === 'plan' ? '更新计划' : '更新复盘';
|
||||
}
|
||||
return eventType === 'plan' ? '创建计划' : '创建复盘';
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
maskClosable={true}
|
||||
keyboard
|
||||
className="event-form-modal"
|
||||
footer={
|
||||
<div className="modal-footer">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div ref={modalContentRef}>
|
||||
{isOpen && <Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 4 }}
|
||||
wrapperCol={{ span: 20 }}
|
||||
labelAlign="left"
|
||||
requiredMark={false}
|
||||
initialValues={{
|
||||
date: dayjs(),
|
||||
stocks: [],
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={<span style={{ fontWeight: 600 }}>标题 <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入标题' },
|
||||
{ max: 50, message: '标题不能超过50个字符' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={getTitlePlaceholder()}
|
||||
maxLength={50}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 日期 */}
|
||||
<Form.Item
|
||||
name="date"
|
||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
rules={[{ required: true, message: '请选择日期' }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
allowClear={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 描述/内容 - 上下布局 */}
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
labelCol={{ span: 24 }}
|
||||
wrapperCol={{ span: 24 }}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<TextArea
|
||||
placeholder={getContentPlaceholder()}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={2000}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Space wrap size="small" className="template-buttons">
|
||||
{templates.map((template) => (
|
||||
<Button
|
||||
key={template.label}
|
||||
size="small"
|
||||
onClick={() => handleInsertTemplate(template)}
|
||||
>
|
||||
{template.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 关联股票 */}
|
||||
<Form.Item
|
||||
name="stocks"
|
||||
label={<span style={{ fontWeight: 600 }}>关联股票</span>}
|
||||
>
|
||||
<Select {...selectProps} />
|
||||
</Form.Item>
|
||||
</Form>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventFormModal;
|
||||
187
src/views/Center/components/EventPanel.tsx
Normal file
187
src/views/Center/components/EventPanel.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* EventPanel - 通用事件面板组件
|
||||
* 用于显示、编辑和管理投资计划或复盘
|
||||
*
|
||||
* 通过 props 配置差异化行为:
|
||||
* - type: 'plan' | 'review'
|
||||
* - colorScheme: 主题色
|
||||
* - label: 显示文案
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
VStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiFileText } from 'react-icons/fi';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { EventFormModal } from './EventFormModal';
|
||||
import { EventCard } from './EventCard';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
/**
|
||||
* EventPanel Props
|
||||
*/
|
||||
export interface EventPanelProps {
|
||||
/** 事件类型 */
|
||||
type: 'plan' | 'review';
|
||||
/** 主题颜色 */
|
||||
colorScheme: string;
|
||||
/** 显示标签(如 "计划" 或 "复盘") */
|
||||
label: string;
|
||||
/** 外部触发打开模态框的计数器 */
|
||||
openModalTrigger?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventPanel 组件
|
||||
* 通用事件列表面板,显示投资计划或复盘
|
||||
*/
|
||||
export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
type,
|
||||
colorScheme,
|
||||
label,
|
||||
openModalTrigger,
|
||||
}) => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
} = usePlanningData();
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
|
||||
const prevTriggerRef = useRef<number>(openModalTrigger || 0);
|
||||
|
||||
// 筛选事件列表(按类型过滤,排除系统事件)
|
||||
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
|
||||
|
||||
// 监听外部触发打开新建模态框(修复 bug:只在值变化时触发)
|
||||
useEffect(() => {
|
||||
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
|
||||
// 只有当 trigger 值增加时才打开弹窗
|
||||
handleOpenModal(null);
|
||||
}
|
||||
prevTriggerRef.current = openModalTrigger || 0;
|
||||
}, [openModalTrigger]);
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setModalMode('edit');
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setModalMode('create');
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
const handleCloseModal = (): void => {
|
||||
setIsModalOpen(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 useCallback 优化回调函数
|
||||
const handleEdit = useCallback((item: InvestmentEvent) => {
|
||||
handleOpenModal(item);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color={`${colorScheme}.500`} />
|
||||
</Center>
|
||||
) : events.length === 0 ? (
|
||||
<Center py={{ base: 6, md: 8 }}>
|
||||
<VStack spacing={{ base: 2, md: 3 }}>
|
||||
<Icon as={FiFileText} boxSize={{ base: 8, md: 12 }} color="gray.300" />
|
||||
<Text color={secondaryText} fontSize={{ base: 'sm', md: 'md' }}>暂无投资{label}</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={{ base: 3, md: 4 }}>
|
||||
{events.map(event => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
variant="list"
|
||||
colorScheme={colorScheme}
|
||||
label={label}
|
||||
textColor={textColor}
|
||||
secondaryText={secondaryText}
|
||||
cardBg={cardBg}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 使用通用弹窗组件 */}
|
||||
<EventFormModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
mode={modalMode}
|
||||
eventType={type}
|
||||
editingEvent={editingItem}
|
||||
onSuccess={loadAllData}
|
||||
label={label}
|
||||
apiEndpoint="investment-plans"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventPanel;
|
||||
123
src/views/Center/components/InvestmentCalendar.less
Normal file
123
src/views/Center/components/InvestmentCalendar.less
Normal file
@@ -0,0 +1,123 @@
|
||||
// src/views/Dashboard/components/InvestmentCalendar.less
|
||||
|
||||
// 颜色变量(与日历视图按钮一致的紫色)
|
||||
@primary-color: #805AD5;
|
||||
@primary-hover: #6B46C1;
|
||||
@border-color: #e2e8f0;
|
||||
@text-color: #2d3748;
|
||||
@today-bg: #e6f3ff;
|
||||
|
||||
// 暗色模式颜色
|
||||
@dark-border-color: #4a5568;
|
||||
@dark-text-color: #e2e8f0;
|
||||
@dark-today-bg: #2d3748;
|
||||
|
||||
// FullCalendar 自定义样式
|
||||
.fc {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
// 工具栏按钮紧密排列(提升优先级)
|
||||
.fc .fc-toolbar.fc-header-toolbar {
|
||||
justify-content: flex-start !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-chunk:first-child {
|
||||
display: flex !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.fc-theme-standard {
|
||||
td, th {
|
||||
border-color: @border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮样式(针对 fc-button-group 内的按钮)
|
||||
.fc .fc-toolbar .fc-button-group .fc-button {
|
||||
background-color: @primary-color !important;
|
||||
border-color: @primary-color !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
}
|
||||
|
||||
&:not(:disabled):active,
|
||||
&:not(:disabled).fc-button-active {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 今天按钮样式
|
||||
.fc .fc-toolbar .fc-today-button {
|
||||
background-color: @primary-color !important;
|
||||
border-color: @primary-color !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
}
|
||||
|
||||
// 选中状态(disabled 表示当前视图包含今天)
|
||||
&:disabled {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
opacity: 1 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期数字
|
||||
.fc-daygrid-day-number {
|
||||
color: @text-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 今天高亮
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: @today-bg !important;
|
||||
border: 2px solid @primary-color !important;
|
||||
}
|
||||
|
||||
// 事件样式
|
||||
.fc-event {
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fc-daygrid-event-dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fc-daygrid-day-events {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 暗色模式支持
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fc-theme-standard {
|
||||
td, th {
|
||||
border-color: @dark-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
color: @dark-text-color;
|
||||
}
|
||||
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: @dark-today-bg !important;
|
||||
}
|
||||
|
||||
.fc-col-header-cell-cushion,
|
||||
.fc-daygrid-day-number {
|
||||
color: @dark-text-color;
|
||||
}
|
||||
}
|
||||
270
src/views/Center/components/InvestmentPlanningCenter.tsx
Normal file
270
src/views/Center/components/InvestmentPlanningCenter.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||
* - 使用 TypeScript 提供类型安全
|
||||
*
|
||||
* 组件架构:
|
||||
* - InvestmentPlanningCenter (主组件)
|
||||
* - CalendarPanel (日历面板,懒加载)
|
||||
* - EventPanel (通用事件面板,用于计划和复盘)
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
HStack,
|
||||
Flex,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Spinner,
|
||||
Center,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiTarget,
|
||||
FiFileText,
|
||||
FiList,
|
||||
FiPlus,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import { PlanningDataProvider } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
// 懒加载子面板组件(实现代码分割)
|
||||
const CalendarPanel = lazy(() =>
|
||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||
);
|
||||
const EventPanel = lazy(() =>
|
||||
import('./EventPanel').then(module => ({ default: module.EventPanel }))
|
||||
);
|
||||
|
||||
/**
|
||||
* 面板加载占位符
|
||||
*/
|
||||
const PanelLoadingFallback: React.FC = () => (
|
||||
<Center py={12}>
|
||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
/**
|
||||
* InvestmentPlanningCenter 主组件
|
||||
*/
|
||||
const InvestmentPlanningCenter: React.FC = () => {
|
||||
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 cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 全局数据状态
|
||||
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('list');
|
||||
const [listTab, setListTab] = useState<number>(0); // 0: 我的计划, 1: 我的复盘
|
||||
const [openPlanModalTrigger, setOpenPlanModalTrigger] = useState<number>(0);
|
||||
const [openReviewModalTrigger, setOpenReviewModalTrigger] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
||||
*/
|
||||
const loadAllData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllEvents(data.data || []);
|
||||
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 提供给子组件的 Context 值(使用 useMemo 缓存,避免子组件不必要的重渲染)
|
||||
const contextValue: PlanningContextValue = useMemo(
|
||||
() => ({
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
}),
|
||||
[
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
]
|
||||
);
|
||||
|
||||
// 计算各类型事件数量(使用 useMemo 缓存,避免每次渲染重复遍历数组)
|
||||
const { planCount, reviewCount } = useMemo(
|
||||
() => ({
|
||||
planCount: allEvents.filter(e => e.type === 'plan').length,
|
||||
reviewCount: allEvents.filter(e => e.type === 'review').length,
|
||||
}),
|
||||
[allEvents]
|
||||
);
|
||||
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={{ base: 2, md: 4 }} px={{ base: 3, md: 5 }}>
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={2}>
|
||||
<HStack spacing={{ base: 1, md: 2 }}>
|
||||
<Icon as={FiTarget} color="purple.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>投资规划中心</Heading>
|
||||
</HStack>
|
||||
{/* 视图切换按钮组 - H5隐藏 */}
|
||||
<ButtonGroup size="sm" isAttached variant="outline" display={{ base: 'none', md: 'flex' }}>
|
||||
<Button
|
||||
leftIcon={<Icon as={FiList} boxSize={4} />}
|
||||
colorScheme={viewMode === 'list' ? 'purple' : 'gray'}
|
||||
variant={viewMode === 'list' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
列表视图
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<Icon as={FiCalendar} boxSize={4} />}
|
||||
colorScheme={viewMode === 'calendar' ? 'purple' : 'gray'}
|
||||
variant={viewMode === 'calendar' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
>
|
||||
日历视图
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0} px={{ base: 3, md: 5 }}>
|
||||
{viewMode === 'calendar' ? (
|
||||
/* 日历视图 */
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<CalendarPanel />
|
||||
</Suspense>
|
||||
) : (
|
||||
/* 列表视图:我的计划 / 我的复盘 切换 */
|
||||
<Tabs
|
||||
index={listTab}
|
||||
onChange={setListTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
size={{ base: 'sm', md: 'md' }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} flexWrap="nowrap" gap={1}>
|
||||
<TabList mb={0} borderBottom="none" flex="1" minW={0}>
|
||||
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
|
||||
<Icon as={FiTarget} mr={1} boxSize={{ base: 3, md: 4 }} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
|
||||
<Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="purple"
|
||||
leftIcon={<Icon as={FiPlus} boxSize={3} />}
|
||||
fontSize={{ base: '11px', md: 'sm' }}
|
||||
flexShrink={0}
|
||||
onClick={() => {
|
||||
if (listTab === 0) {
|
||||
setOpenPlanModalTrigger(prev => prev + 1);
|
||||
} else {
|
||||
setOpenReviewModalTrigger(prev => prev + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{listTab === 0 ? '新建计划' : '新建复盘'}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<TabPanels>
|
||||
{/* 计划列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<EventPanel
|
||||
type="plan"
|
||||
colorScheme="purple"
|
||||
label="计划"
|
||||
openModalTrigger={openPlanModalTrigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<EventPanel
|
||||
type="review"
|
||||
colorScheme="green"
|
||||
label="复盘"
|
||||
openModalTrigger={openReviewModalTrigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PlanningDataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvestmentPlanningCenter;
|
||||
302
src/views/Center/components/MyFutureEvents.js
Normal file
302
src/views/Center/components/MyFutureEvents.js
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Center,
|
||||
Spinner,
|
||||
LinkBox,
|
||||
LinkOverlay,
|
||||
Tooltip,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function MyFutureEvents({ limit = 5 }) {
|
||||
const [futureEvents, setFutureEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const importanceBg = useColorModeValue('yellow.50', 'yellow.900');
|
||||
const importanceColor = useColorModeValue('yellow.600', 'yellow.300');
|
||||
|
||||
// 加载关注的未来事件
|
||||
const loadFutureEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await eventService.calendar.getFollowingEvents();
|
||||
if (response.success) {
|
||||
// 按时间排序,最近的在前
|
||||
const sortedEvents = (response.data || []).sort((a, b) =>
|
||||
dayjs(a.calendar_time).valueOf() - dayjs(b.calendar_time).valueOf()
|
||||
);
|
||||
setFutureEvents(sortedEvents);
|
||||
logger.debug('MyFutureEvents', '未来事件加载成功', {
|
||||
count: sortedEvents.length
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('MyFutureEvents', 'loadFutureEvents', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadFutureEvents();
|
||||
}, [loadFutureEvents]);
|
||||
|
||||
// 取消关注
|
||||
const handleUnfollow = async (eventId) => {
|
||||
try {
|
||||
const response = await eventService.calendar.toggleFollow(eventId);
|
||||
if (response.success) {
|
||||
setFutureEvents(prev => prev.filter(event => event.id !== eventId));
|
||||
logger.info('MyFutureEvents', '取消关注成功', { eventId });
|
||||
toast({
|
||||
title: '取消关注成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('MyFutureEvents', 'handleUnfollow', error, { eventId });
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: '取消关注失败,请重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatEventTime = (time) => {
|
||||
const eventTime = dayjs(time);
|
||||
const now = dayjs();
|
||||
const daysDiff = eventTime.diff(now, 'days');
|
||||
|
||||
if (daysDiff === 0) {
|
||||
return {
|
||||
date: '今天',
|
||||
time: eventTime.format('HH:mm'),
|
||||
color: 'red',
|
||||
urgent: true
|
||||
};
|
||||
} else if (daysDiff === 1) {
|
||||
return {
|
||||
date: '明天',
|
||||
time: eventTime.format('HH:mm'),
|
||||
color: 'orange',
|
||||
urgent: true
|
||||
};
|
||||
} else if (daysDiff <= 7) {
|
||||
return {
|
||||
date: `${daysDiff}天后`,
|
||||
time: eventTime.format('MM/DD HH:mm'),
|
||||
color: 'yellow',
|
||||
urgent: false
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
date: eventTime.format('MM月DD日'),
|
||||
time: eventTime.format('HH:mm'),
|
||||
color: 'gray',
|
||||
urgent: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 获取重要性颜色
|
||||
const getImportanceColor = (star) => {
|
||||
if (star >= 5) return 'red';
|
||||
if (star >= 4) return 'orange';
|
||||
if (star >= 3) return 'yellow';
|
||||
return 'blue';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (futureEvents.length === 0) {
|
||||
return (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiCalendar} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText} fontSize="sm">
|
||||
暂无关注的未来事件
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
as={Link}
|
||||
to="/community"
|
||||
>
|
||||
探索投资日历
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{futureEvents.slice(0, limit).map((event) => {
|
||||
const timeInfo = formatEventTime(event.calendar_time);
|
||||
|
||||
return (
|
||||
<LinkBox
|
||||
key={event.id}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
bg={timeInfo.urgent ? importanceBg : 'transparent'}
|
||||
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
position="relative"
|
||||
>
|
||||
{/* 紧急标记 */}
|
||||
{timeInfo.urgent && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
>
|
||||
<Badge colorScheme={timeInfo.color} variant="solid" fontSize="xs">
|
||||
即将发生
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题 */}
|
||||
<LinkOverlay as={Link} to="/community">
|
||||
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
|
||||
{event.title}
|
||||
</Text>
|
||||
</LinkOverlay>
|
||||
|
||||
{/* 时间和重要性 */}
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={3}>
|
||||
<Badge colorScheme={timeInfo.color} variant="subtle">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FiClock} boxSize={3} />
|
||||
<Text>{timeInfo.date}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{timeInfo.time}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 重要性星级 */}
|
||||
<HStack spacing={0}>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Icon
|
||||
key={i}
|
||||
as={FiStar}
|
||||
boxSize={3}
|
||||
color={i < event.star ? importanceColor : 'gray.300'}
|
||||
fill={i < event.star ? 'currentColor' : 'none'}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 标签和相关信息 */}
|
||||
<HStack justify="space-between" flexWrap="wrap">
|
||||
<HStack spacing={2}>
|
||||
{event.type && (
|
||||
<Tag size="sm" variant="subtle" colorScheme={event.type === 'event' ? 'blue' : 'green'}>
|
||||
<TagLabel>{event.type === 'event' ? '事件' : '数据'}</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
{event.related_stocks && event.related_stocks.length > 0 && (
|
||||
<Tag size="sm" variant="subtle" colorScheme="purple">
|
||||
<Icon as={FiTrendingUp} boxSize={3} mr={1} />
|
||||
<TagLabel>{event.related_stocks.length}只相关股票</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleUnfollow(event.id);
|
||||
}}
|
||||
>
|
||||
取消关注
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 预测信息 */}
|
||||
{event.forecast && (
|
||||
<Box>
|
||||
<HStack spacing={1} mb={1}>
|
||||
<Icon as={FiAlertCircle} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="xs" color={secondaryText}>
|
||||
预测
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={secondaryText} noOfLines={2}>
|
||||
{event.forecast}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</LinkBox>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 查看更多 */}
|
||||
{futureEvents.length > limit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
as={Link}
|
||||
to="/community"
|
||||
>
|
||||
查看全部 ({futureEvents.length})
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
60
src/views/Center/components/PlanningContext.tsx
Normal file
60
src/views/Center/components/PlanningContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter Context
|
||||
* 用于在日历、计划、复盘三个面板间共享数据和状态
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import type { PlanningContextValue } from '@/types';
|
||||
|
||||
/**
|
||||
* Planning Data Context
|
||||
* 提供投资规划数据和操作方法
|
||||
*/
|
||||
const PlanningDataContext = createContext<PlanningContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* PlanningDataProvider Props
|
||||
*/
|
||||
interface PlanningDataProviderProps {
|
||||
/** Context 值 */
|
||||
value: PlanningContextValue;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanningDataProvider 组件
|
||||
* 包裹需要访问投资规划数据的组件
|
||||
*/
|
||||
export const PlanningDataProvider: React.FC<PlanningDataProviderProps> = ({ value, children }) => {
|
||||
return (
|
||||
<PlanningDataContext.Provider value={value}>
|
||||
{children}
|
||||
</PlanningDataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* usePlanningData Hook
|
||||
* 在子组件中访问投资规划数据
|
||||
*
|
||||
* @throws {Error} 如果在 PlanningDataProvider 外部调用
|
||||
* @returns {PlanningContextValue} Context 值
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function CalendarPanel() {
|
||||
* const { allEvents, loading, toast } = usePlanningData();
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const usePlanningData = (): PlanningContextValue => {
|
||||
const context = useContext(PlanningDataContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('usePlanningData 必须在 PlanningDataProvider 内部使用');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user