This commit is contained in:
2025-10-11 12:10:00 +08:00
parent 8107dee8d3
commit 4d0dc109bc
109 changed files with 152150 additions and 8037 deletions

View File

@@ -33,8 +33,7 @@ import {
Tr,
Th,
Td,
TableContainer,
Link
TableContainer
} from '@chakra-ui/react';
import {
FaExclamationTriangle,
@@ -145,47 +144,6 @@ const HistoricalEvents = ({
return `${Math.floor(diffDays / 365)}年前`;
};
// 可展开的文本组件
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) {
return (
@@ -248,7 +206,7 @@ const HistoricalEvents = ({
超预期得分: {expectationScore}
</Text>
<Text fontSize="xs" color="yellow.700">
基于历史事件判断当前事件的超预期情况满分100分AI合成
基于历史事件判断当前事件的超预期情况满分100分
</Text>
</VStack>
</HStack>
@@ -359,10 +317,10 @@ const HistoricalEvents = ({
</HStack>
</HStack>
{/* 事件简介 */}
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
{event.content ? `${event.content}AI合成` : '暂无内容'}
</Text>
{/* 事件简介 */}
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
{event.content || '暂无内容'}
</Text>
{/* 展开的详细信息 */}
<Collapse in={isExpanded} animateOpacity>
@@ -401,9 +359,9 @@ const HistoricalEvents = ({
</VStack>
{/* 事件相关股票模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent maxW="80vw" maxH="85vh">
<ModalContent>
<ModalHeader>
<VStack align="flex-start" spacing={1}>
<Text>{selectedEvent?.title || '历史事件'}</Text>
@@ -414,7 +372,7 @@ const HistoricalEvents = ({
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto" maxH="calc(85vh - 180px)">
<ModalBody>
{loadingStocks ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
@@ -423,7 +381,6 @@ const HistoricalEvents = ({
) : (
<StocksList
stocks={selectedEvent ? eventStocks[selectedEvent.id] || [] : []}
eventTradingDate={selectedEvent ? selectedEvent.event_date : null}
/>
)}
</ModalBody>
@@ -438,14 +395,8 @@ const HistoricalEvents = ({
};
// 股票列表子组件
const StocksList = ({ stocks, eventTradingDate }) => {
const StocksList = ({ stocks }) => {
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 处理股票代码,移除.SZ/.SH后缀
const formatStockCode = (stockCode) => {
if (!stockCode) return '';
return stockCode.replace(/\.(SZ|SH)$/i, '');
};
if (!stocks || stocks.length === 0) {
return (
@@ -458,81 +409,50 @@ const StocksList = ({ stocks, eventTradingDate }) => {
}
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>
)}
<TableContainer>
<Table size="md">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th>板块</Th>
<Th isNumeric>相关度</Th>
<Th isNumeric>事件日涨幅</Th>
<Th>关联原因</Th>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th>板块</Th>
<Th isNumeric>相关度</Th>
<Th>关联原因</Th>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, index) => (
<Tr key={stock.id || index}>
<Td fontFamily="mono" fontWeight="medium">
{stock.stock_code}
</Td>
<Td>{stock.stock_name || '--'}</Td>
<Td>
<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>
<Text fontSize="xs" noOfLines={2} maxW="200px">
{stock.relation_desc || '--'}
</Text>
</Td>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, index) => (
<Tr key={stock.id || index}>
<Td fontFamily="mono" fontWeight="medium">
<Link
href={`https://valuefrontier.cn/company?scode=${stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}`}
isExternal
color="blue.500"
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}
</Link>
</Td>
<Td>{stock.stock_name || '--'}</Td>
<Td>
<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}>
<Text fontSize="xs" noOfLines={2} maxW="300px">
{stock.relation_desc ? `${stock.relation_desc}AI合成` : '--'}
</Text>
</VStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</>
))}
</Tbody>
</Table>
</TableContainer>
);
};

View File

@@ -1,8 +1,7 @@
// src/views/EventDetail/components/RelatedConcepts.js - 支持概念API调用
// src/views/EventDetail/components/RelatedConcepts.js - 支持交易日计算
import React, { useState, useEffect } from 'react';
import {
Icon, // 明确导入 Icon 组件
Box,
VStack,
HStack,
@@ -26,40 +25,37 @@ import {
IconButton,
Tooltip,
Button,
Center,
Divider
Center
} from '@chakra-ui/react';
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment';
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
// API配置
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'https://valuefrontier.cn/concept-api';
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'http://111.198.58.126:16801';
// 增强版 ConceptCard 组件 - 展示更多数据细节
// ConceptCard 组件 - 修改为使用新的数据结构
const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const [imageError, setImageError] = useState(false);
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textColor = useColorModeValue('gray.600', 'gray.400');
const highlightBg = useColorModeValue('yellow.50', 'yellow.900');
// 计算涨跌幅颜色和符号
const changeColor = concept.price_info?.avg_change_pct > 0 ? 'red' : 'green';
// 计算涨跌幅颜色
const changeColor = concept.price_info?.avg_change_pct > 0 ? 'red.500' : 'green.500';
const changeSymbol = concept.price_info?.avg_change_pct > 0 ? '+' : '';
const hasValidPriceInfo = concept.price_info && concept.price_info.avg_change_pct !== null;
// 获取匹配类型的中文名称
const getMatchTypeName = (type) => {
const typeMap = {
'hybrid_knn': '混合匹配',
'keyword': '关键词匹配',
'semantic': '语义匹配'
};
return typeMap[type] || type;
const handleImageLoad = () => {
setImageLoading(false);
};
// 处理概念点击
const handleImageError = () => {
setImageLoading(false);
setImageError(true);
};
// 处理概念点击 - 跳转到概念详情页
const handleConceptClick = () => {
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(concept.concept)}.html`, '_blank');
};
@@ -68,196 +64,100 @@ const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
<Card
bg={cardBg}
borderColor={borderColor}
borderWidth={2}
cursor="pointer"
_hover={{
transform: 'translateY(-2px)',
shadow: 'xl',
borderColor: 'blue.400'
shadow: 'lg',
borderColor: 'blue.300'
}}
transition="all 0.3s"
transition="all 0.2s"
onClick={handleConceptClick}
>
<CardBody p={5}>
<VStack spacing={4} align="stretch">
{/* 头部信息 */}
<Box>
<HStack justify="space-between" align="flex-start" mb={2}>
<VStack align="start" spacing={1} flex={1}>
<Text fontSize="lg" fontWeight="bold" color="blue.600">
{concept.concept}
</Text>
<HStack spacing={2} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="xs">
相关度: {concept.score.toFixed(2)}
</Badge>
<Badge colorScheme="teal" fontSize="xs">
{getMatchTypeName(concept.match_type)}
</Badge>
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
</HStack>
</VStack>
{hasValidPriceInfo && (
<Box textAlign="right">
<Text fontSize="xs" color={textColor} mb={1}>
{tradingDate || concept.price_info.trade_date}
</Text>
<Badge
size="lg"
colorScheme={changeColor}
fontSize="md"
px={3}
py={1}
>
<CardBody p={4}>
<VStack spacing={3} align="stretch">
{/* 概念信息 */}
<VStack spacing={2} align="stretch">
<HStack justify="space-between" align="flex-start">
<Text fontSize="md" fontWeight="bold" noOfLines={2}>
{concept.concept}
</Text>
{hasValidPriceInfo ? (
<Tooltip label={`${tradingDate} 平均涨跌幅`}>
<Badge size="sm" colorScheme={concept.price_info.avg_change_pct > 0 ? 'red' : 'green'}>
{changeSymbol}{concept.price_info.avg_change_pct?.toFixed(2)}%
</Badge>
</Box>
</Tooltip>
) : (
<Tooltip label={tradingDate ? `${tradingDate} 暂无数据` : '暂无涨跌数据'}>
<Badge size="sm" variant="outline" colorScheme="gray">
--%
</Badge>
</Tooltip>
)}
</HStack>
</Box>
<Divider />
{/* 股票数量和相关度 */}
<HStack spacing={2}>
<Badge variant="subtle" colorScheme="blue">
{concept.stock_count} 只股票
</Badge>
<Badge variant="subtle" colorScheme="purple">
相关度: {(concept.score * 10).toFixed(1)}
</Badge>
</HStack>
{/* 概念描述 */}
<Box>
<Text
fontSize="sm"
color={textColor}
noOfLines={isExpanded ? undefined : 4}
lineHeight="1.6"
>
{concept.description}
</Text>
{concept.description && concept.description.length > 200 && (
<Button
size="xs"
variant="link"
colorScheme="blue"
mt={1}
{/* 概念描述 */}
{concept.description && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{concept.description}
</Text>
)}
{/* 部分股票展示 */}
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<Text fontSize="xs" color={textColor} mb={1}>
相关股票
</Text>
<HStack spacing={1} flexWrap="wrap">
{concept.stocks.slice(0, 3).map((stock, idx) => (
<Badge key={idx} size="sm" variant="outline">
{stock.stock_name}
</Badge>
))}
{concept.stocks.length > 3 && (
<Text fontSize="xs" color={textColor}>
{concept.stock_count}
</Text>
)}
</HStack>
</Box>
)}
<HStack justify="space-between" align="center" pt={2}>
<Text fontSize="xs" color={textColor}>
点击查看概念详情
</Text>
<IconButton
icon={<FaExternalLinkAlt />}
size="sm"
variant="ghost"
aria-label="查看详情"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
handleConceptClick();
}}
>
{isExpanded ? '收起' : '展开更多'}
</Button>
)}
</Box>
{/* 历史发生时间 */}
{concept.happened_times && concept.happened_times.length > 0 && (
<Box>
<Text fontSize="xs" fontWeight="semibold" mb={2} color={textColor}>
历史触发时间
</Text>
<HStack spacing={2} flexWrap="wrap">
{concept.happened_times.map((time, idx) => (
<Badge key={idx} variant="subtle" colorScheme="gray" fontSize="xs">
{time}
</Badge>
))}
</HStack>
</Box>
)}
{/* 相关股票展示 - 增强版 */}
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<HStack justify="space-between" mb={2}>
<Text fontSize="sm" fontWeight="semibold" color={textColor}>
核心相关股票
</Text>
<Text fontSize="xs" color="gray.500">
{concept.stock_count}
</Text>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
{concept.stocks.slice(0, isExpanded ? 8 : 4).map((stock, idx) => (
<Box
key={idx}
p={2}
borderRadius="md"
bg={useColorModeValue('gray.50', 'gray.700')}
fontSize="xs"
>
<HStack justify="space-between">
<Text fontWeight="semibold">
{stock.stock_name}
</Text>
<Badge size="sm" variant="outline">
{stock.stock_code}
</Badge>
</HStack>
{stock.reason && (
<Text fontSize="xs" color={textColor} mt={1} noOfLines={2}>
{stock.reason}
</Text>
)}
</Box>
))}
</SimpleGrid>
{concept.stocks.length > 4 && !isExpanded && (
<Button
size="xs"
variant="ghost"
colorScheme="blue"
mt={2}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
>
查看更多股票
</Button>
)}
</Box>
)}
{/* 操作按钮 */}
<HStack spacing={2} pt={2}>
<Button
size="sm"
colorScheme="blue"
leftIcon={<FaChartLine />}
flex={1}
onClick={(e) => {
e.stopPropagation();
handleConceptClick();
}}
>
查看概念详情
</Button>
<Button
size="sm"
variant="outline"
colorScheme="blue"
leftIcon={<FaEye />}
flex={1}
onClick={(e) => {
e.stopPropagation();
onViewDetails(concept);
}}
>
快速预览
</Button>
</HStack>
/>
</HStack>
</VStack>
</VStack>
</CardBody>
</Card>
);
};
// 主组件 - 修改为接收事件信息并调用API
// 主组件 - 修改为从父组件接收事件标题和时间并搜索概念
const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoading, error: externalError }) => {
// 调试:检查 Icon 组件是否可用
if (typeof Icon === 'undefined') {
console.error('Icon component is not defined! Make sure @chakra-ui/react is properly imported.');
return <div>组件加载错误Icon 组件未定义</div>;
}
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedConcept, setSelectedConcept] = useState(null);
const [concepts, setConcepts] = useState([]);
@@ -267,63 +167,37 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
const bgColor = useColorModeValue('blue.50', 'blue.900');
const textColor = useColorModeValue('gray.600', 'gray.400');
// 数据验证函数
const validateConceptData = (data) => {
if (!data || typeof data !== 'object') {
throw new Error('Invalid response data format');
// 计算有效交易日
useEffect(() => {
if (eventTime) {
const tradingDate = tradingDayUtils.getEffectiveTradingDay(eventTime);
setEffectiveTradingDate(tradingDate);
console.log('事件时间:', eventTime, '-> 有效交易日:', tradingDate);
}
// 验证新的API格式
if (data.results && Array.isArray(data.results)) {
return data.results.every(item =>
item &&
typeof item === 'object' &&
(item.concept || item.concept_id) &&
typeof item.score === 'number'
);
}
// 验证旧的API格式
if (data.data && data.data.concepts && Array.isArray(data.data.concepts)) {
return data.data.concepts.every(item =>
item &&
typeof item === 'object' &&
(item.concept || item.concept_id) &&
typeof item.score === 'number'
);
}
return false;
};
}, [eventTime]);
// 搜索概念函数
const searchConcepts = async (title, tradingDate) => {
if (!title) {
setConcepts([]);
return;
}
setLoading(true);
setError(null);
// 搜索相关概念
const searchConcepts = async (title, tradeDate) => {
try {
setLoading(true);
setError(null);
// 确保tradeDate是字符串格式
let formattedTradeDate;
if (typeof tradeDate === 'string') {
formattedTradeDate = tradeDate;
} else if (tradeDate instanceof Date) {
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD');
} else if (moment.isMoment(tradeDate)) {
formattedTradeDate = tradeDate.format('YYYY-MM-DD');
} else {
console.warn('Invalid tradeDate format:', tradeDate, typeof tradeDate);
formattedTradeDate = moment().format('YYYY-MM-DD');
}
const requestBody = {
query: title,
size: 4,
size: 4, // 返回前4个最相关的概念
page: 1,
sort_by: "_score",
trade_date: formattedTradeDate
sort_by: "_score" // 按相关度排序
};
console.log('Searching concepts with:', requestBody);
// 如果有交易日期,添加到请求中
if (tradingDate) {
requestBody.trade_date = tradingDate;
}
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
@@ -334,35 +208,18 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
throw new Error(`搜索失败: ${response.status}`);
}
const data = await response.json();
console.log('Concept search response:', data);
setConcepts(data.results || []);
// 数据验证
if (!validateConceptData(data)) {
console.warn('Invalid concept data format:', data);
setConcepts([]);
setError('返回的数据格式无效');
return;
}
// 修复适配实际的API响应格式
if (data.results && Array.isArray(data.results)) {
setConcepts(data.results);
// 使用传入的交易日期作为生效日期
setEffectiveTradingDate(formattedTradeDate);
} else if (data.data && data.data.concepts) {
// 保持向后兼容
setConcepts(data.data.concepts);
setEffectiveTradingDate(data.data.trade_date || formattedTradeDate);
} else {
setConcepts([]);
console.warn('No concepts found in response');
// 如果返回了价格日期,更新显示
if (data.price_date && data.price_date !== tradingDate) {
console.log('API返回的实际价格日期:', data.price_date);
}
} catch (err) {
console.error('Failed to search concepts:', err);
console.error('概念搜索错误:', err);
setError(err.message);
setConcepts([]);
} finally {
@@ -370,92 +227,23 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
}
};
// 当事件信息变化时调用API搜索概念
// 当事件标题或交易日变化时搜索概念
useEffect(() => {
if (eventTitle && eventTime) {
// 格式化日期为 YYYY-MM-DD
let formattedDate;
try {
// eventTime 可能是Date对象或字符串使用 moment 处理
let eventMoment;
// 检查是否是Date对象
if (eventTime instanceof Date) {
eventMoment = moment(eventTime);
} else if (typeof eventTime === 'string') {
eventMoment = moment(eventTime);
} else if (typeof eventTime === 'number') {
eventMoment = moment(eventTime);
} else {
console.warn('Unknown eventTime format:', eventTime, typeof eventTime);
eventMoment = moment();
}
// 确保moment对象有效
if (!eventMoment.isValid()) {
console.warn('Invalid eventTime:', eventTime);
eventMoment = moment();
}
formattedDate = eventMoment.format('YYYY-MM-DD');
// 如果时间是15:00之后获取下一个交易日
if (eventMoment.hour() >= 15) {
// 使用 tradingDayUtils 获取下一个交易日
if (tradingDayUtils && tradingDayUtils.getNextTradingDay) {
const nextTradingDay = tradingDayUtils.getNextTradingDay(formattedDate);
// 确保返回的是字符串格式
if (typeof nextTradingDay === 'string') {
formattedDate = nextTradingDay;
} else if (nextTradingDay instanceof Date) {
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD');
} else {
console.warn('tradingDayUtils.getNextTradingDay returned invalid format:', nextTradingDay);
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
}
} else {
// 降级处理:简单地加一天(不考虑周末和节假日)
console.warn('tradingDayUtils.getNextTradingDay not available, using simple date addition');
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
}
}
} catch (e) {
console.error('Failed to format event time:', e);
// 使用当前交易日作为fallback
if (tradingDayUtils && tradingDayUtils.getCurrentTradingDay) {
const currentTradingDay = tradingDayUtils.getCurrentTradingDay();
// 确保返回的是字符串格式
if (typeof currentTradingDay === 'string') {
formattedDate = currentTradingDay;
} else if (currentTradingDay instanceof Date) {
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD');
} else {
console.warn('tradingDayUtils.getCurrentTradingDay returned invalid format:', currentTradingDay);
formattedDate = moment().format('YYYY-MM-DD');
}
} else {
formattedDate = moment().format('YYYY-MM-DD');
}
}
searchConcepts(eventTitle, formattedDate);
} else if (!eventTitle) {
console.warn('No event title provided for concept search');
setConcepts([]);
if (eventTitle && effectiveTradingDate) {
searchConcepts(eventTitle, effectiveTradingDate);
} else if (eventTitle) {
// 如果没有交易日期,仍然搜索但不带日期参数
searchConcepts(eventTitle, null);
}
}, [eventTitle, eventTime]);
}, [eventTitle, effectiveTradingDate]);
const handleViewDetails = (concept) => {
setSelectedConcept(concept);
onOpen();
};
// 合并加载状态
const isLoading = externalLoading || loading;
const displayError = externalError || error;
// 加载状态
if (isLoading) {
if (loading || externalLoading) {
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1, 2, 3, 4].map((i) => (
@@ -468,11 +256,11 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
}
// 错误状态
if (displayError) {
if (error || externalError) {
return (
<Alert status="error" borderRadius="lg">
<AlertIcon />
加载相关概念失败: {displayError}
加载相关概念失败: {error || externalError}
</Alert>
);
}
@@ -481,9 +269,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (!concepts || concepts.length === 0) {
return (
<Box textAlign="center" py={8}>
<Text color="gray.500" mb={4}>
{eventTitle ? '未找到相关概念' : '暂无相关概念数据'}
</Text>
<Text color="gray.500" mb={4}>暂无相关概念数据</Text>
<Button
colorScheme="blue"
size="lg"
@@ -505,9 +291,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
<FaCalendarAlt color={textColor} />
<Text fontSize="sm" color={textColor}>
涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
{eventTime && effectiveTradingDate !== eventTime.split(' ')[0] && (
<Text as="span" ml={2} fontSize="xs">
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : eventTime}显示下一交易日数据)
(事件发生于 {eventTime}显示下一交易日数据)
</Text>
)}
</Text>
@@ -517,9 +303,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
{/* 概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
{concepts.map((concept) => (
<ConceptCard
key={concept.concept_id || index}
key={concept.concept_id}
concept={concept}
tradingDate={effectiveTradingDate}
onViewDetails={handleViewDetails}
@@ -555,183 +341,73 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
</VStack>
</Center>
{/* 增强版概念详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
{/* 概念详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader borderBottomWidth={1}>
<ModalContent>
<ModalHeader>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<Text fontSize="xl">{selectedConcept?.concept}</Text>
<HStack spacing={2}>
<Badge colorScheme="purple">
相关度: {selectedConcept?.score?.toFixed(2)}
</Badge>
<Badge colorScheme="teal">
{selectedConcept?.stock_count} 只股票
</Badge>
</HStack>
</VStack>
<Text>{selectedConcept?.concept}</Text>
{selectedConcept?.price_info && (
<VStack align="end" spacing={1}>
<Text fontSize="xs" color="gray.500">
{selectedConcept.price_info.trade_date || '暂无数据'}
</Text>
<Badge
size="lg"
colorScheme={selectedConcept.price_info.avg_change_pct > 0 ? 'red' : 'green'}
fontSize="lg"
px={4}
py={2}
>
{selectedConcept.price_info.avg_change_pct > 0 ? '+' : ''}
{selectedConcept.price_info.avg_change_pct?.toFixed(2) || '0.00'}%
</Badge>
</VStack>
<Badge colorScheme={selectedConcept.price_info.avg_change_pct > 0 ? 'red' : 'green'}>
{selectedConcept.price_info.avg_change_pct > 0 ? '+' : ''}
{selectedConcept.price_info.avg_change_pct?.toFixed(2)}%
</Badge>
)}
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6} overflowY="auto">
<VStack spacing={6} align="stretch">
{/* 概念描述 - 完整版 */}
<ModalBody pb={6}>
<VStack spacing={4} align="stretch">
{/* 显示交易日期 */}
{effectiveTradingDate && (
<HStack>
<FaCalendarAlt />
<Text fontSize="sm">
数据日期{effectiveTradingDate}
</Text>
</HStack>
)}
{/* 概念描述 */}
{selectedConcept?.description && (
<Box>
<HStack mb={3}>
<Icon as={FaChartLine} color="blue.500" />
<Text fontSize="md" fontWeight="bold">
概念解析
</Text>
</HStack>
<Box
p={4}
bg={useColorModeValue('blue.50', 'blue.900')}
borderRadius="md"
>
<Text
fontSize="sm"
color={useColorModeValue('gray.700', 'gray.300')}
lineHeight="1.8"
>
{selectedConcept.description}
</Text>
</Box>
<Text fontSize="sm" fontWeight="bold" mb={2}>
概念描述:
</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
{selectedConcept.description}
</Text>
</Box>
)}
{/* 历史触发时间线 */}
{selectedConcept?.happened_times && selectedConcept.happened_times.length > 0 && (
<Box>
<HStack mb={3}>
<Icon as={FaCalendarAlt} color="purple.500" />
<Text fontSize="md" fontWeight="bold">
历史触发时间
</Text>
</HStack>
<HStack spacing={3} flexWrap="wrap">
{selectedConcept.happened_times.map((time, idx) => (
<Badge
key={idx}
colorScheme="purple"
variant="subtle"
px={3}
py={1}
>
{time}
</Badge>
))}
</HStack>
</Box>
)}
{/* 相关股票详细列表 */}
{/* 相关股票 */}
{selectedConcept?.stocks && selectedConcept.stocks.length > 0 && (
<Box>
<HStack mb={3}>
<Icon as={FaEye} color="green.500" />
<Text fontSize="md" fontWeight="bold">
核心相关股票 ({selectedConcept.stock_count})
</Text>
</HStack>
<Box
maxH="300px"
overflowY="auto"
borderWidth={1}
borderRadius="md"
p={3}
>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
{selectedConcept.stocks.map((stock, idx) => (
<Box
key={idx}
p={3}
borderWidth={1}
borderRadius="md"
bg={useColorModeValue('white', 'gray.700')}
_hover={{
bg: useColorModeValue('gray.50', 'gray.600'),
borderColor: 'blue.300'
}}
transition="all 0.2s"
>
<HStack justify="space-between" mb={2}>
<Text fontWeight="bold" fontSize="sm">
{stock.stock_name}
</Text>
<Badge colorScheme="blue" fontSize="xs">
{stock.stock_code}
</Badge>
</HStack>
{stock.reason && (
<Text fontSize="xs" color="gray.600">
{stock.reason}
</Text>
)}
{(stock.行业 || stock.项目) && (
<HStack spacing={2} mt={2}>
{stock.行业 && (
<Badge size="sm" variant="subtle">
{stock.行业}
</Badge>
)}
{stock.项目 && (
<Badge size="sm" variant="subtle" colorScheme="green">
{stock.项目}
</Badge>
)}
</HStack>
)}
</Box>
))}
</SimpleGrid>
</Box>
<Text fontSize="sm" fontWeight="bold" mb={2}>
相关股票 ({selectedConcept.stock_count}):
</Text>
<SimpleGrid columns={2} spacing={2}>
{selectedConcept.stocks.slice(0, 6).map((stock, index) => (
<Badge key={index} p={2} variant="outline">
{stock.stock_name} ({stock.stock_code})
</Badge>
))}
</SimpleGrid>
</Box>
)}
{/* 操作按钮 */}
<HStack spacing={3} pt={4}>
<Button
colorScheme="blue"
size="lg"
flex={1}
onClick={() => {
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(selectedConcept.concept)}.html`, '_blank');
}}
leftIcon={<FaExternalLinkAlt />}
>
查看概念详情页
</Button>
<Button
variant="outline"
colorScheme="blue"
size="lg"
flex={1}
onClick={onClose}
>
关闭
</Button>
</HStack>
{/* 查看详情按钮 */}
<Button
colorScheme="blue"
onClick={() => {
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(selectedConcept.concept)}.html`, '_blank');
}}
leftIcon={<FaExternalLinkAlt />}
>
查看概念详情页
</Button>
</VStack>
</ModalBody>
</ModalContent>

View File

@@ -48,7 +48,6 @@ import {
FaSearch
} from 'react-icons/fa';
import * as echarts from 'echarts';
import StockChartModal from '../../../components/StockChart/StockChartModal';
import { eventService, stockService } from '../../../services/eventService';
@@ -71,12 +70,22 @@ const RelatedStocks = ({
const [sortOrder, setSortOrder] = useState('desc');
// 模态框状态
const {
isOpen: isAddModalOpen,
onOpen: onAddModalOpen,
onClose: onAddModalClose
} = useDisclosure();
const {
isOpen: isChartModalOpen,
onOpen: onChartModalOpen,
onClose: onChartModalClose
} = useDisclosure();
// 添加股票表单状态
const [addStockForm, setAddStockForm] = useState({
stock_code: '',
relation_desc: ''
});
// 主题和工具
const toast = useToast();
@@ -132,6 +141,41 @@ const RelatedStocks = ({
});
};
const handleAddStock = async () => {
if (!addStockForm.stock_code.trim() || !addStockForm.relation_desc.trim()) {
toast({
title: '请填写完整信息',
status: 'warning',
duration: 3000,
isClosable: true,
});
return;
}
try {
await eventService.addRelatedStock(eventId, addStockForm);
toast({
title: '添加成功',
status: 'success',
duration: 3000,
isClosable: true,
});
// 重置表单
setAddStockForm({ stock_code: '', relation_desc: '' });
onAddModalClose();
onStockAdded();
} catch (err) {
toast({
title: '添加失败',
description: err.message,
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleDeleteStock = async (stockId, stockCode) => {
if (!window.confirm(`确定要删除股票 ${stockCode} 吗?`)) {
@@ -288,6 +332,14 @@ const RelatedStocks = ({
/>
</Tooltip>
<Button
leftIcon={<FaPlus />}
size="sm"
colorScheme="blue"
onClick={onAddModalOpen}
>
添加股票
</Button>
</HStack>
</Flex>
@@ -329,6 +381,7 @@ const RelatedStocks = ({
}}>
涨跌幅 {sortField === 'change' && (sortOrder === 'asc' ? '↑' : '↓')}
</Th>
<Th textAlign="center">分时图</Th>
<Th textAlign="center" cursor="pointer" onClick={() => {
if (sortField === 'correlation') {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
@@ -414,6 +467,16 @@ const RelatedStocks = ({
</Badge>
</Td>
{/* 分时图 */}
<Td>
<Box width="120px" height="40px">
<MiniChart
stockCode={stock.stock_code}
eventTime={eventTime}
onChartClick={() => handleShowChart(stock)}
/>
</Box>
</Td>
{/* 相关度 */}
<Td textAlign="center">
@@ -438,20 +501,6 @@ const RelatedStocks = ({
{/* 操作 */}
<Td>
<HStack spacing={1}>
<Tooltip label="股票详情">
<Button
size="xs"
colorScheme="blue"
variant="solid"
onClick={() => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
}}
>
股票详情
</Button>
</Tooltip>
<Tooltip label="查看K线图">
<IconButton
icon={<FaChartLine />}
@@ -482,6 +531,14 @@ const RelatedStocks = ({
</TableContainer>
</Box>
{/* 添加股票模态框 */}
<AddStockModal
isOpen={isAddModalOpen}
onClose={onAddModalClose}
formData={addStockForm}
setFormData={setAddStockForm}
onSubmit={handleAddStock}
/>
{/* 股票图表模态框 */}
<StockChartModal
@@ -496,8 +553,401 @@ const RelatedStocks = ({
// ==================== 子组件 ====================
// 迷你分时图组件
const MiniChart = ({ stockCode, eventTime, onChartClick }) => {
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (chartRef.current && stockCode) {
loadChartData();
}
return () => {
if (chartInstanceRef.current) {
chartInstanceRef.current.dispose();
chartInstanceRef.current = null;
}
};
}, [stockCode, eventTime]);
// 现在使用统一的StockChartModal组件无需重复代码
const loadChartData = async () => {
try {
setLoading(true);
setError(null);
const response = await stockService.getKlineData(stockCode, 'timeline', eventTime);
if (!response.data || response.data.length === 0) {
setError('无数据');
return;
}
// 初始化图表
if (!chartInstanceRef.current && chartRef.current) {
chartInstanceRef.current = echarts.init(chartRef.current);
}
const option = generateMiniChartOption(response.data);
chartInstanceRef.current.setOption(option, true);
} catch (err) {
console.error('加载迷你图表失败:', err);
setError('加载失败');
} finally {
setLoading(false);
}
};
const generateMiniChartOption = (data) => {
const prices = data.map(item => item.close);
const times = data.map(item => item.time);
// 计算最高最低价格
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
// 判断是上涨还是下跌
const isUp = prices[prices.length - 1] >= prices[0];
const lineColor = isUp ? '#ef5350' : '#26a69a';
return {
grid: {
left: 2,
right: 2,
top: 2,
bottom: 2,
containLabel: false
},
xAxis: {
type: 'category',
data: times,
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false,
min: minPrice * 0.995,
max: maxPrice * 1.005
},
series: [{
data: prices,
type: 'line',
smooth: true,
symbol: 'none',
lineStyle: {
color: lineColor,
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)'
},
{
offset: 1,
color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)'
}
])
},
markLine: {
silent: true,
symbol: 'none',
label: { show: false },
lineStyle: {
color: '#aaa',
type: 'dashed',
width: 1
},
data: [{
yAxis: prices[0] // 参考价
}]
}
}],
tooltip: {
trigger: 'axis',
formatter: function(params) {
if (!params || params.length === 0) return '';
const price = params[0].value.toFixed(2);
const time = params[0].axisValue;
const percentChange = ((price - prices[0]) / prices[0] * 100).toFixed(2);
const sign = percentChange >= 0 ? '+' : '';
return `${time}<br/>价格: ${price}<br/>变动: ${sign}${percentChange}%`;
},
position: function (pos, params, el, elRect, size) {
const obj = { top: 10 };
obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 30;
return obj;
}
},
animation: false
};
};
if (loading) {
return (
<Flex align="center" justify="center" h="100%" w="100%">
<Text fontSize="xs" color="gray.500">加载中...</Text>
</Flex>
);
}
if (error) {
return (
<Flex align="center" justify="center" h="100%" w="100%">
<Text fontSize="xs" color="gray.500">{error}</Text>
</Flex>
);
}
return (
<Box
ref={chartRef}
w="100%"
h="100%"
cursor="pointer"
onClick={onChartClick}
_hover={{ opacity: 0.8 }}
/>
);
};
// 添加股票模态框组件
const AddStockModal = ({ isOpen, onClose, formData, setFormData, onSubmit }) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>添加相关股票</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>股票代码</FormLabel>
<Input
placeholder="请输入股票代码000001.SZ"
value={formData.stock_code}
onChange={(e) => setFormData(prev => ({
...prev,
stock_code: e.target.value.toUpperCase()
}))}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>关联描述</FormLabel>
<Textarea
placeholder="请描述该股票与事件的关联原因..."
value={formData.relation_desc}
onChange={(e) => setFormData(prev => ({
...prev,
relation_desc: e.target.value
}))}
rows={4}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
取消
</Button>
<Button colorScheme="blue" onClick={onSubmit}>
确定
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
// 股票图表模态框组件
const StockChartModal = ({ isOpen, onClose, stock, eventTime }) => {
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);
const [chartType, setChartType] = useState('timeline');
const [loading, setLoading] = useState(false);
const [chartData, setChartData] = useState(null);
const toast = useToast();
useEffect(() => {
if (isOpen && chartRef.current) {
loadChartData(chartType);
}
return () => {
if (chartInstanceRef.current) {
window.removeEventListener('resize', chartInstanceRef.current.resizeHandler);
chartInstanceRef.current.dispose();
chartInstanceRef.current = null;
}
};
}, [isOpen]);
useEffect(() => {
if (isOpen && chartRef.current) {
loadChartData(chartType);
}
}, [chartType, stock, eventTime]);
const loadChartData = async (type) => {
if (!stock || !chartRef.current) return;
try {
setLoading(true);
if (chartInstanceRef.current) {
chartInstanceRef.current.showLoading();
}
const response = await stockService.getKlineData(stock.stock_code, type, eventTime);
setChartData(response);
if (!chartRef.current) return;
if (!chartInstanceRef.current) {
const chart = echarts.init(chartRef.current);
chart.resizeHandler = () => chart.resize();
window.addEventListener('resize', chart.resizeHandler);
chartInstanceRef.current = chart;
}
chartInstanceRef.current.hideLoading();
const option = generateChartOption(response, type);
chartInstanceRef.current.setOption(option, true);
} catch (err) {
console.error('加载图表数据失败:', err);
toast({ title: '加载图表数据失败', description: err.message, status: 'error', duration: 3000, isClosable: true, });
if (chartInstanceRef.current) {
chartInstanceRef.current.hideLoading();
}
} finally {
setLoading(false);
}
};
const generateChartOption = (data, type) => {
if (!data || !data.data || data.data.length === 0) {
return { title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 16 } } };
}
const stockData = data.data;
if (type === 'timeline' || type === 'minute') {
const times = stockData.map(item => item.time);
const prices = stockData.map(item => item.close);
const isUp = prices[prices.length - 1] >= prices[0];
const lineColor = isUp ? '#ef5350' : '#26a69a';
return {
title: { text: `${data.name} (${data.code}) - ${type === 'timeline' ? '分时图' : '分钟线'}`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
tooltip: {
trigger: 'axis',
formatter: function(params) {
if (!params || params.length === 0) {
return '';
}
const point = params[0];
if (!point) return '';
return `时间: ${point.axisValue}<br/>价格: ¥${point.value.toFixed(2)}`;
}
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: times, boundaryGap: false },
yAxis: { type: 'value', scale: true },
series: [{ data: prices, type: 'line', smooth: true, symbol: 'none', lineStyle: { color: lineColor, width: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' }, { offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.1)' : 'rgba(38, 166, 154, 0.1)' }]) } }]
};
}
if (type === 'daily') {
const dates = stockData.map(item => item.time);
const klineData = stockData.map(item => [item.open, item.close, item.low, item.high]);
const volumes = stockData.map(item => item.volume);
return {
title: { text: `${data.name} (${data.code}) - 日K线`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function(params) {
if (!params || !Array.isArray(params) || params.length === 0) {
return '';
}
const kline = params[0];
const volume = params[1];
if (!kline || !kline.data) {
return '';
}
let tooltipHtml = `日期: ${kline.axisValue}<br/>
开盘: ${kline.data[0]}<br/>
收盘: ${kline.data[1]}<br/>
最低: ${kline.data[2]}<br/>
最高: ${kline.data[3]}`;
if (volume && typeof volume.value !== 'undefined') {
tooltipHtml += `<br/>成交量: ${volume.value}`;
}
return tooltipHtml;
}
},
grid: [{ left: '10%', right: '10%', height: '60%' }, { left: '10%', right: '10%', top: '70%', height: '16%' }],
xAxis: [{ type: 'category', data: dates, scale: true, boundaryGap: false, axisLine: { onZero: false }, splitLine: { show: false }, min: 'dataMin', max: 'dataMax' }, { type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLine: { onZero: false }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, min: 'dataMin', max: 'dataMax' }],
yAxis: [{ scale: true, splitArea: { show: true } }, { scale: true, gridIndex: 1, splitNumber: 2, axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false }, splitLine: { show: false } }],
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1], start: 80, end: 100 }, { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 10, start: 80, end: 100 }],
series: [{ name: 'K线', type: 'candlestick', data: klineData, itemStyle: { color: '#ef5350', color0: '#26a69a', borderColor: '#ef5350', borderColor0: '#26a69a' } }, { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes, itemStyle: { color: function(params) { const dataIndex = params.dataIndex; if (dataIndex === 0) return '#ef5350'; return stockData[dataIndex].close >= stockData[dataIndex - 1].close ? '#ef5350' : '#26a69a'; } } }]
};
}
return {};
};
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>
<VStack align="flex-start" spacing={2}>
<HStack>
<Text fontSize="lg" fontWeight="bold">{stock.stock_code} - 股票详情</Text>
{chartData && (<Badge colorScheme="blue">{chartData.trade_date}</Badge>)}
</HStack>
<ButtonGroup size="sm">
<Button variant={chartType === 'timeline' ? 'solid' : 'outline'} onClick={() => setChartType('timeline')} colorScheme="blue">分时图</Button>
<Button variant={chartType === 'minute' ? 'solid' : 'outline'} onClick={() => setChartType('minute')} colorScheme="blue">分钟线</Button>
<Button variant={chartType === 'daily' ? 'solid' : 'outline'} onClick={() => setChartType('daily')} colorScheme="blue">日K线</Button>
</ButtonGroup>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody p={0}>
<Box h="500px" w="100%" position="relative">
{loading && (
<Flex position="absolute" top="0" left="0" right="0" bottom="0" bg="rgba(255, 255, 255, 0.7)" zIndex="10" alignItems="center" justifyContent="center" >
<VStack spacing={4}>
<CircularProgress isIndeterminate color="blue.300" />
<Text>加载图表数据...</Text>
</VStack>
</Flex>
)}
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }}/>
</Box>
{stock?.relation_desc && (
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
<Text fontSize="sm" color="gray.600">{stock.relation_desc}</Text>
</Box>
)}
{process.env.NODE_ENV === 'development' && chartData && (
<Box p={4} bg="gray.50" fontSize="xs" color="gray.600">
<Text fontWeight="bold">调试信息:</Text>
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
<Text>交易日期: {chartData.trade_date}</Text>
<Text>图表类型: {chartData.type}</Text>
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>
);
};
export default RelatedStocks;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,43 @@
import React from 'react';
import ReactECharts from 'echarts-for-react';
const SectorPieChart = ({ sectorData }) => {
const data = Object.entries(sectorData || {}).map(([name, d]) => ({
name,
value: d.count,
}));
const option = {
title: {
text: '涨停股票分布',
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
},
series: [
{
name: '板块分布',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
};
return <ReactECharts option={option} style={{ height: 400 }} />;
};
export default SectorPieChart;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Grid, Stat, StatLabel, StatNumber, StatHelpText, StatArrow, Box } from '@chakra-ui/react';
import { FiTarget, FiBarChart2, FiTrendingUp, FiZap } from 'react-icons/fi';
const StatCard = ({ icon, label, value, color }) => (
<Box p={4} bg="white" borderRadius="lg" boxShadow="md" display="flex" alignItems="center">
<Box as={icon} boxSize={6} color={color} mr={3} />
<Box>
<StatLabel>{label}</StatLabel>
<StatNumber>{value}</StatNumber>
</Box>
</Box>
);
const StatisticsCards = ({ data }) => (
<Grid templateColumns="repeat(4, 1fr)" gap={6} mb={6}>
<StatCard icon={FiTarget} label="涨停股票总数" value={data.total_stocks} color="blue.400" />
<StatCard icon={FiBarChart2} label="涉及板块数" value={data.sector_count} color="green.400" />
<StatCard icon={FiTrendingUp} label="平均涨幅" value={data.avg_change + '%'} color="orange.400" />
<StatCard icon={FiZap} label="最大涨幅" value={data.max_change + '%'} color="red.400" />
</Grid>
);
export default StatisticsCards;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import React from 'react';
import ReactECharts from 'echarts-for-react';
const WordCloudChart = ({ sectorData }) => {
const data = Object.entries(sectorData || {}).map(([name, d]) => ({
name,
value: d.count,
}));
const option = {
title: {
text: '热点词汇',
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c}',
},
series: [
{
type: 'wordCloud',
shape: 'circle',
left: 'center',
top: 'center',
width: '90%',
height: '90%',
sizeRange: [16, 60],
rotationRange: [-30, 30],
gridSize: 12,
drawOutOfBound: false,
layoutAnimation: true,
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold',
color: () => {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
];
return colors[Math.floor(Math.random() * colors.length)];
},
},
emphasis: {
textStyle: {
shadowBlur: 10,
shadowColor: '#333',
},
},
data,
},
],
};
return <ReactECharts option={option} style={{ height: 400 }} />;
};
export default WordCloudChart;

File diff suppressed because it is too large Load Diff