Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui

This commit is contained in:
2025-12-05 18:30:28 +08:00
35 changed files with 3179 additions and 5749 deletions

View File

@@ -14,6 +14,7 @@ import dayjs from 'dayjs';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice';
import { loadAllStocks } from '@store/slices/stockSlice';
import { stockService } from '@services/stockService';
import { logger } from '@utils/logger';
import TradingTimeFilter from './TradingTimeFilter';
@@ -61,9 +62,12 @@ const CompactSearchBox = ({
const dispatch = useDispatch();
const industryData = useSelector(selectIndustryData);
const industryLoading = useSelector(selectIndustryLoading);
const reduxAllStocks = useSelector((state) => state.stock.allStocks);
// 防抖搜索
const debouncedSearchRef = useRef(null);
// 存储股票选择时的显示值(代码+名称),用于 useEffect 同步时显示完整信息
const stockDisplayValueRef = useRef(null);
const triggerSearch = useCallback((params) => {
logger.debug('CompactSearchBox', '触发搜索', { params });
@@ -82,16 +86,19 @@ const CompactSearchBox = ({
};
}, [triggerSearch]);
// 加载股票数据
// 加载股票数据(从 Redux 获取)
useEffect(() => {
const loadStocks = async () => {
const response = await stockService.getAllStocks();
if (response.success && response.data) {
setAllStocks(response.data);
}
};
loadStocks();
}, []);
if (!reduxAllStocks || reduxAllStocks.length === 0) {
dispatch(loadAllStocks());
}
}, [dispatch, reduxAllStocks]);
// 同步 Redux 数据到本地状态
useEffect(() => {
if (reduxAllStocks && reduxAllStocks.length > 0) {
setAllStocks(reduxAllStocks);
}
}, [reduxAllStocks]);
// 预加载行业数据(解决第一次点击无数据问题)
useEffect(() => {
@@ -143,9 +150,17 @@ const CompactSearchBox = ({
}
if (filters.q) {
setInputValue(filters.q);
// 如果是股票选择触发的搜索,使用存储的显示值(代码+名称)
if (stockDisplayValueRef.current && stockDisplayValueRef.current.code === filters.q) {
setInputValue(stockDisplayValueRef.current.displayValue);
} else {
setInputValue(filters.q);
// 清除已失效的显示值缓存
stockDisplayValueRef.current = null;
}
} else if (!filters.q) {
setInputValue('');
stockDisplayValueRef.current = null;
}
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
@@ -228,7 +243,7 @@ const CompactSearchBox = ({
sort: actualSort,
importance: importanceValue,
q: (overrides.q ?? filters.q) ?? '',
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
industry_code: overrides.industry_code ?? (industryValue?.join(',') || ''),
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
@@ -264,10 +279,13 @@ const CompactSearchBox = ({
});
}
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
const displayValue = `${stockInfo.code} ${stockInfo.name}`;
setInputValue(displayValue);
// 存储显示值,供 useEffect 同步时使用
stockDisplayValueRef.current = { code: stockInfo.code, displayValue };
const params = buildFilterParams({
q: stockInfo.code,
q: stockInfo.code, // 接口只传代码
industry_code: ''
});
triggerSearch(params);
@@ -330,7 +348,7 @@ const CompactSearchBox = ({
}
const params = buildFilterParams({
industry_code: value?.[value.length - 1] || ''
industry_code: value?.join(',') || ''
});
triggerSearch(params);
};

View File

@@ -469,13 +469,13 @@ const FlowingConcepts = () => {
const row3 = concepts.slice(20, 30);
// 渲染单个概念卡片
const renderConceptCard = (concept, globalIdx) => {
const renderConceptCard = (concept, globalIdx, uniqueIdx) => {
const colors = getColor(concept.change_pct);
const isActive = hoveredIdx === globalIdx;
return (
<Box
key={globalIdx}
key={`${globalIdx}-${uniqueIdx}`}
flexShrink={0}
px={3}
py={2}
@@ -582,7 +582,7 @@ const FlowingConcepts = () => {
>
{/* 复制两份实现无缝滚动 */}
{[...items, ...items].map((concept, idx) =>
renderConceptCard(concept, startIdx + (idx % items.length))
renderConceptCard(concept, startIdx + (idx % items.length), idx)
)}
</Flex>
</Box>

View File

@@ -1,9 +1,9 @@
// src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useEffect, useCallback, useMemo } from 'react';
import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../../../../store/slices/stockSlice';
import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '@store/slices/stockSlice';
import { message } from 'antd';
import { logger } from '../../../../../utils/logger';
import { logger } from '@utils/logger';
/**
* 标准化股票代码为6位格式
@@ -41,8 +41,9 @@ export const useWatchlist = (shouldLoad = true) => {
const loading = useSelector(state => state.stock.loading.watchlist);
// 转换为 Set 方便快速查询标准化为6位代码
// 注意: watchlistArray 现在是 { stock_code, stock_name }[] 格式
const watchlistSet = useMemo(() => {
return new Set(watchlistArray.map(normalizeStockCode));
return new Set(watchlistArray.map(item => normalizeStockCode(item.stock_code)));
}, [watchlistArray]);
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)

View File

@@ -273,18 +273,18 @@ export default function CenterDashboard() {
}
return (
<Box bg={sectionBg} minH="100vh">
<Box px={{ base: 4, md: 8 }} py={6} maxW="1400px" mx="auto">
<Box bg={sectionBg} minH="100vh" overflowX="hidden">
<Box px={{ base: 3, md: 8 }} py={{ base: 4, md: 6 }} maxW="1400px" mx="auto">
{/* 主要内容区域 */}
<Grid templateColumns={{ base: '1fr', md: '1fr 1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
<Grid templateColumns={{ base: '1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
{/* 左列:自选股票 */}
<VStack spacing={6} align="stretch">
<Card bg={cardBg} shadow="md" height="600px" display="flex" flexDirection="column">
<CardHeader pb={4}>
<VStack spacing={6} align="stretch" minW={0}>
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
<CardHeader pb={{ base: 2, md: 4 }}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiBarChart2} color="blue.500" boxSize={5} />
<Heading size="md">自选股票</Heading>
<Icon as={FiBarChart2} color="blue.500" boxSize={{ base: 4, md: 5 }} />
<Heading size={{ base: 'sm', md: 'md' }}>自选股票</Heading>
<Badge colorScheme="blue" variant="subtle">
{watchlist.length}
</Badge>
@@ -321,7 +321,7 @@ export default function CenterDashboard() {
<VStack align="stretch" spacing={2}>
{watchlist.slice(0, 10).map((stock) => (
<LinkBox
key={stock.id}
key={stock.stock_code}
p={3}
borderRadius="md"
_hover={{ bg: hoverBg }}
@@ -388,14 +388,14 @@ export default function CenterDashboard() {
</VStack>
{/* 中列:关注事件 */}
<VStack spacing={6} align="stretch">
<VStack spacing={6} align="stretch" minW={0}>
{/* 关注事件 */}
<Card bg={cardBg} shadow="md" height="600px" display="flex" flexDirection="column">
<CardHeader pb={4}>
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
<CardHeader pb={{ base: 2, md: 4 }}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiStar} color="yellow.500" boxSize={5} />
<Heading size="md">关注事件</Heading>
<Icon as={FiStar} color="yellow.500" boxSize={{ base: 4, md: 5 }} />
<Heading size={{ base: 'sm', md: 'md' }}>关注事件</Heading>
<Badge colorScheme="yellow" variant="subtle">
{followingEvents.length}
</Badge>
@@ -525,14 +525,14 @@ export default function CenterDashboard() {
</VStack>
{/* 右列:我的评论 */}
<VStack spacing={6} align="stretch">
<VStack spacing={6} align="stretch" minW={0}>
{/* 我的评论 */}
<Card bg={cardBg} shadow="md" height="600px" display="flex" flexDirection="column">
<CardHeader pb={4}>
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
<CardHeader pb={{ base: 2, md: 4 }}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiMessageSquare} color="purple.500" boxSize={5} />
<Heading size="md">我的评论</Heading>
<Icon as={FiMessageSquare} color="purple.500" boxSize={{ base: 4, md: 5 }} />
<Heading size={{ base: 'sm', md: 'md' }}>我的评论</Heading>
<Badge colorScheme="purple" variant="subtle">
{eventComments.length}
</Badge>
@@ -568,15 +568,22 @@ export default function CenterDashboard() {
<Text fontSize="sm" noOfLines={3}>
{comment.content}
</Text>
<HStack justify="space-between" fontSize="xs" color={secondaryText}>
<HStack>
<HStack justify="space-between" fontSize="xs" color={secondaryText} spacing={2}>
<HStack flexShrink={0}>
<Icon as={FiClock} />
<Text>{formatDate(comment.created_at)}</Text>
</HStack>
{comment.event_title && (
<Tooltip label={comment.event_title}>
<Badge variant="subtle" fontSize="xs">
{comment.event_title.slice(0, 20)}...
<Badge
variant="subtle"
fontSize="xs"
maxW={{ base: '120px', md: '180px' }}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{comment.event_title}
</Badge>
</Tooltip>
)}

View File

@@ -3,44 +3,18 @@
* 使用 FullCalendar 展示投资计划、复盘等事件
*/
import React, { useState } from 'react';
import React, { useState, lazy, Suspense, useMemo, useCallback } 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';
@@ -50,23 +24,15 @@ 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';
import { EventDetailModal } from './EventDetailModal';
import type { InvestmentEvent } from '@/types';
import './InvestmentCalendar.less';
// 懒加载投资日历组件
const InvestmentCalendar = lazy(() => import('@/views/Community/components/InvestmentCalendar'));
dayjs.locale('zh-cn');
/**
* 新事件表单数据类型
*/
interface NewEventForm {
title: string;
description: string;
type: EventType;
importance: number;
stocks: string;
}
/**
* FullCalendar 事件类型
*/
@@ -89,416 +55,149 @@ interface CalendarEvent {
export const CalendarPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
setActiveTab,
toast,
borderColor,
secondaryText,
setViewMode,
setListTab,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
// 弹窗状态(统一使用 useState
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
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: {
// 转换数据为 FullCalendar 格式(使用 useMemo 缓存)
const calendarEvents: CalendarEvent[] = useMemo(() =>
allEvents.map(event => ({
...event,
isSystem: event.source === 'future',
}
}));
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 handleDateClick = (info: DateClickArg): void => {
const clickedDate = dayjs(info.date);
// 抽取公共的打开事件详情函数
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);
onOpen();
};
setIsDetailModalOpen(true);
}, [allEvents]);
// 处理日期点击
const handleDateClick = useCallback((info: DateClickArg): void => {
openEventDetail(info.date);
}, [openEventDetail]);
// 处理事件点击
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();
};
const handleEventClick = useCallback((info: EventClickArg): void => {
openEventDetail(info.event.start);
}, [openEventDetail]);
return (
<Box>
<Flex justify="flex-end" mb={4}>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => {
if (!selectedDate) setSelectedDate(dayjs());
onAddOpen();
<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: ''
}}
>
</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>
)}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%"
dayMaxEvents={1}
moreLinkText="+更多"
buttonText={{
today: '今天',
month: '月',
week: '周'
}}
titleFormat={{ year: 'numeric', month: 'long' }}
/>
</Box>
{/* 查看事件详情 Modal */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<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>
<ModalHeader>
{selectedDate && selectedDate.format('YYYY年MM月DD日')}
</ModalHeader>
<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>
{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 pb={6}>
<Suspense fallback={<Center py={{ base: 6, md: 8 }}><Spinner size={{ base: 'lg', md: 'xl' }} color="blue.500" /></Center>}>
<InvestmentCalendar />
</Suspense>
</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>
);
};

View 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;

View 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;

View 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;

View 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); }
}

View 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}
destroyOnClose
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;

View 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;

View File

@@ -1,87 +0,0 @@
/* src/views/Dashboard/components/InvestmentCalendar.css */
/* FullCalendar 自定义样式 */
.fc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
.fc-theme-standard td,
.fc-theme-standard th {
border-color: #e2e8f0;
}
.fc-button-primary {
background-color: #3182ce !important;
border-color: #3182ce !important;
}
.fc-button-primary:hover {
background-color: #2c5282 !important;
border-color: #2c5282 !important;
}
.fc-button-primary:not(:disabled):active,
.fc-button-primary:not(:disabled).fc-button-active {
background-color: #2c5282 !important;
border-color: #2c5282 !important;
}
.fc-daygrid-day-number {
color: #2d3748;
font-weight: 500;
}
.fc-daygrid-day.fc-day-today {
background-color: #e6f3ff !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 (max-width: 768px) {
.fc-toolbar {
flex-direction: column;
}
.fc-toolbar-title {
margin: 0.5em 0;
}
.fc-button-group {
margin: 0.2em;
}
}
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
.fc-theme-standard td,
.fc-theme-standard th {
border-color: #4a5568;
}
.fc-daygrid-day-number {
color: #e2e8f0;
}
.fc-daygrid-day.fc-day-today {
background-color: #2d3748 !important;
}
.fc-col-header-cell-cushion,
.fc-daygrid-day-number {
color: #e2e8f0;
}
}

View 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;
}
}

View File

@@ -58,7 +58,7 @@ import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import TimelineChartModal from '../../../components/StockChart/TimelineChartModal';
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
import './InvestmentCalendar.css';
import './InvestmentCalendar.less';
dayjs.locale('zh-cn');

View File

@@ -1,493 +0,0 @@
// 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 moment from 'moment';
import 'moment/locale/zh-cn';
import { logger } from '../../../utils/logger';
import './InvestmentCalendar.css';
moment.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 = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
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 = moment(info.date);
setSelectedDate(clickedDate);
// 筛选当天的事件
const dayEvents = events.filter(event =>
moment(event.start).isSame(clickedDate, 'day')
);
setSelectedDateEvents(dayEvents);
onOpen();
};
// 处理事件点击
const handleEventClick = (info) => {
const event = info.event;
const clickedDate = moment(event.start);
setSelectedDate(clickedDate);
setSelectedDateEvents([{
title: event.title,
start: event.start,
extendedProps: {
...event.extendedProps,
},
}]);
onOpen();
};
// 添加新事件
const handleAddEvent = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const eventData = {
...newEvent,
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().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 = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
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(moment()); 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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,12 @@
*
* 性能优化:
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
* - 使用 TypeScript 提供类型安全
*
* 组件架构:
* - InvestmentPlanningCenter (主组件~200 行)
* - InvestmentPlanningCenter (主组件)
* - CalendarPanel (日历面板,懒加载)
* - PlansPanel (计划面板,懒加载)
* - ReviewsPanel (复盘面板,懒加载)
* - EventPanel (通用事件面板,用于计划和复盘)
* - PlanningContext (数据共享层)
*/
@@ -33,28 +31,29 @@ import {
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.css';
import './InvestmentCalendar.less';
// 懒加载子面板组件(实现代码分割)
const CalendarPanel = lazy(() =>
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
);
const PlansPanel = lazy(() =>
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
);
const ReviewsPanel = lazy(() =>
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
const EventPanel = lazy(() =>
import('./EventPanel').then(module => ({ default: module.EventPanel }))
);
/**
@@ -82,7 +81,10 @@ const InvestmentPlanningCenter: React.FC = () => {
// 全局数据状态
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<number>(0);
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);
/**
* 加载所有事件数据(日历事件 + 计划 + 复盘)
@@ -124,14 +126,15 @@ const InvestmentPlanningCenter: React.FC = () => {
loadAllData,
loading,
setLoading,
activeTab,
setActiveTab,
openPlanModalTrigger,
openReviewModalTrigger,
toast,
bgColor,
borderColor,
textColor,
secondaryText,
cardBg,
setViewMode,
setListTab,
};
// 计算各类型事件数量
@@ -141,59 +144,104 @@ const InvestmentPlanningCenter: React.FC = () => {
return (
<PlanningDataProvider value={contextValue}>
<Card bg={bgColor} shadow="md">
<CardHeader pb={4}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiTarget} color="purple.500" boxSize={5} />
<Heading size="md"></Heading>
<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}>
<Tabs
index={activeTab}
onChange={setActiveTab}
variant="enclosed"
colorScheme="purple"
>
<TabList>
<Tab>
<Icon as={FiCalendar} mr={2} />
</Tab>
<Tab>
<Icon as={FiTarget} mr={2} />
({planCount})
</Tab>
<Tab>
<Icon as={FiFileText} mr={2} />
({reviewCount})
</Tab>
</TabList>
<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 />}>
<CalendarPanel />
</Suspense>
</TabPanel>
<TabPanels>
{/* 计划列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<EventPanel
type="plan"
colorScheme="purple"
label="计划"
openModalTrigger={openPlanModalTrigger}
/>
</Suspense>
</TabPanel>
{/* 计划列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<PlansPanel />
</Suspense>
</TabPanel>
{/* 复盘列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<ReviewsPanel />
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
{/* 复盘列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<EventPanel
type="review"
colorScheme="green"
label="复盘"
openModalTrigger={openReviewModalTrigger}
/>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
)}
</CardBody>
</Card>
</PlanningDataProvider>

View File

@@ -1,587 +0,0 @@
// src/views/Dashboard/components/InvestmentPlansAndReviews.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,
useColorModeValue,
Divider,
Icon,
Input,
FormControl,
FormLabel,
Textarea,
Select,
useToast,
Spinner,
Center,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
Grid,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
InputGroup,
InputLeftElement,
} from '@chakra-ui/react';
import {
FiCalendar,
FiClock,
FiEdit2,
FiTrash2,
FiSave,
FiPlus,
FiFileText,
FiTarget,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import moment from 'moment';
import 'moment/locale/zh-cn';
import { logger } from '../../../utils/logger';
moment.locale('zh-cn');
export default function InvestmentPlansAndReviews({ type = 'both' }) {
const { isOpen, onOpen, onClose } = 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 cardBg = useColorModeValue('gray.50', 'gray.700');
const [plans, setPlans] = useState([]);
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({
date: moment().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState('');
const [tagInput, setTagInput] = useState('');
// 加载数据
const loadData = useCallback(async () => {
try {
setLoading(true);
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const response = await fetch(base + '/api/account/investment-plans', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
const allItems = data.data || [];
setPlans(allItems.filter(item => item.type === 'plan'));
setReviews(allItems.filter(item => item.type === 'review'));
logger.debug('InvestmentPlansAndReviews', '数据加载成功', {
plansCount: allItems.filter(item => item.type === 'plan').length,
reviewsCount: allItems.filter(item => item.type === 'review').length
});
}
}
} catch (error) {
logger.error('InvestmentPlansAndReviews', 'loadData', error);
// ❌ 移除数据加载失败 toast非关键操作
} finally {
setLoading(false);
}
}, []); // ✅ 移除 toast 依赖
useEffect(() => {
loadData();
}, [loadData]);
// 打开编辑/新建模态框
const handleOpenModal = (item = null, itemType = 'plan') => {
if (item) {
setEditingItem(item);
setFormData({
...item,
date: moment(item.date).format('YYYY-MM-DD'),
});
} else {
setEditingItem(null);
setFormData({
date: moment().format('YYYY-MM-DD'),
title: '',
content: '',
type: itemType,
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('InvestmentPlansAndReviews', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
type: formData.type
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('InvestmentPlansAndReviews', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
const handleDelete = async (id) => {
if (!window.confirm('确定要删除吗?')) return;
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('InvestmentPlansAndReviews', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadData();
}
} catch (error) {
logger.error('InvestmentPlansAndReviews', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = () => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = () => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态图标和颜色
const getStatusInfo = (status) => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green' };
case 'cancelled':
return { icon: FiXCircle, color: 'red' };
default:
return { icon: FiAlertCircle, color: 'blue' };
}
};
// 渲染单个卡片
const renderCard = (item) => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={item.type === 'plan' ? FiTarget : FiFileText} color="blue.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{moment(item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
leftIcon={<Icon as={statusInfo.icon} />}
>
{item.status === 'active' ? '进行中' :
item.status === 'completed' ? '已完成' : '已取消'}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
/>
</HStack>
</Flex>
{item.content && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<Tabs variant="enclosed" colorScheme="blue" defaultIndex={type === 'review' ? 1 : 0}>
<TabList>
<Tab>
<Icon as={FiTarget} mr={2} />
我的计划 ({plans.length})
</Tab>
<Tab>
<Icon as={FiFileText} mr={2} />
我的复盘 ({reviews.length})
</Tab>
</TabList>
<TabPanels>
{/* 计划面板 */}
<TabPanel px={0}>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="blue"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'plan')}
>
新建计划
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="blue.500" />
</Center>
) : plans.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiTarget} boxSize={12} color="gray.300" />
<Text color={secondaryText}>暂无投资计划</Text>
<Button
size="sm"
colorScheme="blue"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'plan')}
>
创建第一个计划
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{plans.map(renderCard)}
</Grid>
)}
</VStack>
</TabPanel>
{/* 复盘面板 */}
<TabPanel px={0}>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'review')}
>
新建复盘
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="blue.500" />
</Center>
) : reviews.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiFileText} boxSize={12} color="gray.300" />
<Text color={secondaryText}>暂无复盘记录</Text>
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'review')}
>
创建第一个复盘
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{reviews.map(renderCard)}
</Grid>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
{/* 编辑/新建模态框 - 条件渲染 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
{formData.type === 'plan' ? '投资计划' : '复盘记录'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>日期</FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel>标题</FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder={formData.type === 'plan' ? '例如:布局新能源板块' : '例如:本周交易复盘'}
/>
</FormControl>
<FormControl>
<FormLabel>内容</FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder={formData.type === 'plan' ?
'详细描述您的投资计划...' :
'记录您的交易心得和经验教训...'}
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel>相关股票</FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}>添加</Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{formData.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel>标签</FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}>添加</Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{formData.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel>状态</FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
>
<option value="active">进行中</option>
<option value="completed">已完成</option>
<option value="cancelled">已取消</option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
取消
</Button>
<Button
colorScheme="blue"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
}

View File

@@ -1,506 +0,0 @@
/**
* PlansPanel - 投资计划列表面板组件
* 显示、编辑和管理投资计划
*/
import React, { useState } from 'react';
import {
Box,
Button,
Badge,
IconButton,
Flex,
Grid,
Card,
CardBody,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Input,
InputGroup,
InputLeftElement,
FormControl,
FormLabel,
Textarea,
Select,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
} from '@chakra-ui/react';
import {
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiTarget,
FiCalendar,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 状态信息接口
*/
interface StatusInfo {
icon: React.ComponentType;
color: string;
text: string;
}
/**
* PlansPanel 组件
* 计划列表面板,显示所有投资计划
*/
export const PlansPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
const [formData, setFormData] = useState<PlanFormData>({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState<string>('');
const [tagInput, setTagInput] = useState<string>('');
// 筛选计划列表(排除系统事件)
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
if (item) {
setEditingItem(item);
setFormData({
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
title: item.title,
content: item.description || item.content || '',
type: 'plan',
stocks: item.stocks || [],
tags: item.tags || [],
status: item.status || 'active',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async (): Promise<void> => {
try {
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('PlansPanel', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadAllData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('PlansPanel', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
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('PlansPanel', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadAllData();
}
} catch (error) {
logger.error('PlansPanel', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = (): void => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = (): void => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态信息
const getStatusInfo = (status?: EventStatus): StatusInfo => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
// 渲染单个卡片
const renderCard = (item: InvestmentEvent): React.ReactElement => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={FiTarget} color="purple.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
>
{statusInfo.text}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
aria-label="编辑计划"
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
aria-label="删除计划"
/>
</HStack>
</Flex>
{(item.content || item.description) && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content || item.description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="purple.500" />
</Center>
) : plans.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiTarget} boxSize={12} color="gray.300" />
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{plans.map(renderCard)}
</Grid>
)}
</VStack>
{/* 编辑/新建模态框 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:布局新能源板块"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="详细描述您的投资计划..."
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.stocks || []).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.tags || []).map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
>
<option value="active"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
</Button>
<Button
colorScheme="purple"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -1,506 +0,0 @@
/**
* ReviewsPanel - 投资复盘列表面板组件
* 显示、编辑和管理投资复盘
*/
import React, { useState } from 'react';
import {
Box,
Button,
Badge,
IconButton,
Flex,
Grid,
Card,
CardBody,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Input,
InputGroup,
InputLeftElement,
FormControl,
FormLabel,
Textarea,
Select,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
} from '@chakra-ui/react';
import {
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiFileText,
FiCalendar,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 状态信息接口
*/
interface StatusInfo {
icon: React.ComponentType;
color: string;
text: string;
}
/**
* ReviewsPanel 组件
* 复盘列表面板,显示所有投资复盘
*/
export const ReviewsPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
const [formData, setFormData] = useState<PlanFormData>({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState<string>('');
const [tagInput, setTagInput] = useState<string>('');
// 筛选复盘列表(排除系统事件)
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
if (item) {
setEditingItem(item);
setFormData({
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
title: item.title,
content: item.description || item.content || '',
type: 'review',
stocks: item.stocks || [],
tags: item.tags || [],
status: item.status || 'active',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async (): Promise<void> => {
try {
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadAllData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('ReviewsPanel', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
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('ReviewsPanel', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadAllData();
}
} catch (error) {
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = (): void => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = (): void => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态信息
const getStatusInfo = (status?: EventStatus): StatusInfo => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
// 渲染单个卡片
const renderCard = (item: InvestmentEvent): React.ReactElement => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={FiFileText} color="green.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
>
{statusInfo.text}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
aria-label="编辑复盘"
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
aria-label="删除复盘"
/>
</HStack>
</Flex>
{(item.content || item.description) && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content || item.description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="green.500" />
</Center>
) : reviews.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiFileText} boxSize={12} color="gray.300" />
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{reviews.map(renderCard)}
</Grid>
)}
</VStack>
{/* 编辑/新建模态框 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:本周操作复盘"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="详细记录您的投资复盘..."
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.stocks || []).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.tags || []).map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="green">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
>
<option value="active"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
</Button>
<Button
colorScheme="green"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -66,15 +66,43 @@ const EventDetail = () => {
}
// 错误状态
if (!error) {
if (error) {
return (
<ErrorPage
title="页面找不到了"
description={error}
title="事件走丢了"
subtitle={encodedId ? `ID: ${encodedId}` : undefined}
description="抱歉,我们找不到您请求的事件,这可能是因为:"
detail={eventId}
detailLabel="事件ID"
reasons={[
{
icon: '🔍',
title: '事件ID输入错误',
description: '请检查URL中的事件ID是否正确',
},
{
icon: '🗑️',
title: '该事件已被删除或下架',
description: '该事件可能因过期或内容调整而被移除',
},
{
icon: '🔄',
title: '系统暂时无法访问该事件',
description: '请稍后重试或联系技术支持',
},
]}
techDetails={{
requestUrl: window.location.href,
errorType: '404 - Event Not Found',
errorMessage: error,
timestamp: new Date().toISOString(),
relatedId: eventId,
}}
showRetry
onRetry={() => window.location.reload()}
showBack
showHome
homePath="/community"
/>
);
}