feat: 添加合规

This commit is contained in:
zdl
2025-10-20 21:25:33 +08:00
parent d695f8ff7b
commit 6c96299b8f
42 changed files with 7118 additions and 289 deletions

View File

@@ -1,5 +1,6 @@
// src\views\Authentication\SignUp/SignUpIllustration.js
import React, { useState, useEffect, useRef } from "react";
import { getApiBase } from '../../../utils/apiConfig';
import {
Box,
Button,
@@ -31,7 +32,7 @@ import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
import UserAgreementModal from '../../../components/UserAgreementModal';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
const API_BASE_URL = getApiBase();
export default function SignUpPage() {
const [showPassword, setShowPassword] = useState(false);

View File

@@ -67,6 +67,28 @@ const EventDetailModal = ({ visible, event, onClose }) => {
return colors[importance] || 'default';
};
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
const renderPriceTag = (value, label) => {
if (value === null || value === undefined) return `${label}: --`;
@@ -176,7 +198,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
>
<List.Item.Meta
title={`${stock.stock_name} (${stock.stock_code})`}
description={stock.relation_desc ? `${stock.relation_desc}AI合成` : ''}
description={getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}AI合成` : ''}
/>
{stock.change !== null && (
<Tag color={stock.change > 0 ? 'red' : 'green'}>

View File

@@ -52,6 +52,7 @@ import {
import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置中国A股配色红涨绿跌- 分档次显示
@@ -180,7 +181,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
const loadFollowing = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
const data = await res.json();
if (res.ok && data.success) {
@@ -201,7 +202,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
const toggleFollow = async (eventId) => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const res = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -0,0 +1,818 @@
// src/views/Community/components/EventList.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Badge,
Tag,
TagLabel,
TagLeftIcon,
Flex,
Avatar,
Tooltip,
IconButton,
Divider,
Container,
useColorModeValue,
Circle,
Stat,
StatNumber,
StatHelpText,
StatArrow,
ButtonGroup,
Heading,
SimpleGrid,
Card,
CardBody,
Center,
Link,
Spacer,
Switch,
FormControl,
FormLabel,
} from '@chakra-ui/react';
import {
ViewIcon,
ChatIcon,
StarIcon,
TimeIcon,
InfoIcon,
WarningIcon,
WarningTwoIcon,
CheckCircleIcon,
TriangleUpIcon,
TriangleDownIcon,
ArrowForwardIcon,
ExternalLinkIcon,
ViewOffIcon,
} from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import { logger } from '../../../utils/logger';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置中国A股配色红涨绿跌- 分档次显示
const getPriceChangeColor = (value) => {
if (value === null || value === undefined) return 'gray.500';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨用红色,根据涨幅大小使用不同深浅
if (absValue >= 3) return 'red.600'; // 深红色3%以上
if (absValue >= 1) return 'red.500'; // 中红色1-3%
return 'red.400'; // 浅红色0-1%
} else if (value < 0) {
// 下跌用绿色,根据跌幅大小使用不同深浅
if (absValue >= 3) return 'green.600'; // 深绿色3%以上
if (absValue >= 1) return 'green.500'; // 中绿色1-3%
return 'green.400'; // 浅绿色0-1%
}
return 'gray.500';
};
const getPriceChangeBg = (value) => {
if (value === null || value === undefined) return 'gray.50';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨背景色
if (absValue >= 3) return 'red.100'; // 深色背景3%以上
if (absValue >= 1) return 'red.50'; // 中色背景1-3%
return 'red.50'; // 浅色背景0-1%
} else if (value < 0) {
// 下跌背景色
if (absValue >= 3) return 'green.100'; // 深色背景3%以上
if (absValue >= 1) return 'green.50'; // 中色背景1-3%
return 'green.50'; // 浅色背景0-1%
}
return 'gray.50';
};
const getPriceChangeBorderColor = (value) => {
if (value === null || value === undefined) return 'gray.300';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨边框色
if (absValue >= 3) return 'red.500'; // 深边框3%以上
if (absValue >= 1) return 'red.400'; // 中边框1-3%
return 'red.300'; // 浅边框0-1%
} else if (value < 0) {
// 下跌边框色
if (absValue >= 3) return 'green.500'; // 深边框3%以上
if (absValue >= 1) return 'green.400'; // 中边框1-3%
return 'green.300'; // 浅边框0-1%
}
return 'gray.300';
};
// 重要性等级配置 - 金融配色方案
const importanceLevels = {
'S': {
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
},
'A': {
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
},
'B': {
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
},
'C': {
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
}
};
const getImportanceConfig = (importance) => {
return importanceLevels[importance] || importanceLevels['C'];
};
// 自定义的涨跌箭头组件(修复颜色问题)
const PriceArrow = ({ value }) => {
if (value === null || value === undefined) return null;
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
const color = value > 0 ? 'red.500' : 'green.500';
return <Icon color={color} boxSize="16px" />;
};
// ========== 主组件 ==========
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
const navigate = useNavigate();
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
const [followingMap, setFollowingMap] = useState({});
const [followCountMap, setFollowCountMap] = useState({});
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
events.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
const loadFollowing = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
const data = await res.json();
if (res.ok && data.success) {
const map = {};
(data.data || []).forEach(ev => { map[ev.id] = true; });
setFollowingMap(map);
logger.debug('EventList', '关注状态加载成功', {
followingCount: Object.keys(map).length
});
}
} catch (e) {
logger.warn('EventList', '加载关注状态失败', { error: e.message });
}
};
loadFollowing();
// 仅在 events 更新时重跑
}, [events]);
const toggleFollow = async (eventId) => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const res = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
const isFollowing = data.data?.is_following;
const count = data.data?.follower_count ?? 0;
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
logger.debug('EventList', '关注状态切换成功', {
eventId,
isFollowing,
followerCount: count
});
} catch (e) {
logger.warn('EventList', '关注操作失败', {
eventId,
error: e.message
});
}
};
// 专业的金融配色方案
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.200');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const renderPriceChange = (value, label) => {
if (value === null || value === undefined) {
return (
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
</Tag>
);
}
const absValue = Math.abs(value);
const isPositive = value > 0;
// 根据涨跌幅大小选择不同的颜色深浅
let colorScheme = 'gray';
let variant = 'solid';
if (isPositive) {
// 上涨用红色系
if (absValue >= 3) {
colorScheme = 'red';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'red';
variant = 'subtle'; // 中等
} else {
colorScheme = 'red';
variant = 'outline'; // 浅色
}
} else {
// 下跌用绿色系
if (absValue >= 3) {
colorScheme = 'green';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'green';
variant = 'subtle'; // 中等
} else {
colorScheme = 'green';
variant = 'outline'; // 浅色
}
}
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
return (
<Tag
size="lg"
colorScheme={colorScheme}
borderRadius="full"
variant={variant}
boxShadow="sm"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<TagLeftIcon as={Icon} boxSize="16px" />
<TagLabel fontSize="sm" fontWeight="bold">
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
</TagLabel>
</Tag>
);
};
const handleTitleClick = (e, event) => {
e.preventDefault();
e.stopPropagation();
onEventClick(event);
};
const handleViewDetailClick = (e, eventId) => {
e.stopPropagation();
navigate(`/event-detail/${eventId}`);
};
// 精简模式的事件渲染
const renderCompactEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="32px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="sm"
boxShadow="sm"
border="2px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="60px"
/>
</VStack>
{/* 精简事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-1px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={3}
>
<CardBody p={4}>
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
{/* 左侧:标题和时间 */}
<VStack align="start" spacing={2} flex="1" minW="200px">
<Heading
size="sm"
color={linkColor}
_hover={{ textDecoration: 'underline' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
noOfLines={1}
>
{event.title}
</Heading>
<HStack spacing={2} fontSize="xs" color={mutedColor}>
<TimeIcon />
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
<Text></Text>
<Text>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
</VStack>
{/* 右侧:涨跌幅指标 */}
<HStack spacing={3}>
<Tooltip label="平均涨幅" placement="top">
<Box
px={3}
py={1}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="1px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
>
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text
fontSize="sm"
fontWeight="bold"
color={getPriceChangeColor(event.related_avg_chg)}
>
{event.related_avg_chg != null
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
: '--'}
</Text>
</HStack>
</Box>
</Tooltip>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详情
</Button>
<Button
size="sm"
variant={isFollowing ? 'solid' : 'outline'}
colorScheme="yellow"
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
</Button>
</HStack>
</Flex>
</CardBody>
</Card>
</HStack>
);
};
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
const renderDetailedEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="40px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="lg"
boxShadow="md"
border="3px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="100px"
/>
</VStack>
{/* 事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-2px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={4}
>
<CardBody p={5}>
<VStack align="stretch" spacing={3}>
{/* 标题和重要性标签 */}
<Flex align="center" justify="space-between">
<Tooltip
label="点击查看事件详情"
placement="top"
hasArrow
openDelay={500}
>
<Heading
size="md"
color={linkColor}
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
>
{event.title}
</Heading>
</Tooltip>
<Badge
colorScheme={importance.color.split('.')[0]}
px={3}
py={1}
borderRadius="full"
fontSize="sm"
>
{importance.label}优先级
</Badge>
</Flex>
{/* 元信息 */}
<HStack spacing={4} fontSize="sm">
<HStack
bg="blue.50"
px={3}
py={1}
borderRadius="full"
color="blue.700"
fontWeight="medium"
>
<TimeIcon />
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
</HStack>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
{/* 描述 */}
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
{event.description}
</Text>
{/* 价格变化指标 */}
<Box
bg={useColorModeValue('gradient.subtle', 'gray.700')}
bgGradient="linear(to-r, gray.50, white)"
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
平均涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
{event.related_avg_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text fontWeight="bold">
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_max_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
最大涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
{event.related_max_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_max_chg} />
<Text fontWeight="bold">
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_week_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
周涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
{event.related_week_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_week_chg} />
<Text fontWeight="bold">
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
</SimpleGrid>
</Box>
<Divider />
{/* 统计信息和操作按钮 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
<HStack spacing={6}>
<Tooltip label="浏览量" placement="top">
<HStack spacing={1} color={mutedColor}>
<ViewIcon />
<Text fontSize="sm">{event.view_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="帖子数" placement="top">
<HStack spacing={1} color={mutedColor}>
<ChatIcon />
<Text fontSize="sm">{event.post_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="关注数" placement="top">
<HStack spacing={1} color={mutedColor}>
<StarIcon />
<Text fontSize="sm">{followerCount}</Text>
</HStack>
</Tooltip>
</HStack>
<ButtonGroup size="sm" spacing={2}>
<Button
variant="outline"
colorScheme="gray"
leftIcon={<ViewIcon />}
onClick={(e) => {
e.stopPropagation();
onEventClick(event);
}}
>
快速查看
</Button>
<Button
colorScheme="blue"
leftIcon={<ExternalLinkIcon />}
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详细信息
</Button>
<Button
colorScheme="yellow"
variant={isFollowing ? 'solid' : 'outline'}
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
</Button>
</ButtonGroup>
</Flex>
</VStack>
</CardBody>
</Card>
</HStack>
);
};
// 分页组件
const Pagination = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
return (
<Flex justify="center" align="center" mt={8} gap={2}>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current - 1)}
isDisabled={current === 1}
>
上一页
</Button>
<HStack spacing={1}>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
size="sm"
variant={current === pageNum ? 'solid' : 'ghost'}
colorScheme={current === pageNum ? 'blue' : 'gray'}
onClick={() => onChange(pageNum)}
>
{pageNum}
</Button>
);
})}
{totalPages > 5 && <Text>...</Text>}
{totalPages > 5 && (
<Button
size="sm"
variant={current === totalPages ? 'solid' : 'ghost'}
colorScheme={current === totalPages ? 'blue' : 'gray'}
onClick={() => onChange(totalPages)}
>
{totalPages}
</Button>
)}
</HStack>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current + 1)}
isDisabled={current === totalPages}
>
下一页
</Button>
<Text fontSize="sm" color={mutedColor} ml={4}>
{total}
</Text>
</Flex>
);
};
return (
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="container.xl">
{/* 视图切换控制 */}
<Flex justify="flex-end" mb={6}>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
精简模式
</FormLabel>
<Switch
id="compact-mode"
isChecked={isCompactMode}
onChange={(e) => setIsCompactMode(e.target.checked)}
colorScheme="blue"
/>
</FormControl>
</Flex>
{events.length > 0 ? (
<VStack align="stretch" spacing={0}>
{events.map((event, index) => (
<Box key={event.id} position="relative">
{isCompactMode
? renderCompactEvent(event)
: renderDetailedEvent(event)
}
</Box>
))}
</VStack>
) : (
<Center h="300px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
</Center>
)}
{pagination.total > 0 && (
<Pagination
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}
onChange={onPageChange}
/>
)}
</Container>
</Box>
);
};
export default EventList;

View File

@@ -15,6 +15,7 @@ import StockChartAntdModal from '../../../components/StockChart/StockChartAntdMo
import { useSubscription } from '../../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
import CitationMark from '../../../components/Citation/CitationMark';
import CitedContent from '../../../components/Citation/CitedContent';
import { processCitationData } from '../../../utils/citationUtils';
import { logger } from '../../../utils/logger';
import './InvestmentCalendar.css';
@@ -194,9 +195,49 @@ const InvestmentCalendar = () => {
return <span>{stars}</span>;
};
// 显示内容详情
/**
* 显示内容详情
* 支持两种数据格式:
* 1. 字符串格式:直接显示文本,自动添加"(AI合成)"标识
* 例如showContentDetail("这是事件背景内容", "事件背景")
*
* 2. 引用格式使用CitedContent组件渲染显示引用来源
* 例如showContentDetail({
* data: [
* { sentence: "第一句话", citation: { source: "来源1", url: "..." } },
* { sentence: "第二句话", citation: { source: "来源2", url: "..." } }
* ]
* }, "事件背景")
*
* 后端API返回数据格式说明
* - 字符串格式former字段直接返回字符串
* - 引用格式former字段返回 { data: [...] } 对象其中data是引用数组
*/
const showContentDetail = (content, title) => {
setSelectedDetail({ content, title });
let processedContent;
// 判断content类型字符串或引用格式
if (typeof content === 'string') {
// 字符串类型添加AI合成标识
processedContent = {
type: 'text',
content: content + (content ? '\n\n(AI合成)' : '')
};
} else if (content && content.data && Array.isArray(content.data)) {
// 引用格式使用CitedContent渲染
processedContent = {
type: 'citation',
content: content
};
} else {
// 其他情况转为字符串并添加AI标识
processedContent = {
type: 'text',
content: String(content || '') + '\n\n(AI合成)'
};
}
setSelectedDetail({ content: processedContent, title });
setDetailDrawerVisible(true);
};
@@ -332,7 +373,7 @@ const InvestmentCalendar = () => {
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => showContentDetail(text + (text ? '\n\n(AI合成)' : ''), '事件背景')}
onClick={() => showContentDetail(text, '事件背景')}
disabled={!text}
>
查看
@@ -495,8 +536,8 @@ const InvestmentCalendar = () => {
}));
};
// 检查是否有引用数据(可能在 record.reason_citation 或 record[4]
const citationData = record.reason;
// 检查是否有引用数据(reason 就是 record[2]
const citationData = reason;
const hasCitation = citationData && citationData.data && Array.isArray(citationData.data);
if (hasCitation) {
@@ -694,9 +735,17 @@ const InvestmentCalendar = () => {
onClose={() => setDetailDrawerVisible(false)}
visible={detailDrawerVisible}
>
<div className="markdown-content">
<ReactMarkdown>{selectedDetail?.content || '暂无内容'}</ReactMarkdown>
</div>
{selectedDetail?.content?.type === 'citation' ? (
<CitedContent
data={selectedDetail.content.content}
title={selectedDetail.title || '事件背景'}
showAIBadge={true}
/>
) : (
<div className="markdown-content">
<ReactMarkdown>{selectedDetail?.content?.content || '暂无内容'}</ReactMarkdown>
</div>
)}
</Drawer>
{/* 相关股票模态框 */}

View File

@@ -16,6 +16,7 @@ import { useSubscription } from '../../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
import moment from 'moment';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
const { Title, Text } = Typography;
const { TabPane } = Tabs;
@@ -394,7 +395,7 @@ function StockDetailPanel({ visible, event, onClose }) {
const loadWatchlist = useCallback(async () => {
try {
const isProduction = process.env.NODE_ENV === 'production';
const apiBase = isProduction ? '' : process.env.REACT_APP_API_URL || '';
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist`, {
credentials: 'include' // 确保发送cookies
});
@@ -415,7 +416,7 @@ function StockDetailPanel({ visible, event, onClose }) {
const handleWatchlistToggle = async (stockCode, isInWatchlist) => {
try {
const isProduction = process.env.NODE_ENV === 'production';
const apiBase = isProduction ? '' : process.env.REACT_APP_API_URL || '';
const apiBase = getApiBase();
let response;
if (isInWatchlist) {

View File

@@ -37,9 +37,10 @@ import {
import ReactECharts from 'echarts-for-react';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
// API配置
const API_BASE_URL = process.env.NODE_ENV === 'production' ? "" : (process.env.REACT_APP_API_URL || 'http://localhost:5001');
const API_BASE_URL = getApiBase();
// 格式化工具
const formatUtils = {

View File

@@ -1,6 +1,7 @@
// src/views/Market/MarketDataPro.jsx
import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import {
Box,
Container,
@@ -97,8 +98,7 @@ import ReactECharts from 'echarts-for-react';
import ReactMarkdown from 'react-markdown';
// API服务配置
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : (process.env.REACT_APP_API_URL || 'http://localhost:5001');
const API_BASE_URL = getApiBase();
// 主题配置
const themes = {

View File

@@ -27,9 +27,9 @@ import {
} from '@chakra-ui/react';
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
import HomeNavbar from '../../components/Navbars/HomeNavbar';
import { useAuth } from '../../contexts/AuthContext';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import FinancialPanorama from './FinancialPanorama';
import ForecastReport from './ForecastReport';
import MarketDataView from './MarketDataView';
@@ -46,8 +46,7 @@ const CompanyIndex = () => {
const bgColor = useColorModeValue('white', 'gray.800');
const tabBg = useColorModeValue('gray.50', 'gray.700');
const activeBg = useColorModeValue('blue.500', 'blue.400');
const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001'));
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
@@ -153,9 +152,7 @@ const CompanyIndex = () => {
};
return (
<>
<HomeNavbar />
<Container maxW="container.xl" py={5}>
<Container maxW="container.xl" py={5}>
{/* 页面标题和股票搜索 */}
<VStack align="stretch" spacing={5}>
<Card bg={bgColor} shadow="md">
@@ -308,8 +305,7 @@ const CompanyIndex = () => {
</CardBody>
</Card>
</VStack>
</Container>
</>
</Container>
);
};

View File

@@ -1,6 +1,7 @@
// src/views/Dashboard/Center.js
import React, { useEffect, useState, useCallback } from 'react';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import {
Box,
Flex,
@@ -85,7 +86,7 @@ export default function CenterDashboard() {
const loadData = useCallback(async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const ts = Date.now();
const [w, e, c] = await Promise.all([
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
@@ -118,7 +119,7 @@ export default function CenterDashboard() {
const loadRealtimeQuotes = useCallback(async () => {
try {
setQuotesLoading(true);
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const response = await fetch(base + '/api/account/watchlist/realtime', {
credentials: 'include',
cache: 'no-store',

View File

@@ -55,6 +55,7 @@ import interactionPlugin from '@fullcalendar/interaction';
import moment from 'moment';
import 'moment/locale/zh-cn';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import './InvestmentCalendar.css';
moment.locale('zh-cn');
@@ -86,7 +87,7 @@ export default function InvestmentCalendarChakra() {
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 base = getApiBase();
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
const userResponse = await fetch(base + '/api/account/calendar/events', {
@@ -168,7 +169,7 @@ export default function InvestmentCalendarChakra() {
// 添加新事件
const handleAddEvent = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const eventData = {
...newEvent,
@@ -235,7 +236,7 @@ export default function InvestmentCalendarChakra() {
return;
}
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
method: 'DELETE',

View File

@@ -0,0 +1,493 @@
// 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>
);
}

View File

@@ -69,6 +69,7 @@ import interactionPlugin from '@fullcalendar/interaction';
import moment from 'moment';
import 'moment/locale/zh-cn';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import '../components/InvestmentCalendar.css';
moment.locale('zh-cn');
@@ -95,7 +96,7 @@ export default function InvestmentPlanningCenter() {
const loadAllData = 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 base = getApiBase();
const response = await fetch(base + '/api/account/calendar/events', {
credentials: 'include'
@@ -257,7 +258,7 @@ function CalendarPanel() {
// 添加新事件
const handleAddEvent = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const eventData = {
...newEvent,
@@ -324,7 +325,7 @@ function CalendarPanel() {
return;
}
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
method: 'DELETE',
@@ -658,7 +659,7 @@ function PlansPanel() {
// 保存数据
const handleSave = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
@@ -709,7 +710,7 @@ function PlansPanel() {
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 base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',
@@ -1082,7 +1083,7 @@ function ReviewsPanel() {
// 保存数据
const handleSave = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
@@ -1133,7 +1134,7 @@ function ReviewsPanel() {
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 base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,7 @@ import {
import moment from 'moment';
import 'moment/locale/zh-cn';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
moment.locale('zh-cn');
@@ -97,7 +98,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
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 base = getApiBase();
const response = await fetch(base + '/api/account/investment-plans', {
credentials: 'include'
@@ -153,7 +154,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
// 保存数据
const handleSave = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
@@ -205,7 +206,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
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 base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',

View File

@@ -0,0 +1,587 @@
// 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

@@ -149,11 +149,34 @@ const HistoricalEvents = ({
return `${Math.floor(diffDays / 365)}年前`;
};
// 处理关联描述字段的辅助函数
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
// 可展开的文本组件
const ExpandableText = ({ text, maxLength = 20 }) => {
const { isOpen, onToggle } = useDisclosure();
const [shouldTruncate, setShouldTruncate] = useState(false);
useEffect(() => {
if (text && text.length > maxLength) {
setShouldTruncate(true);
@@ -161,22 +184,22 @@ const HistoricalEvents = ({
setShouldTruncate(false);
}
}, [text, maxLength]);
if (!text) return <Text fontSize="xs">--</Text>;
const displayText = shouldTruncate && !isOpen
? text.substring(0, maxLength) + '...'
const displayText = shouldTruncate && !isOpen
? text.substring(0, maxLength) + '...'
: text;
return (
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={isOpen ? undefined : 2} maxW="300px">
{displayText}{text.includes('AI合成') ? '' : 'AI合成'}
</Text>
{shouldTruncate && (
<Button
size="xs"
variant="link"
<Button
size="xs"
variant="link"
color="blue.500"
onClick={onToggle}
height="auto"
@@ -444,13 +467,36 @@ const HistoricalEvents = ({
// 股票列表子组件
const StocksList = ({ stocks, eventTradingDate }) => {
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 处理股票代码,移除.SZ/.SH后缀
const formatStockCode = (stockCode) => {
if (!stockCode) return '';
return stockCode.replace(/\.(SZ|SH)$/i, '');
};
// 处理关联描述字段的辅助函数
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
if (!stocks || stocks.length === 0) {
return (
<Box textAlign="center" py={8} color={textSecondary}>
@@ -527,7 +573,7 @@ const StocksList = ({ stocks, eventTradingDate }) => {
<Td>
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={2} maxW="300px">
{stock.relation_desc ? `${stock.relation_desc}AI合成` : '--'}
{getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}AI合成` : '--'}
</Text>
</VStack>
</Td>

View File

@@ -65,6 +65,7 @@ import ReactECharts from 'echarts-for-react';
import HomeNavbar from '../../../components/Navbars/HomeNavbar';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
// 板块关联TOP10数据计算
function getSectorRelationTop10(sectorData) {
@@ -177,7 +178,7 @@ function getTop10Sectors(chartData) {
}
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE = isProduction ? "" : process.env.REACT_APP_API_URL;
const API_BASE = getApiBase();
// const API_BASE = 'http://49.232.185.254:5001'; // 改回5001端口确保和后端一致
// 涨停分析服务

View File

@@ -206,6 +206,28 @@ const RelatedStocks = ({
return `股票${stock.stock_code.split('.')[0]}`;
};
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '--';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '--';
}
}
// 其他情况返回默认值
return '--';
};
// ==================== 数据处理 ====================
const filteredAndSortedStocks = stocksData
.filter(stock => {
@@ -442,7 +464,7 @@ const RelatedStocks = ({
{/* 关联描述 */}
<Td maxW="200px">
<Text fontSize="xs" noOfLines={2}>
{stock.relation_desc || '--'}
{getRelationDesc(stock.relation_desc)}
</Text>
</Td>

View File

@@ -405,6 +405,14 @@ const TransmissionChainAnalysis = ({ eventId }) => {
const modalBgColor = useColorModeValue('white', 'gray.800');
const modalBorderColor = useColorModeValue('gray.200', 'gray.600');
// 关闭弹窗并清空状态
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedNode(null);
setNodeDetail(null);
setTransmissionPath([]);
};
// 延迟初始化图表确保DOM容器准备好
useEffect(() => {
const timer = setTimeout(() => {
@@ -771,9 +779,10 @@ const TransmissionChainAnalysis = ({ eventId }) => {
)}
{/* 节点详情弹窗 */}
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} size="xl">
<ModalOverlay />
<ModalContent maxH="80vh" bg={modalBgColor}>
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={handleCloseModal} size="xl">
<ModalOverlay />
<ModalContent maxH="80vh" bg={modalBgColor}>
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor}>
<HStack justify="space-between">
<Text>{selectedNode ? '节点详情' : '传导链分析'}</Text>
@@ -845,9 +854,9 @@ const TransmissionChainAnalysis = ({ eventId }) => {
{selectedNode.extra?.description && (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">描述</Text>
<Box
fontSize="sm"
color="gray.600"
<Box
fontSize="sm"
color="gray.600"
borderLeft="3px solid"
borderColor="blue.200"
pl={3}
@@ -856,7 +865,19 @@ const TransmissionChainAnalysis = ({ eventId }) => {
borderRadius="md"
fontStyle="italic"
>
{selectedNode.extra.description}AI合成
{selectedNode.extra.description?.data ? (
<>
<CitedContent
data={selectedNode.extra.description}
title=""
showAIBadge={false}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
/>
AI合成
</>
) : (
`${selectedNode.extra.description}AI合成`
)}
</Box>
</Box>
)}
@@ -899,16 +920,17 @@ const TransmissionChainAnalysis = ({ eventId }) => {
<HStack justify="space-between" align="flex-start">
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{parent.name}</Text>
{parent.transmission_mechanism_citation?.data ? (
<Box fontSize="xs">
{parent.transmission_mechanism?.data ? (
<Text fontSize="xs">
<Text as="span" fontWeight="bold">机制: </Text>
<CitedContent
data={parent.transmission_mechanism_citation.data}
data={parent.transmission_mechanism}
title=""
showAIBadge={false}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline' }}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
/>
</Box>
AI合成
</Text>
) : parent.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
机制: {parent.transmission_mechanism}AI合成
@@ -950,15 +972,16 @@ const TransmissionChainAnalysis = ({ eventId }) => {
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{child.name}</Text>
{child.transmission_mechanism?.data ? (
<Box fontSize="xs">
<Text fontSize="xs">
<Text as="span" fontWeight="bold">机制: </Text>
<CitedContent
data={child.transmission_mechanism.data}
data={child.transmission_mechanism}
title=""
showAIBadge={false}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline' }}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
/>
</Box>
AI合成
</Text>
) : child.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
机制: {child.transmission_mechanism}AI合成
@@ -991,10 +1014,11 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor={modalBorderColor}>
<Button onClick={() => setIsModalOpen(false)}>关闭</Button>
<Button onClick={handleCloseModal}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -1,11 +1,39 @@
import { Flex } from '@chakra-ui/react';
import {
Flex,
Container,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Text
} from '@chakra-ui/react';
import { ChevronRightIcon } from '@chakra-ui/icons';
import React from 'react';
import SubscriptionContent from 'components/Subscription/SubscriptionContent';
function Subscription() {
return (
<Flex direction='column' pt={{ base: '120px', md: '75px' }} px={{ base: '20px', md: '40px' }}>
<SubscriptionContent />
<Flex direction='column'>
<Container maxW="container.xl" px={{ base: 4, md: 6 }} py={{ base: 4, md: 6 }}>
{/* 面包屑导航 */}
<Breadcrumb
spacing='8px'
separator={<ChevronRightIcon color='gray.500' />}
mb={6}
fontSize='sm'
>
<BreadcrumbItem>
<BreadcrumbLink href='/home'>首页</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink href='/home/pages/account'>个人中心</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<Text color='gray.500'>订阅管理</Text>
</BreadcrumbItem>
</Breadcrumb>
<SubscriptionContent />
</Container>
</Flex>
);
}

View File

@@ -57,6 +57,7 @@ import {
} from '@chakra-ui/icons';
import { FaWeixin, FaMobile, FaEnvelope } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { getApiBase } from '../../utils/apiConfig';
import { logger } from '../../utils/logger';
export default function SettingsPage() {
@@ -118,9 +119,7 @@ export default function SettingsPage() {
// 获取密码状态
const fetchPasswordStatus = async () => {
try {
const API_BASE_URL = process.env.NODE_ENV === 'production'
? ""
: process.env.REACT_APP_API_URL || "http://49.232.185.254:5001";
const API_BASE_URL = getApiBase();
logger.api.request('GET', '/api/account/password-status', null);
@@ -191,9 +190,7 @@ export default function SettingsPage() {
setIsLoading(true);
try {
// 调用后端API修改密码
const API_BASE_URL = process.env.NODE_ENV === 'production'
? ""
: process.env.REACT_APP_API_URL || "http://49.232.185.254:5001";
const API_BASE_URL = getApiBase();
const response = await fetch(`${API_BASE_URL}/api/account/change-password`, {
method: 'POST',
@@ -257,7 +254,7 @@ export default function SettingsPage() {
const url = '/api/account/phone/send-code';
logger.api.request('POST', url, { phone: phoneForm.phone.substring(0, 3) + '****' });
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + url, {
const res = await fetch(getApiBase() + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -272,7 +269,7 @@ export default function SettingsPage() {
logger.api.request('POST', url, { email: emailForm.email.substring(0, 3) + '***@***' });
// 使用绑定邮箱的验证码API
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + url, {
const res = await fetch(getApiBase() + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -305,7 +302,7 @@ export default function SettingsPage() {
const handlePhoneBind = async () => {
setIsLoading(true);
try {
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/phone/bind', {
const res = await fetch(getApiBase() + '/api/account/phone/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -346,7 +343,7 @@ export default function SettingsPage() {
setIsLoading(true);
try {
// 调用真实的邮箱绑定API
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/email/bind', {
const res = await fetch(getApiBase() + '/api/account/email/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -559,7 +556,7 @@ export default function SettingsPage() {
onClick={async () => {
setIsLoading(true);
try {
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/phone/unbind', {
const res = await fetch(getApiBase() + '/api/account/phone/unbind', {
method: 'POST',
credentials: 'include'
});
@@ -641,7 +638,7 @@ export default function SettingsPage() {
onClick={async () => {
setIsLoading(true);
try {
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/wechat/unbind', {
const res = await fetch(getApiBase() + '/api/account/wechat/unbind', {
method: 'POST',
credentials: 'include'
});
@@ -662,7 +659,7 @@ export default function SettingsPage() {
<Button leftIcon={<LinkIcon />} colorScheme="green" onClick={async () => {
setIsLoading(true);
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const base = getApiBase();
const res = await fetch(base + '/api/account/wechat/qrcode', { method: 'GET', credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || '获取二维码失败');

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,10 @@
import { useState, useCallback } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
// API 基础URL - 修复HTTPS混合内容问题
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '' // 生产环境使用相对路径通过nginx代理
: (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const API_BASE_URL = getApiBase();
// API 请求封装
const apiRequest = async (url, options = {}) => {

View File

@@ -0,0 +1,384 @@
// src/views/TradingSimulation/hooks/useTradingAccount.js - 模拟盘账户管理 Hook
import { useState, useCallback } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { logger } from '../../../utils/logger';
// API 基础URL - 修复HTTPS混合内容问题
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '' // 生产环境使用相对路径通过nginx代理
: (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
// API 请求封装
const apiRequest = async (url, options = {}) => {
const response = await fetch(`${API_BASE_URL}${url}`, {
credentials: 'include', // 包含session cookie
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
};
// 数据字段映射函数
const mapAccountData = (backendData) => {
return {
id: backendData.account_id,
accountName: backendData.account_name,
initialCash: backendData.initial_capital,
availableCash: backendData.available_cash,
frozenCash: backendData.frozen_cash,
marketValue: backendData.position_value,
totalAssets: backendData.total_assets,
totalProfit: backendData.total_profit,
totalProfitPercent: backendData.total_profit_rate,
dailyProfit: backendData.daily_profit,
dailyProfitRate: backendData.daily_profit_rate,
riskLevel: 'MEDIUM', // 默认值
marginBalance: 0,
shortBalance: 0,
lastUpdated: backendData.updated_at,
createdAt: backendData.created_at
};
};
const mapPositionData = (backendPositions) => {
return backendPositions.map(pos => ({
id: pos.id,
stockCode: pos.stock_code,
stockName: pos.stock_name,
quantity: pos.position_qty,
availableQuantity: pos.available_qty,
frozenQuantity: pos.frozen_qty,
avgPrice: pos.avg_cost,
currentPrice: pos.current_price,
totalCost: pos.position_qty * pos.avg_cost,
marketValue: pos.market_value,
profit: pos.profit,
profitRate: pos.profit_rate,
todayProfit: pos.today_profit,
todayProfitRate: pos.today_profit_rate,
updatedAt: pos.updated_at
}));
};
const mapOrderData = (backendOrders) => {
return backendOrders.map(order => ({
id: order.id,
orderId: order.order_no,
stockCode: order.stock_code,
stockName: order.stock_name,
type: order.order_type, // 添加 type 字段
orderType: order.order_type,
priceType: order.price_type,
orderPrice: order.order_price,
quantity: order.order_qty,
filledQuantity: order.filled_qty,
price: order.filled_price, // 添加 price 字段
filledPrice: order.filled_price,
totalAmount: order.filled_amount, // 添加 totalAmount 字段
filledAmount: order.filled_amount,
commission: order.commission,
stampTax: order.stamp_tax,
transferFee: order.transfer_fee,
totalFee: order.total_fee,
status: order.status,
rejectReason: order.reject_reason,
createdAt: order.order_time,
filledAt: order.filled_time
}));
};
export function useTradingAccount() {
const { user } = useAuth();
const [account, setAccount] = useState(null);
const [positions, setPositions] = useState([]);
const [tradingHistory, setTradingHistory] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [stockQuotes, setStockQuotes] = useState({});
// 搜索股票
const searchStocks = useCallback(async (keyword) => {
// 调试模式:返回模拟数据
if (!user || user.id === 'demo') {
logger.debug('useTradingAccount', '调试模式:模拟股票搜索', { keyword });
const mockStocks = [
{
stock_code: '000001',
stock_name: '平安银行',
current_price: 12.50,
pinyin_abbr: 'payh',
security_type: 'A股',
exchange: '深交所'
},
{
stock_code: '600036',
stock_name: '招商银行',
current_price: 42.80,
pinyin_abbr: 'zsyh',
security_type: 'A股',
exchange: '上交所'
},
{
stock_code: '688256',
stock_name: '寒武纪',
current_price: 1394.94,
pinyin_abbr: 'hwj',
security_type: 'A股',
exchange: '上交所科创板'
}
];
return mockStocks.filter(stock =>
stock.stock_code.includes(keyword) ||
stock.stock_name.includes(keyword) ||
stock.pinyin_abbr.includes(keyword.toLowerCase())
);
}
try {
const response = await apiRequest(`/api/stocks/search?q=${encodeURIComponent(keyword)}&limit=10`);
return response.data || [];
} catch (error) {
logger.error('useTradingAccount', 'searchStocks', error, { keyword });
return [];
}
}, [user]);
// 刷新账户数据
const refreshAccount = useCallback(async () => {
// 调试模式使用模拟数据因为后端API可能有CORS问题
if (!user || user.id === 'demo') {
logger.debug('useTradingAccount', '调试模式:使用模拟账户数据', { userId: user?.id });
setAccount({
id: 'demo',
accountName: '演示账户',
initialCash: 1000000,
availableCash: 950000,
frozenCash: 0,
marketValue: 50000,
totalAssets: 1000000,
totalProfit: 0,
totalProfitPercent: 0,
dailyProfit: 0,
dailyProfitRate: 0,
riskLevel: 'MEDIUM',
marginBalance: 0,
shortBalance: 0,
lastUpdated: new Date().toISOString(),
createdAt: new Date().toISOString()
});
setPositions([]);
setTradingHistory([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
// 获取账户信息
const accountResponse = await apiRequest('/api/simulation/account');
setAccount(mapAccountData(accountResponse.data));
// 获取持仓信息
const positionsResponse = await apiRequest('/api/simulation/positions');
setPositions(mapPositionData(positionsResponse.data || []));
// 获取交易历史
const ordersResponse = await apiRequest('/api/simulation/orders?limit=100');
setTradingHistory(mapOrderData(ordersResponse.data || []));
} catch (err) {
logger.error('useTradingAccount', 'refreshAccount', err, { userId: user?.id });
setError('加载账户数据失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, [user]);
// 买入股票
const buyStock = useCallback(async (stockCode, quantity, orderType = 'MARKET', limitPrice = null) => {
if (!account) {
throw new Error('账户未初始化');
}
// 调试模式:模拟买入成功
if (!user || user.id === 'demo') {
logger.debug('useTradingAccount', '调试模式:模拟买入', { stockCode, quantity, orderType });
return { success: true, orderId: 'demo_' + Date.now() };
}
setIsLoading(true);
try {
const requestData = {
stock_code: stockCode,
order_type: 'BUY',
order_qty: quantity,
price_type: orderType
};
const response = await apiRequest('/api/simulation/place-order', {
method: 'POST',
body: JSON.stringify(requestData)
});
// 刷新账户数据
await refreshAccount();
return { success: true, orderId: response.data.order_no };
} catch (err) {
throw new Error('买入失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, [account, user, refreshAccount]);
// 卖出股票
const sellStock = useCallback(async (stockCode, quantity, orderType = 'MARKET', limitPrice = null) => {
if (!account) {
throw new Error('账户未初始化');
}
// 调试模式:模拟卖出成功
if (!user || user.id === 'demo') {
logger.debug('useTradingAccount', '调试模式:模拟卖出', { stockCode, quantity, orderType });
return { success: true, orderId: 'demo_' + Date.now() };
}
setIsLoading(true);
try {
const requestData = {
stock_code: stockCode,
order_type: 'SELL',
order_qty: quantity,
price_type: orderType
};
const response = await apiRequest('/api/simulation/place-order', {
method: 'POST',
body: JSON.stringify(requestData)
});
// 刷新账户数据
await refreshAccount();
return { success: true, orderId: response.data.order_no };
} catch (err) {
throw new Error('卖出失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, [account, user, refreshAccount]);
// 撤销订单
const cancelOrder = useCallback(async (orderId) => {
setIsLoading(true);
try {
const response = await apiRequest(`/api/simulation/cancel-order/${orderId}`, {
method: 'POST'
});
// 刷新交易历史
const ordersResponse = await apiRequest('/api/simulation/orders?limit=100');
setTradingHistory(mapOrderData(ordersResponse.data || []));
return { success: true };
} catch (err) {
throw new Error('撤单失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, []);
// 获取交易记录
const getTransactions = useCallback(async (options = {}) => {
try {
const params = new URLSearchParams();
if (options.limit) params.append('limit', options.limit);
if (options.date) params.append('date', options.date);
const response = await apiRequest(`/api/simulation/transactions?${params.toString()}`);
return response.data || [];
} catch (error) {
logger.error('useTradingAccount', 'getTransactions', error, options);
return [];
}
}, []);
// 获取资产历史
const getAssetHistory = useCallback(async (days = 30) => {
// 调试模式demo用户返回模拟数据避免CORS
if (!user || user.id === 'demo') {
const now = Date.now();
const data = Array.from({ length: days }, (_, i) => {
const date = new Date(now - (days - 1 - i) * 24 * 3600 * 1000);
// 简单生成一条平滑的收益曲线
const value = Math.sin(i / 5) * 0.01 + 0.001 * i;
return { date: date.toISOString().slice(0, 10), value };
});
return data;
}
try {
const response = await apiRequest(`/api/simulation/statistics?days=${days}`);
return response.data?.daily_returns || [];
} catch (error) {
logger.error('useTradingAccount', 'getAssetHistory', error, { days, userId: user?.id });
return [];
}
}, [user]);
// 获取股票实时行情(如果需要的话)
const getStockQuotes = useCallback(async (stockCodes) => {
try {
// 这里可以调用自选股实时行情接口
const response = await apiRequest('/api/account/watchlist/quotes');
if (response.success) {
const quotes = {};
response.data.forEach(item => {
quotes[item.stock_code] = {
name: item.stock_name,
price: item.current_price,
change: item.change,
changePercent: item.change_percent
};
});
setStockQuotes(quotes);
return quotes;
}
return {};
} catch (error) {
logger.error('useTradingAccount', 'getStockQuotes', error, { stockCodes });
return {};
}
}, []);
return {
account,
positions,
tradingHistory,
isLoading,
error,
stockQuotes,
buyStock,
sellStock,
cancelOrder,
refreshAccount,
searchStocks,
getTransactions,
getAssetHistory,
getStockQuotes
};
}