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:
zdl
2025-11-03 12:41:02 +08:00
parent cc2777ae20
commit 95134d526d
2 changed files with 347 additions and 405 deletions

View File

@@ -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);
// 关注状态管理 // 关注状态管理

View File

@@ -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,72 +413,113 @@ 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>
</> </>
); );
}; };