Files
vf_react/src/views/EventDetail/components/HistoricalEvents.js
zdl 78fac38f3b 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>
2025-11-03 12:41:02 +08:00

528 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// src/views/EventDetail/components/HistoricalEvents.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Button,
Skeleton,
Alert,
AlertIcon,
SimpleGrid,
Icon,
useColorModeValue,
Spinner,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Link,
Flex,
Collapse
} from '@chakra-ui/react';
import {
FaChartLine,
FaInfoCircle
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
const HistoricalEvents = ({
events = [],
expectationScore = null,
loading = false,
error = null
}) => {
// 状态管理
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
const [stocksModalOpen, setStocksModalOpen] = useState(false);
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState({});
// 颜色主题
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const nameColor = useColorModeValue('gray.700', 'gray.300');
// 字段兼容函数
const getEventDate = (event) => {
return event?.event_date || event?.created_at || event?.date || event?.publish_time;
};
const getEventContent = (event) => {
return event?.content || event?.description || event?.summary;
};
// Debug: 打印实际数据结构
useEffect(() => {
if (events && events.length > 0) {
console.log('===== Historical Events Debug =====');
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]);
// 点击相关股票按钮
const handleViewStocks = async (event) => {
setSelectedEventForStocks(event);
setStocksModalOpen(true);
// 如果已经加载过该事件的股票数据,不再重复加载
if (eventStocks[event.id]) {
return;
}
// 标记为加载中
setLoadingStocks(prev => ({ ...prev, [event.id]: true }));
try {
// 调用API获取历史事件相关股票
const response = await stockService.getHistoricalEventStocks(event.id);
setEventStocks(prev => ({
...prev,
[event.id]: response.data || []
}));
} catch (err) {
logger.error('HistoricalEvents', 'handleViewStocks', err, {
eventId: event.id,
eventTitle: event.title
});
setEventStocks(prev => ({
...prev,
[event.id]: []
}));
} finally {
setLoadingStocks(prev => ({ ...prev, [event.id]: false }));
}
};
// 关闭弹窗
const handleCloseModal = () => {
setStocksModalOpen(false);
setSelectedEventForStocks(null);
};
// 获取重要性颜色
const getImportanceColor = (importance) => {
if (importance >= 4) return 'red';
if (importance >= 2) return 'orange';
return 'green';
};
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '日期未知';
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
// 计算相对时间
const getRelativeTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays < 30) return `${diffDays}天前`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月前`;
return `${Math.floor(diffDays / 365)}年前`;
};
// 加载状态
if (loading) {
return (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{[1, 2, 3].map((i) => (
<Box
key={i}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
>
<VStack align="flex-start" spacing={3}>
<Skeleton height="20px" width="70%" />
<Skeleton height="16px" width="50%" />
<Skeleton height="60px" width="100%" />
<Skeleton height="32px" width="100px" />
</VStack>
</Box>
))}
</SimpleGrid>
);
}
// 错误状态
if (error) {
return (
<Alert status="error" borderRadius="lg">
<AlertIcon />
加载历史事件失败: {error}
</Alert>
);
}
// 无数据状态
if (!events || events.length === 0) {
return (
<Box
textAlign="center"
py={12}
color="gray.500"
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="lg"
>
<Text fontSize="lg" mb={2}>暂无历史事件</Text>
<Text fontSize="sm">历史事件数据将在这里显示</Text>
</Box>
);
}
return (
<>
{/* 超预期得分显示 */}
{expectationScore && (
<Box
mb={4}
p={3}
bg={useColorModeValue('yellow.50', 'yellow.900')}
borderColor="yellow.200"
borderWidth="1px"
borderRadius="md"
>
<HStack spacing={3}>
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
<VStack align="flex-start" spacing={1}>
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
超预期得分: {expectationScore}
</Text>
<Text fontSize="xs" color="yellow.700">
基于历史事件判断当前事件的超预期情况满分100分AI合成
</Text>
</VStack>
</HStack>
</Box>
)}
{/* 历史事件卡片网格 */}
<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 [expandedStocks, setExpandedStocks] = useState(new Set());
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const dividerColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const nameColor = useColorModeValue('gray.700', 'gray.300');
// 处理关联描述字段的辅助函数
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 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) {
return (
<Box textAlign="center" py={12} color={textSecondary}>
<Icon as={FaInfoCircle} boxSize="48px" mb={4} />
<Text fontSize="lg" mb={2}>暂无相关股票数据</Text>
<Text fontSize="sm">该历史事件暂未关联股票信息</Text>
</Box>
);
}
return (
<>
{/* 事件交易日提示 */}
{eventTradingDate && (
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
📅 事件对应交易日{new Date(eventTradingDate).toLocaleDateString('zh-CN')}
</Text>
</Box>
)}
{/* 股票卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{stocks.map((stock, index) => {
const stockId = stock.id || index;
const isExpanded = expandedStocks.has(stockId);
const cleanCode = stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : '';
const relationDesc = getRelationDesc(stock.relation_desc);
const needTruncate = relationDesc && relationDesc.length > 50;
return (
<Box
key={stockId}
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}>
{/* 顶部:股票代码 + 名称 + 涨跌幅 */}
<Flex justify="space-between" align="center">
<VStack align="flex-start" spacing={1}>
<Link
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>
</VStack>
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(stock.event_day_change_pct)}
>
{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;