feat(HistoricalEvents): 优化历史事件列表 UI 和相关股票弹窗
主要改进:
1. 历史事件列表改为卡片式网格布局
- 移除时间轴样式(垂直线 + 节点图标)
- 使用 SimpleGrid 响应式布局(1列/2列/3列)
- 卡片显示:事件名称、日期、相关度、重要性、描述
- 点击"相关股票"按钮打开 Modal 弹窗
2. 历史事件对比默认展开
- DynamicNewsDetailPanel: isHistoricalOpen 初始值改为 true
- 用户打开事件详情面板时,历史事件对比区域默认展开
3. 相关股票弹窗改为卡片式布局
- StocksList 组件从 Table 表格改为 SimpleGrid 卡片
- 显示 6 个字段:代码、名称、板块、相关度、涨幅、关联原因
- 关联原因支持展开/收起(startingHeight: 40px)
- 响应式网格布局(base: 1列, md: 2列, lg: 3列)
4. 修复字段映射兼容性
- 添加 getEventDate() 兼容多种日期字段
- 添加 getEventContent() 兼容多种内容字段
- 支持字段:event_date/created_at/date、content/description/summary
- 添加 Debug 日志输出实际数据结构
5. 修复弹窗关闭问题
- 添加 handleCloseModal() 同时清空两个状态
- 使用条件渲染 {stocksModalOpen && <Modal>}
- 关闭时完全卸载 Modal 组件,避免状态残留
技术细节:
- 移除未使用的导入(Table, Thead, Tbody, Tr, Th, Td 等)
- 新增工具函数:formatChange, getChangeColor, getCorrelationColor
- 卡片 hover 效果:boxShadow + borderColor 变化
- 涨跌幅颜色:红色(上涨)/ 绿色(下跌)
- 相关度颜色梯度:>=80% 红色, >=60% 橙色, <60% 绿色
代码统计:
- HistoricalEvents.js: -402 行, +344 行(净减少 58 行)
- 移除时间轴复杂逻辑,简化组件结构
- 提升代码可维护性和可读性
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,7 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
|||||||
|
|
||||||
// 折叠状态管理
|
// 折叠状态管理
|
||||||
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
||||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
const [isHistoricalOpen, setIsHistoricalOpen] = useState(true);
|
||||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
||||||
|
|
||||||
// 关注状态管理
|
// 关注状态管理
|
||||||
|
|||||||
@@ -7,121 +7,107 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Collapse,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Alert,
|
Alert,
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
Card,
|
SimpleGrid,
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
Divider,
|
|
||||||
Icon,
|
Icon,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
Tooltip,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Table,
|
Modal,
|
||||||
Thead,
|
ModalOverlay,
|
||||||
Tbody,
|
ModalContent,
|
||||||
Tr,
|
ModalHeader,
|
||||||
Th,
|
ModalCloseButton,
|
||||||
Td,
|
ModalBody,
|
||||||
TableContainer,
|
Link,
|
||||||
Link
|
Flex,
|
||||||
|
Collapse
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
FaExclamationTriangle,
|
|
||||||
FaClock,
|
|
||||||
FaCalendarAlt,
|
|
||||||
FaChartLine,
|
FaChartLine,
|
||||||
FaEye,
|
FaInfoCircle
|
||||||
FaTimes,
|
|
||||||
FaInfoCircle,
|
|
||||||
FaChevronDown,
|
|
||||||
FaChevronUp
|
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { stockService } from '../../../services/eventService';
|
import { stockService } from '../../../services/eventService';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
const HistoricalEvents = ({
|
const HistoricalEvents = ({
|
||||||
events = [],
|
events = [],
|
||||||
expectationScore = null,
|
expectationScore = null,
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null
|
error = null
|
||||||
}) => {
|
}) => {
|
||||||
// 所有 useState/useEffect/useContext/useRef/useCallback/useMemo 必须在组件顶层、顺序一致
|
// 状态管理
|
||||||
// 不要在 if/循环/回调中调用 Hook
|
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
|
||||||
const [expandedEvents, setExpandedEvents] = useState(new Set());
|
const [stocksModalOpen, setStocksModalOpen] = useState(false);
|
||||||
const [expandedStocks, setExpandedStocks] = useState(new Set()); // 追踪哪些事件的股票列表被展开
|
|
||||||
const [eventStocks, setEventStocks] = useState({});
|
const [eventStocks, setEventStocks] = useState({});
|
||||||
const [loadingStocks, setLoadingStocks] = useState({});
|
const [loadingStocks, setLoadingStocks] = useState({});
|
||||||
|
|
||||||
// 颜色主题
|
// 颜色主题
|
||||||
const timelineBg = useColorModeValue('#D4AF37', '#B8860B');
|
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||||
|
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||||
|
|
||||||
// 切换事件展开状态
|
// 字段兼容函数
|
||||||
const toggleEventExpansion = (eventId) => {
|
const getEventDate = (event) => {
|
||||||
const newExpanded = new Set(expandedEvents);
|
return event?.event_date || event?.created_at || event?.date || event?.publish_time;
|
||||||
if (newExpanded.has(eventId)) {
|
|
||||||
newExpanded.delete(eventId);
|
|
||||||
} else {
|
|
||||||
newExpanded.add(eventId);
|
|
||||||
}
|
|
||||||
setExpandedEvents(newExpanded);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换股票列表展开状态
|
const getEventContent = (event) => {
|
||||||
const toggleStocksExpansion = async (event) => {
|
return event?.content || event?.description || event?.summary;
|
||||||
const eventId = event.id;
|
};
|
||||||
const newExpanded = new Set(expandedStocks);
|
|
||||||
|
|
||||||
// 如果正在收起,直接更新状态
|
// Debug: 打印实际数据结构
|
||||||
if (newExpanded.has(eventId)) {
|
useEffect(() => {
|
||||||
newExpanded.delete(eventId);
|
if (events && events.length > 0) {
|
||||||
setExpandedStocks(newExpanded);
|
console.log('===== Historical Events Debug =====');
|
||||||
return;
|
console.log('First Event Data:', events[0]);
|
||||||
|
console.log('Available Fields:', Object.keys(events[0]));
|
||||||
|
console.log('Date Field:', getEventDate(events[0]));
|
||||||
|
console.log('Content Field:', getEventContent(events[0]));
|
||||||
|
console.log('==================================');
|
||||||
}
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
// 如果正在展开,先展开再加载数据
|
// 点击相关股票按钮
|
||||||
newExpanded.add(eventId);
|
const handleViewStocks = async (event) => {
|
||||||
setExpandedStocks(newExpanded);
|
setSelectedEventForStocks(event);
|
||||||
|
setStocksModalOpen(true);
|
||||||
|
|
||||||
// 如果已经加载过该事件的股票数据,不再重复加载
|
// 如果已经加载过该事件的股票数据,不再重复加载
|
||||||
if (eventStocks[eventId]) {
|
if (eventStocks[event.id]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记为加载中
|
// 标记为加载中
|
||||||
setLoadingStocks(prev => ({ ...prev, [eventId]: true }));
|
setLoadingStocks(prev => ({ ...prev, [event.id]: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调用API获取历史事件相关股票
|
// 调用API获取历史事件相关股票
|
||||||
const response = await stockService.getHistoricalEventStocks(eventId);
|
const response = await stockService.getHistoricalEventStocks(event.id);
|
||||||
setEventStocks(prev => ({
|
setEventStocks(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[eventId]: response.data || []
|
[event.id]: response.data || []
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('HistoricalEvents', 'toggleStocksExpansion', err, {
|
logger.error('HistoricalEvents', 'handleViewStocks', err, {
|
||||||
eventId: eventId,
|
eventId: event.id,
|
||||||
eventTitle: event.title
|
eventTitle: event.title
|
||||||
});
|
});
|
||||||
setEventStocks(prev => ({
|
setEventStocks(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[eventId]: []
|
[event.id]: []
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingStocks(prev => ({ ...prev, [eventId]: false }));
|
setLoadingStocks(prev => ({ ...prev, [event.id]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取重要性图标
|
// 关闭弹窗
|
||||||
const getImportanceIcon = (importance) => {
|
const handleCloseModal = () => {
|
||||||
if (importance >= 4) return FaExclamationTriangle;
|
setStocksModalOpen(false);
|
||||||
if (importance >= 2) return FaCalendarAlt;
|
setSelectedEventForStocks(null);
|
||||||
return FaClock;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取重要性颜色
|
// 获取重要性颜色
|
||||||
@@ -153,89 +139,28 @@ const HistoricalEvents = ({
|
|||||||
return `${Math.floor(diffDays / 365)}年前`;
|
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);
|
|
||||||
} else {
|
|
||||||
setShouldTruncate(false);
|
|
||||||
}
|
|
||||||
}, [text, maxLength]);
|
|
||||||
|
|
||||||
if (!text) return <Text fontSize="xs">--</Text>;
|
|
||||||
|
|
||||||
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"
|
|
||||||
color="blue.500"
|
|
||||||
onClick={onToggle}
|
|
||||||
height="auto"
|
|
||||||
py={0}
|
|
||||||
minH={0}
|
|
||||||
>
|
|
||||||
{isOpen ? '收起' : '展开'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<VStack spacing={4} align="stretch">
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Card key={i} borderLeft="4px solid" borderLeftColor="gray.200">
|
<Box
|
||||||
<CardBody>
|
key={i}
|
||||||
<HStack spacing={4} align="flex-start">
|
bg={cardBg}
|
||||||
<Skeleton boxSize="40px" borderRadius="full" />
|
borderWidth="1px"
|
||||||
<VStack align="flex-start" spacing={2} flex="1">
|
borderColor={borderColor}
|
||||||
<Skeleton height="20px" width="70%" />
|
borderRadius="md"
|
||||||
<Skeleton height="16px" width="40%" />
|
p={4}
|
||||||
<Skeleton height="14px" width="90%" />
|
>
|
||||||
</VStack>
|
<VStack align="flex-start" spacing={3}>
|
||||||
</HStack>
|
<Skeleton height="20px" width="70%" />
|
||||||
</CardBody>
|
<Skeleton height="16px" width="50%" />
|
||||||
</Card>
|
<Skeleton height="60px" width="100%" />
|
||||||
|
<Skeleton height="32px" width="100px" />
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,208 +192,151 @@ const HistoricalEvents = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VStack spacing={4} align="stretch">
|
{/* 超预期得分显示 */}
|
||||||
{/* 超预期得分显示 */}
|
{expectationScore && (
|
||||||
{expectationScore && (
|
<Box
|
||||||
<Card bg={useColorModeValue('yellow.50', 'yellow.900')} borderColor="yellow.200">
|
mb={4}
|
||||||
<CardBody>
|
p={3}
|
||||||
<HStack spacing={3}>
|
bg={useColorModeValue('yellow.50', 'yellow.900')}
|
||||||
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
|
borderColor="yellow.200"
|
||||||
<VStack align="flex-start" spacing={1}>
|
borderWidth="1px"
|
||||||
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
|
borderRadius="md"
|
||||||
超预期得分: {expectationScore}
|
>
|
||||||
</Text>
|
<HStack spacing={3}>
|
||||||
<Text fontSize="xs" color="yellow.700">
|
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
|
||||||
基于历史事件判断当前事件的超预期情况,满分100分(AI合成)
|
<VStack align="flex-start" spacing={1}>
|
||||||
</Text>
|
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
|
||||||
</VStack>
|
超预期得分: {expectationScore}
|
||||||
</HStack>
|
</Text>
|
||||||
</CardBody>
|
<Text fontSize="xs" color="yellow.700">
|
||||||
</Card>
|
基于历史事件判断当前事件的超预期情况,满分100分(AI合成)
|
||||||
)}
|
</Text>
|
||||||
|
</VStack>
|
||||||
{/* 历史事件时间轴 */}
|
</HStack>
|
||||||
<Box position="relative">
|
|
||||||
{/* 时间轴线 */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
left="20px"
|
|
||||||
top="20px"
|
|
||||||
bottom="20px"
|
|
||||||
width="2px"
|
|
||||||
background={`linear-gradient(to bottom, ${timelineBg}, #996515)`}
|
|
||||||
zIndex={0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 事件列表 */}
|
|
||||||
<VStack spacing={6} align="stretch">
|
|
||||||
{events.map((event, index) => {
|
|
||||||
const ImportanceIcon = getImportanceIcon(event.importance);
|
|
||||||
const importanceColor = getImportanceColor(event.importance);
|
|
||||||
const isExpanded = expandedEvents.has(event.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={event.id} position="relative">
|
|
||||||
{/* 时间轴节点 */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
left="0"
|
|
||||||
top="20px"
|
|
||||||
width="40px"
|
|
||||||
height="40px"
|
|
||||||
borderRadius="full"
|
|
||||||
bg={cardBg}
|
|
||||||
border="2px solid"
|
|
||||||
borderColor={timelineBg}
|
|
||||||
display="flex"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
zIndex={1}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
as={ImportanceIcon}
|
|
||||||
color={`${importanceColor}.500`}
|
|
||||||
boxSize="16px"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 事件内容卡片 */}
|
|
||||||
<Box ml="60px">
|
|
||||||
<Card
|
|
||||||
borderLeft="3px solid"
|
|
||||||
borderLeftColor={timelineBg}
|
|
||||||
bg={cardBg}
|
|
||||||
_hover={{ shadow: 'md' }}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
|
||||||
<CardBody>
|
|
||||||
<VStack align="flex-start" spacing={3}>
|
|
||||||
{/* 事件标题和操作 */}
|
|
||||||
<HStack justify="space-between" align="flex-start" w="100%">
|
|
||||||
<VStack align="flex-start" spacing={1} flex="1">
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
color={useColorModeValue('blue.600', 'blue.400')}
|
|
||||||
fontWeight="bold"
|
|
||||||
fontSize="md"
|
|
||||||
p={0}
|
|
||||||
h="auto"
|
|
||||||
onClick={() => toggleEventExpansion(event.id)}
|
|
||||||
_hover={{ textDecoration: 'underline' }}
|
|
||||||
>
|
|
||||||
{event.title || '未命名事件'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<HStack spacing={3} fontSize="sm" color={textSecondary}>
|
|
||||||
<Text>{formatDate(event.event_date)}</Text>
|
|
||||||
<Text>({getRelativeTime(event.event_date)})</Text>
|
|
||||||
{event.relevance && (
|
|
||||||
<Badge colorScheme="blue" size="sm">
|
|
||||||
相关度: {event.relevance}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<HStack spacing={2}>
|
|
||||||
{event.importance && (
|
|
||||||
<Tooltip label={`重要性等级: ${event.importance}/5`}>
|
|
||||||
<Badge colorScheme={importanceColor} size="sm">
|
|
||||||
重要性: {event.importance}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<Icon as={FaChartLine} />}
|
|
||||||
rightIcon={<Icon as={expandedStocks.has(event.id) ? FaChevronUp : FaChevronDown} />}
|
|
||||||
onClick={() => toggleStocksExpansion(event)}
|
|
||||||
colorScheme="blue"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
相关股票
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 事件简介 */}
|
|
||||||
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
|
|
||||||
{event.content ? `${event.content}(AI合成)` : '暂无内容'}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* 展开的详细信息 */}
|
|
||||||
<Collapse in={isExpanded} animateOpacity>
|
|
||||||
<Box pt={3} borderTop="1px solid" borderTopColor={borderColor}>
|
|
||||||
<VStack align="flex-start" spacing={2}>
|
|
||||||
<Text fontSize="xs" color={textSecondary}>
|
|
||||||
事件ID: {event.id}
|
|
||||||
</Text>
|
|
||||||
{event.source && (
|
|
||||||
<Text fontSize="xs" color={textSecondary}>
|
|
||||||
来源: {event.source}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{event.tags && event.tags.length > 0 && (
|
|
||||||
<HStack spacing={1} flexWrap="wrap">
|
|
||||||
<Text fontSize="xs" color={textSecondary}>标签:</Text>
|
|
||||||
{event.tags.map((tag, idx) => (
|
|
||||||
<Badge key={idx} size="sm" variant="outline">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
{/* 相关股票列表 Collapse */}
|
|
||||||
<Collapse in={expandedStocks.has(event.id)} animateOpacity>
|
|
||||||
<Box
|
|
||||||
mt={3}
|
|
||||||
pt={3}
|
|
||||||
borderTop="1px solid"
|
|
||||||
borderTopColor={borderColor}
|
|
||||||
bg={useColorModeValue('gray.50', 'gray.750')}
|
|
||||||
p={3}
|
|
||||||
borderRadius="md"
|
|
||||||
>
|
|
||||||
{loadingStocks[event.id] ? (
|
|
||||||
<VStack spacing={4} py={8}>
|
|
||||||
<Spinner size="lg" color="blue.500" />
|
|
||||||
<Text color={textSecondary}>加载相关股票数据...</Text>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<StocksList
|
|
||||||
stocks={eventStocks[event.id] || []}
|
|
||||||
eventTradingDate={event.event_date}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
)}
|
||||||
|
|
||||||
|
{/* 历史事件卡片网格 */}
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||||
|
{events.map((event) => {
|
||||||
|
const importanceColor = getImportanceColor(event.importance);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={event.id}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
p={4}
|
||||||
|
_hover={{
|
||||||
|
boxShadow: 'md',
|
||||||
|
borderColor: 'blue.300',
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{/* 事件名称 */}
|
||||||
|
<Text
|
||||||
|
fontSize="md"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={useColorModeValue('blue.600', 'blue.400')}
|
||||||
|
noOfLines={2}
|
||||||
|
lineHeight="1.4"
|
||||||
|
>
|
||||||
|
{event.title || '未命名事件'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 日期 + Badges */}
|
||||||
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
|
<Text fontSize="sm" color={textSecondary}>
|
||||||
|
{formatDate(getEventDate(event))}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={textSecondary}>
|
||||||
|
({getRelativeTime(getEventDate(event))})
|
||||||
|
</Text>
|
||||||
|
{event.relevance && (
|
||||||
|
<Badge colorScheme="blue" size="sm">
|
||||||
|
相关度: {event.relevance}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{event.importance && (
|
||||||
|
<Badge colorScheme={importanceColor} size="sm">
|
||||||
|
重要性: {event.importance}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 事件描述 */}
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color={nameColor}
|
||||||
|
lineHeight="1.6"
|
||||||
|
noOfLines={4}
|
||||||
|
>
|
||||||
|
{getEventContent(event) ? `${getEventContent(event)}(AI合成)` : '暂无内容'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 相关股票按钮 */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Icon as={FaChartLine} />}
|
||||||
|
onClick={() => handleViewStocks(event)}
|
||||||
|
colorScheme="blue"
|
||||||
|
variant="outline"
|
||||||
|
width="full"
|
||||||
|
>
|
||||||
|
相关股票
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 相关股票 Modal - 条件渲染 */}
|
||||||
|
{stocksModalOpen && (
|
||||||
|
<Modal
|
||||||
|
isOpen={stocksModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
size="6xl"
|
||||||
|
scrollBehavior="inside"
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>
|
||||||
|
{selectedEventForStocks?.title || '历史事件相关股票'}
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody pb={6}>
|
||||||
|
{loadingStocks[selectedEventForStocks?.id] ? (
|
||||||
|
<VStack spacing={4} py={12}>
|
||||||
|
<Spinner size="xl" color="blue.500" />
|
||||||
|
<Text color={textSecondary}>加载相关股票数据...</Text>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<StocksList
|
||||||
|
stocks={eventStocks[selectedEventForStocks?.id] || []}
|
||||||
|
eventTradingDate={getEventDate(selectedEventForStocks)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 股票列表子组件
|
// 股票列表子组件(卡片式布局)
|
||||||
const StocksList = ({ stocks, eventTradingDate }) => {
|
const StocksList = ({ stocks, eventTradingDate }) => {
|
||||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
const [expandedStocks, setExpandedStocks] = useState(new Set());
|
||||||
|
|
||||||
// 处理股票代码,移除.SZ/.SH后缀
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const formatStockCode = (stockCode) => {
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
if (!stockCode) return '';
|
const dividerColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
return stockCode.replace(/\.(SZ|SH)$/i, '');
|
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||||
};
|
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||||
|
|
||||||
// 处理关联描述字段的辅助函数
|
// 处理关联描述字段的辅助函数
|
||||||
const getRelationDesc = (relationDesc) => {
|
const getRelationDesc = (relationDesc) => {
|
||||||
@@ -493,9 +361,41 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 切换展开状态
|
||||||
|
const toggleExpand = (stockId) => {
|
||||||
|
const newExpanded = new Set(expandedStocks);
|
||||||
|
if (newExpanded.has(stockId)) {
|
||||||
|
newExpanded.delete(stockId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(stockId);
|
||||||
|
}
|
||||||
|
setExpandedStocks(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化涨跌幅
|
||||||
|
const formatChange = (value) => {
|
||||||
|
if (value === null || value === undefined || isNaN(value)) return '--';
|
||||||
|
const prefix = value > 0 ? '+' : '';
|
||||||
|
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取涨跌幅颜色
|
||||||
|
const getChangeColor = (value) => {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (isNaN(num) || num === 0) return 'gray.500';
|
||||||
|
return num > 0 ? 'red.500' : 'green.500';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取相关度颜色
|
||||||
|
const getCorrelationColor = (correlation) => {
|
||||||
|
if (correlation >= 0.8) return 'red';
|
||||||
|
if (correlation >= 0.6) return 'orange';
|
||||||
|
return 'green';
|
||||||
|
};
|
||||||
|
|
||||||
if (!stocks || stocks.length === 0) {
|
if (!stocks || stocks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box textAlign="center" py={8} color={textSecondary}>
|
<Box textAlign="center" py={12} color={textSecondary}>
|
||||||
<Icon as={FaInfoCircle} boxSize="48px" mb={4} />
|
<Icon as={FaInfoCircle} boxSize="48px" mb={4} />
|
||||||
<Text fontSize="lg" mb={2}>暂无相关股票数据</Text>
|
<Text fontSize="lg" mb={2}>暂无相关股票数据</Text>
|
||||||
<Text fontSize="sm">该历史事件暂未关联股票信息</Text>
|
<Text fontSize="sm">该历史事件暂未关联股票信息</Text>
|
||||||
@@ -505,6 +405,7 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* 事件交易日提示 */}
|
||||||
{eventTradingDate && (
|
{eventTradingDate && (
|
||||||
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
|
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
|
||||||
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
|
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
|
||||||
@@ -512,74 +413,115 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<TableContainer>
|
|
||||||
<Table size="md">
|
{/* 股票卡片网格 */}
|
||||||
<Thead>
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||||
<Tr>
|
{stocks.map((stock, index) => {
|
||||||
<Th>股票代码</Th>
|
const stockId = stock.id || index;
|
||||||
<Th>股票名称</Th>
|
const isExpanded = expandedStocks.has(stockId);
|
||||||
<Th>板块</Th>
|
const cleanCode = stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : '';
|
||||||
<Th isNumeric>相关度</Th>
|
const relationDesc = getRelationDesc(stock.relation_desc);
|
||||||
<Th isNumeric>事件日涨幅</Th>
|
const needTruncate = relationDesc && relationDesc.length > 50;
|
||||||
<Th>关联原因</Th>
|
|
||||||
</Tr>
|
return (
|
||||||
</Thead>
|
<Box
|
||||||
<Tbody>
|
key={stockId}
|
||||||
{stocks.map((stock, index) => (
|
bg={cardBg}
|
||||||
<Tr key={stock.id || index}>
|
borderWidth="1px"
|
||||||
<Td fontFamily="mono" fontWeight="medium">
|
borderColor={borderColor}
|
||||||
<Link
|
borderRadius="md"
|
||||||
href={`https://valuefrontier.cn/company?scode=${stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}`}
|
p={4}
|
||||||
isExternal
|
_hover={{
|
||||||
color="blue.500"
|
boxShadow: 'md',
|
||||||
_hover={{ textDecoration: 'underline' }}
|
borderColor: 'blue.300',
|
||||||
>
|
}}
|
||||||
{stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}
|
transition="all 0.2s"
|
||||||
</Link>
|
>
|
||||||
</Td>
|
<VStack align="stretch" spacing={3}>
|
||||||
<Td>{stock.stock_name || '--'}</Td>
|
{/* 顶部:股票代码 + 名称 + 涨跌幅 */}
|
||||||
<Td>
|
<Flex justify="space-between" align="center">
|
||||||
<Badge size="sm" variant="outline">
|
|
||||||
{stock.sector || '未知'}
|
|
||||||
</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td isNumeric>
|
|
||||||
<Badge
|
|
||||||
colorScheme={
|
|
||||||
stock.correlation >= 0.8 ? 'red' :
|
|
||||||
stock.correlation >= 0.6 ? 'orange' : 'green'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{Math.round((stock.correlation || 0) * 100)}%
|
|
||||||
</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td isNumeric>
|
|
||||||
{stock.event_day_change_pct !== null && stock.event_day_change_pct !== undefined ? (
|
|
||||||
<Text
|
|
||||||
fontWeight="medium"
|
|
||||||
color={stock.event_day_change_pct >= 0 ? 'red.500' : 'green.500'}
|
|
||||||
>
|
|
||||||
{stock.event_day_change_pct >= 0 ? '+' : ''}{stock.event_day_change_pct.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text color={textSecondary} fontSize="sm">--</Text>
|
|
||||||
)}
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<VStack align="flex-start" spacing={1}>
|
<VStack align="flex-start" spacing={1}>
|
||||||
<Text fontSize="xs" noOfLines={2} maxW="300px">
|
<Link
|
||||||
{getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}(AI合成)` : '--'}
|
href={`https://valuefrontier.cn/company?scode=${cleanCode}`}
|
||||||
|
isExternal
|
||||||
|
fontSize="md"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="blue.500"
|
||||||
|
_hover={{ textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{cleanCode}
|
||||||
|
</Link>
|
||||||
|
<Text fontSize="sm" color={nameColor}>
|
||||||
|
{stock.stock_name || '--'}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Td>
|
|
||||||
</Tr>
|
<Text
|
||||||
))}
|
fontSize="lg"
|
||||||
</Tbody>
|
fontWeight="bold"
|
||||||
</Table>
|
color={getChangeColor(stock.event_day_change_pct)}
|
||||||
</TableContainer>
|
>
|
||||||
|
{formatChange(stock.event_day_change_pct)}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<Box borderTop="1px solid" borderColor={dividerColor} />
|
||||||
|
|
||||||
|
{/* 板块和相关度 */}
|
||||||
|
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text fontSize="xs" color={textSecondary}>板块:</Text>
|
||||||
|
<Badge size="sm" variant="outline">
|
||||||
|
{stock.sector || '未知'}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text fontSize="xs" color={textSecondary}>相关度:</Text>
|
||||||
|
<Badge
|
||||||
|
colorScheme={getCorrelationColor(stock.correlation || 0)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{Math.round((stock.correlation || 0) * 100)}%
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<Box borderTop="1px solid" borderColor={dividerColor} />
|
||||||
|
|
||||||
|
{/* 关联原因 */}
|
||||||
|
{relationDesc && (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="xs" color={textSecondary} mb={1}>
|
||||||
|
关联原因:
|
||||||
|
</Text>
|
||||||
|
<Collapse in={isExpanded} startingHeight={40}>
|
||||||
|
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
|
||||||
|
{relationDesc}(AI合成)
|
||||||
|
</Text>
|
||||||
|
</Collapse>
|
||||||
|
{needTruncate && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="link"
|
||||||
|
colorScheme="blue"
|
||||||
|
onClick={() => toggleExpand(stockId)}
|
||||||
|
mt={1}
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '展开'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HistoricalEvents;
|
export default HistoricalEvents;
|
||||||
|
|||||||
Reference in New Issue
Block a user