pref: PlansPanel 和 ReviewsPanel 代码高度重复,提取公共组件
创建通用 EventPanel 组件 新建 EventPanel.tsx (~420 行) - 通用事件面板组件 - 删除 PlansPanel.tsx (495 行 → 27 行,减少 94%) - 删除 ReviewsPanel.tsx (496 行 → 27 行,减少 94%) - 修复 CalendarPanel.tsx 中的 setActiveTab 引用
This commit is contained in:
@@ -91,7 +91,6 @@ export const CalendarPanel: React.FC = () => {
|
|||||||
allEvents,
|
allEvents,
|
||||||
loadAllData,
|
loadAllData,
|
||||||
loading,
|
loading,
|
||||||
setActiveTab,
|
|
||||||
toast,
|
toast,
|
||||||
borderColor,
|
borderColor,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
@@ -246,13 +245,8 @@ export const CalendarPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跳转到计划或复盘标签页
|
// 查看事件详情(关闭弹窗)
|
||||||
const handleViewDetails = (event: InvestmentEvent): void => {
|
const handleViewDetails = (): void => {
|
||||||
if (event.type === 'plan') {
|
|
||||||
setActiveTab(1); // 跳转到"我的计划"标签页
|
|
||||||
} else if (event.type === 'review') {
|
|
||||||
setActiveTab(2); // 跳转到"我的复盘"标签页
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -357,7 +351,7 @@ export const CalendarPanel: React.FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
onClick={() => handleViewDetails(event)}
|
onClick={() => handleViewDetails()}
|
||||||
aria-label="查看详情"
|
aria-label="查看详情"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* ReviewsPanel - 投资复盘列表面板组件
|
* EventPanel - 通用事件面板组件
|
||||||
* 显示、编辑和管理投资复盘
|
* 用于显示、编辑和管理投资计划或复盘
|
||||||
|
*
|
||||||
|
* 通过 props 配置差异化行为:
|
||||||
|
* - type: 'plan' | 'review'
|
||||||
|
* - colorScheme: 主题色
|
||||||
|
* - label: 显示文案
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -71,10 +76,29 @@ interface StatusInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReviewsPanel 组件
|
* EventPanel Props
|
||||||
* 复盘列表面板,显示所有投资复盘
|
|
||||||
*/
|
*/
|
||||||
export const ReviewsPanel: React.FC = () => {
|
export interface EventPanelProps {
|
||||||
|
/** 事件类型 */
|
||||||
|
type: 'plan' | 'review';
|
||||||
|
/** 主题颜色 */
|
||||||
|
colorScheme: string;
|
||||||
|
/** 显示标签(如 "计划" 或 "复盘") */
|
||||||
|
label: string;
|
||||||
|
/** 外部触发打开模态框的计数器 */
|
||||||
|
openModalTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventPanel 组件
|
||||||
|
* 通用事件列表面板,显示投资计划或复盘
|
||||||
|
*/
|
||||||
|
export const EventPanel: React.FC<EventPanelProps> = ({
|
||||||
|
type,
|
||||||
|
colorScheme,
|
||||||
|
label,
|
||||||
|
openModalTrigger,
|
||||||
|
}) => {
|
||||||
const {
|
const {
|
||||||
allEvents,
|
allEvents,
|
||||||
loadAllData,
|
loadAllData,
|
||||||
@@ -83,8 +107,6 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
textColor,
|
textColor,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
cardBg,
|
cardBg,
|
||||||
borderColor,
|
|
||||||
openReviewModalTrigger,
|
|
||||||
} = usePlanningData();
|
} = usePlanningData();
|
||||||
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
@@ -93,7 +115,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
date: dayjs().format('YYYY-MM-DD'),
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
type: 'review',
|
type,
|
||||||
stocks: [],
|
stocks: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -101,25 +123,18 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
const [stockInput, setStockInput] = useState<string>('');
|
const [stockInput, setStockInput] = useState<string>('');
|
||||||
const [tagInput, setTagInput] = useState<string>('');
|
const [tagInput, setTagInput] = useState<string>('');
|
||||||
|
|
||||||
// 筛选复盘列表(排除系统事件)
|
// 筛选事件列表(按类型过滤,排除系统事件)
|
||||||
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
|
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
|
||||||
|
|
||||||
// 监听外部触发打开新建模态框
|
|
||||||
useEffect(() => {
|
|
||||||
if (openReviewModalTrigger && openReviewModalTrigger > 0) {
|
|
||||||
handleOpenModal(null);
|
|
||||||
}
|
|
||||||
}, [openReviewModalTrigger]);
|
|
||||||
|
|
||||||
// 打开编辑/新建模态框
|
// 打开编辑/新建模态框
|
||||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
const handleOpenModal = useCallback((item: InvestmentEvent | null = null): void => {
|
||||||
if (item) {
|
if (item) {
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
setFormData({
|
setFormData({
|
||||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||||
title: item.title,
|
title: item.title,
|
||||||
content: item.description || item.content || '',
|
content: item.description || item.content || '',
|
||||||
type: 'review',
|
type,
|
||||||
stocks: item.stocks || [],
|
stocks: item.stocks || [],
|
||||||
tags: item.tags || [],
|
tags: item.tags || [],
|
||||||
status: item.status || 'active',
|
status: item.status || 'active',
|
||||||
@@ -130,14 +145,21 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
date: dayjs().format('YYYY-MM-DD'),
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
type: 'review',
|
type,
|
||||||
stocks: [],
|
stocks: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onOpen();
|
onOpen();
|
||||||
};
|
}, [type, onOpen]);
|
||||||
|
|
||||||
|
// 监听外部触发打开新建模态框
|
||||||
|
useEffect(() => {
|
||||||
|
if (openModalTrigger && openModalTrigger > 0) {
|
||||||
|
handleOpenModal(null);
|
||||||
|
}
|
||||||
|
}, [openModalTrigger, handleOpenModal]);
|
||||||
|
|
||||||
// 保存数据
|
// 保存数据
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
@@ -160,7 +182,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
logger.info('EventPanel', `${editingItem ? '更新' : '创建'}${label}成功`, {
|
||||||
itemId: editingItem?.id,
|
itemId: editingItem?.id,
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
});
|
});
|
||||||
@@ -175,7 +197,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
throw new Error('保存失败');
|
throw new Error('保存失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ReviewsPanel', 'handleSave', error, {
|
logger.error('EventPanel', 'handleSave', error, {
|
||||||
itemId: editingItem?.id,
|
itemId: editingItem?.id,
|
||||||
title: formData?.title
|
title: formData?.title
|
||||||
});
|
});
|
||||||
@@ -201,7 +223,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
logger.info('ReviewsPanel', '删除成功', { itemId: id });
|
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
|
||||||
toast({
|
toast({
|
||||||
title: '删除成功',
|
title: '删除成功',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -210,7 +232,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
loadAllData();
|
loadAllData();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
|
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
|
||||||
toast({
|
toast({
|
||||||
title: '删除失败',
|
title: '删除失败',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -270,7 +292,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
<Flex justify="space-between" align="start">
|
<Flex justify="space-between" align="start">
|
||||||
<VStack align="start" spacing={1} flex={1}>
|
<VStack align="start" spacing={1} flex={1}>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FiFileText} color="green.500" />
|
<Icon as={FiFileText} color={`${colorScheme}.500`} />
|
||||||
<Text fontWeight="bold" fontSize="lg">
|
<Text fontWeight="bold" fontSize="lg">
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -294,7 +316,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleOpenModal(item)}
|
onClick={() => handleOpenModal(item)}
|
||||||
aria-label="编辑复盘"
|
aria-label={`编辑${label}`}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<FiTrash2 />}
|
icon={<FiTrash2 />}
|
||||||
@@ -302,7 +324,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
colorScheme="red"
|
colorScheme="red"
|
||||||
onClick={() => handleDelete(item.id)}
|
onClick={() => handleDelete(item.id)}
|
||||||
aria-label="删除复盘"
|
aria-label={`删除${label}`}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -327,7 +349,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
{item.tags && item.tags.length > 0 && (
|
{item.tags && item.tags.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{item.tags.map((tag, idx) => (
|
{item.tags.map((tag, idx) => (
|
||||||
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
|
<Tag key={idx} size="sm" colorScheme={colorScheme} variant="subtle">
|
||||||
<TagLeftIcon as={FiHash} />
|
<TagLeftIcon as={FiHash} />
|
||||||
<TagLabel>{tag}</TagLabel>
|
<TagLabel>{tag}</TagLabel>
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -346,18 +368,18 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
<VStack align="stretch" spacing={4}>
|
<VStack align="stretch" spacing={4}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Center py={8}>
|
<Center py={8}>
|
||||||
<Spinner size="xl" color="green.500" />
|
<Spinner size="xl" color={`${colorScheme}.500`} />
|
||||||
</Center>
|
</Center>
|
||||||
) : reviews.length === 0 ? (
|
) : events.length === 0 ? (
|
||||||
<Center py={8}>
|
<Center py={8}>
|
||||||
<VStack spacing={3}>
|
<VStack spacing={3}>
|
||||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||||
<Text color={secondaryText}>暂无投资复盘</Text>
|
<Text color={secondaryText}>暂无投资{label}</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||||
{reviews.map(renderCard)}
|
{events.map(renderCard)}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -368,7 +390,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
{editingItem ? '编辑' : '新建'}投资复盘
|
{editingItem ? '编辑' : '新建'}投资{label}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton />
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
@@ -392,7 +414,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
placeholder="例如:本周操作复盘"
|
placeholder={type === 'plan' ? '例如:布局新能源板块' : '例如:本周操作复盘'}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -401,7 +423,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={formData.content}
|
value={formData.content}
|
||||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||||
placeholder="详细记录您的投资复盘..."
|
placeholder={type === 'plan' ? '详细描述您的投资计划...' : '详细记录您的投资复盘...'}
|
||||||
rows={6}
|
rows={6}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -446,7 +468,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||||
{(formData.tags || []).map((tag, idx) => (
|
{(formData.tags || []).map((tag, idx) => (
|
||||||
<Tag key={idx} size="sm" colorScheme="green">
|
<Tag key={idx} size="sm" colorScheme={colorScheme}>
|
||||||
<TagLeftIcon as={FiHash} />
|
<TagLeftIcon as={FiHash} />
|
||||||
<TagLabel>{tag}</TagLabel>
|
<TagLabel>{tag}</TagLabel>
|
||||||
<TagCloseButton
|
<TagCloseButton
|
||||||
@@ -478,7 +500,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
colorScheme="green"
|
colorScheme={colorScheme}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
isDisabled={!formData.title || !formData.date}
|
isDisabled={!formData.title || !formData.date}
|
||||||
leftIcon={<FiSave />}
|
leftIcon={<FiSave />}
|
||||||
@@ -492,3 +514,5 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default EventPanel;
|
||||||
@@ -3,14 +3,12 @@
|
|||||||
*
|
*
|
||||||
* 性能优化:
|
* 性能优化:
|
||||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||||
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
|
|
||||||
* - 使用 TypeScript 提供类型安全
|
* - 使用 TypeScript 提供类型安全
|
||||||
*
|
*
|
||||||
* 组件架构:
|
* 组件架构:
|
||||||
* - InvestmentPlanningCenter (主组件,~200 行)
|
* - InvestmentPlanningCenter (主组件)
|
||||||
* - CalendarPanel (日历面板,懒加载)
|
* - CalendarPanel (日历面板,懒加载)
|
||||||
* - PlansPanel (计划面板,懒加载)
|
* - EventPanel (通用事件面板,用于计划和复盘)
|
||||||
* - ReviewsPanel (复盘面板,懒加载)
|
|
||||||
* - PlanningContext (数据共享层)
|
* - PlanningContext (数据共享层)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -54,11 +52,8 @@ import './InvestmentCalendar.css';
|
|||||||
const CalendarPanel = lazy(() =>
|
const CalendarPanel = lazy(() =>
|
||||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||||
);
|
);
|
||||||
const PlansPanel = lazy(() =>
|
const EventPanel = lazy(() =>
|
||||||
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
|
import('./EventPanel').then(module => ({ default: module.EventPanel }))
|
||||||
);
|
|
||||||
const ReviewsPanel = lazy(() =>
|
|
||||||
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,14 +215,24 @@ const InvestmentPlanningCenter: React.FC = () => {
|
|||||||
{/* 计划列表面板 */}
|
{/* 计划列表面板 */}
|
||||||
<TabPanel px={0}>
|
<TabPanel px={0}>
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
<Suspense fallback={<PanelLoadingFallback />}>
|
||||||
<PlansPanel />
|
<EventPanel
|
||||||
|
type="plan"
|
||||||
|
colorScheme="purple"
|
||||||
|
label="计划"
|
||||||
|
openModalTrigger={openPlanModalTrigger}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* 复盘列表面板 */}
|
{/* 复盘列表面板 */}
|
||||||
<TabPanel px={0}>
|
<TabPanel px={0}>
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
<Suspense fallback={<PanelLoadingFallback />}>
|
||||||
<ReviewsPanel />
|
<EventPanel
|
||||||
|
type="review"
|
||||||
|
colorScheme="green"
|
||||||
|
label="复盘"
|
||||||
|
openModalTrigger={openReviewModalTrigger}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
|
|||||||
@@ -1,494 +0,0 @@
|
|||||||
/**
|
|
||||||
* PlansPanel - 投资计划列表面板组件
|
|
||||||
* 显示、编辑和管理投资计划
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } 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 {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PlansPanel 组件
|
|
||||||
* 计划列表面板,显示所有投资计划
|
|
||||||
*/
|
|
||||||
export const PlansPanel: React.FC = () => {
|
|
||||||
const {
|
|
||||||
allEvents,
|
|
||||||
loadAllData,
|
|
||||||
loading,
|
|
||||||
toast,
|
|
||||||
textColor,
|
|
||||||
secondaryText,
|
|
||||||
cardBg,
|
|
||||||
borderColor,
|
|
||||||
openPlanModalTrigger,
|
|
||||||
} = 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');
|
|
||||||
|
|
||||||
// 监听外部触发打开新建模态框
|
|
||||||
useEffect(() => {
|
|
||||||
if (openPlanModalTrigger && openPlanModalTrigger > 0) {
|
|
||||||
handleOpenModal(null);
|
|
||||||
}
|
|
||||||
}, [openPlanModalTrigger]);
|
|
||||||
|
|
||||||
// 打开编辑/新建模态框
|
|
||||||
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={FiFileText} 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}>
|
|
||||||
{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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user