Files
vf_react/src/views/Dashboard/components/InvestmentPlanningCenter.js.old
2025-11-18 18:22:31 +08:00

1421 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/Dashboard/components/InvestmentPlanningCenter.js
import React, { useState, useEffect, useCallback, createContext, useContext } 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,
TagCloseButton,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
InputGroup,
InputLeftElement,
} from '@chakra-ui/react';
import {
FiCalendar,
FiClock,
FiStar,
FiTrendingUp,
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiX,
FiTarget,
FiFileText,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import '../components/InvestmentCalendar.css';
dayjs.locale('zh-cn');
// 创建 Context 用于跨标签页共享数据
const PlanningDataContext = createContext();
export default function InvestmentPlanningCenter() {
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textColor = useColorModeValue('gray.700', 'white');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const cardBg = useColorModeValue('gray.50', 'gray.700');
// 全局数据状态
const [allEvents, setAllEvents] = useState([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState(0);
// 加载所有事件数据(日历事件 + 计划 + 复盘)
const loadAllData = useCallback(async () => {
try {
setLoading(true);
const base = getApiBase();
const response = await fetch(base + '/api/account/calendar/events', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setAllEvents(data.data || []);
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
count: data.data?.length || 0
});
}
}
} catch (error) {
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAllData();
}, [loadAllData]);
// 提供给子组件的 Context 值
const contextValue = {
allEvents,
setAllEvents,
loadAllData,
loading,
setLoading,
activeTab,
setActiveTab,
toast,
bgColor,
borderColor,
textColor,
secondaryText,
cardBg,
};
return (
<PlanningDataContext.Provider 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>
</HStack>
</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} />
我的计划 ({allEvents.filter(e => e.type === 'plan').length})
</Tab>
<Tab>
<Icon as={FiFileText} mr={2} />
我的复盘 ({allEvents.filter(e => e.type === 'review').length})
</Tab>
</TabList>
<TabPanels>
<TabPanel px={0}>
<CalendarPanel />
</TabPanel>
<TabPanel px={0}>
<PlansPanel />
</TabPanel>
<TabPanel px={0}>
<ReviewsPanel />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
</PlanningDataContext.Provider>
);
}
// 日历视图面板
function CalendarPanel() {
const {
allEvents,
loadAllData,
loading,
setActiveTab,
toast,
borderColor,
secondaryText,
} = useContext(PlanningDataContext);
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
const [selectedDate, setSelectedDate] = useState(null);
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
const [newEvent, setNewEvent] = useState({
title: '',
description: '',
type: 'plan',
importance: 3,
stocks: '',
});
// 转换数据为 FullCalendar 格式
const calendarEvents = allEvents.map(event => ({
...event,
id: `${event.source || 'user'}-${event.id}`,
title: event.title,
start: event.event_date,
date: event.event_date,
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
extendedProps: {
...event,
isSystem: event.source === 'future',
}
}));
// 处理日期点击
const handleDateClick = (info) => {
const clickedDate = dayjs(info.date);
setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(event =>
dayjs(event.event_date).isSame(clickedDate, 'day')
);
setSelectedDateEvents(dayEvents);
onOpen();
};
// 处理事件点击
const handleEventClick = (info) => {
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 () => {
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) => {
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) => {
if (event.type === 'plan') {
setActiveTab(1); // 跳转到"我的计划"标签页
} else if (event.type === 'review') {
setActiveTab(2); // 跳转到"我的复盘"标签页
}
onClose();
};
return (
<Box>
<Flex justify="flex-end" mb={4}>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
>
添加计划
</Button>
</Flex>
{loading ? (
<Center h="560px">
<Spinner size="xl" color="purple.500" />
</Center>
) : (
<Box height={{ base: '500px', md: '600px' }}>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: ''
}}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%"
dayMaxEvents={3}
moreLinkText="更多"
buttonText={{
today: '今天',
month: '月',
week: '周'
}}
/>
</Box>
)}
{/* 查看事件详情 Modal */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selectedDateEvents.length === 0 ? (
<Center py={8}>
<VStack>
<Text color={secondaryText}>当天没有事件</Text>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => {
onClose();
onAddOpen();
}}
>
添加投资计划
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={4}>
{selectedDateEvents.map((event, idx) => (
<Box
key={idx}
p={4}
borderRadius="md"
border="1px"
borderColor={borderColor}
>
<Flex justify="space-between" align="start" mb={2}>
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold" fontSize="lg">
{event.title}
</Text>
{event.source === 'future' ? (
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
) : event.type === 'plan' ? (
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
) : (
<Badge colorScheme="green" variant="subtle">我的复盘</Badge>
)}
</HStack>
{event.importance && (
<HStack spacing={2}>
<Icon as={FiStar} color="yellow.500" />
<Text fontSize="sm" color={secondaryText}>
重要度: {event.importance}/5
</Text>
</HStack>
)}
</VStack>
<HStack>
{!event.source || event.source === 'user' ? (
<>
<Tooltip label="查看详情">
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
colorScheme="blue"
onClick={() => handleViewDetails(event)}
/>
</Tooltip>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDeleteEvent(event.id)}
/>
</>
) : null}
</HStack>
</Flex>
{event.description && (
<Text fontSize="sm" color={secondaryText} mb={2}>
{event.description}
</Text>
)}
{event.stocks && event.stocks.length > 0 && (
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
{event.stocks.map((stock, i) => (
<Tag key={i} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</HStack>
)}
</Box>
))}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 添加投资计划 Modal */}
{isAddOpen && (
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
添加投资计划
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>标题</FormLabel>
<Input
value={newEvent.title}
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
placeholder="例如:关注半导体板块"
/>
</FormControl>
<FormControl>
<FormLabel>描述</FormLabel>
<Textarea
value={newEvent.description}
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
placeholder="详细描述您的投资计划..."
rows={3}
/>
</FormControl>
<FormControl>
<FormLabel>类型</FormLabel>
<Select
value={newEvent.type}
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value })}
>
<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>
);
}
// 计划列表面板
function PlansPanel() {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = useContext(PlanningDataContext);
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState('');
const [tagInput, setTagInput] = useState('');
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item = null) => {
if (item) {
setEditingItem(item);
setFormData({
...item,
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
content: item.description || item.content || '',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async () => {
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) => {
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 = () => {
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', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
// 渲染单个卡片
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={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)}
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
/>
</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 })}
>
<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>
);
}
// 复盘列表面板
function ReviewsPanel() {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = useContext(PlanningDataContext);
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState('');
const [tagInput, setTagInput] = useState('');
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item = null) => {
if (item) {
setEditingItem(item);
setFormData({
...item,
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
content: item.description || item.content || '',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async () => {
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) => {
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 = () => {
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 renderCard = (item) => {
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>
</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 || 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="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="purple">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</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>
);
}