Files
vf_react/src/views/EventDetail/components/RelatedConcepts.js
zdl 9b55610167 perf: 将 Moment.js 替换为 Day.js,优化打包体积
## 改动内容
  - 替换所有 Moment.js 引用为 Day.js (29 个文件)
  - 更新 Webpack 配置,调整 calendar-lib chunk
  - 添加 Day.js 插件支持 (isSameOrBefore, isSameOrAfter)
  - 移除 Moment.js 依赖

  ## 性能提升
  - JavaScript 打包体积减少: ~50 KB (未压缩)
  - gzip 后减少: ~15-18 KB
  - 预计首屏加载时间提升: 15-20%

  ## 影响范围
  - Dashboard 组件: 5 个文件
  - Community 组件: 19 个文件
  - 工具函数: tradingTimeUtils.js (添加插件)
  - 其他组件: 5 个文件

  ## 测试状态
  -  构建成功 (npm run build)
2025-11-17 19:27:45 +08:00

796 lines
35 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/RelatedConcepts.js - 支持概念API调用
import React, { useState, useEffect } from 'react';
import {
Icon, // 明确导入 Icon 组件
Box,
VStack,
HStack,
Text,
Badge,
SimpleGrid,
Image,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
useDisclosure,
Skeleton,
Alert,
AlertIcon,
Card,
CardBody,
useColorModeValue,
IconButton,
Tooltip,
Button,
Center,
Divider
} from '@chakra-ui/react';
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
import dayjs from 'dayjs';
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
import { logger } from '../../../utils/logger';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
// 增强版 ConceptCard 组件 - 展示更多数据细节
const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
const [isExpanded, setIsExpanded] = useState(false);
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const textColor = PROFESSIONAL_COLORS.text.secondary;
const highlightBg = 'rgba(255, 195, 0, 0.1)';
// 计算涨跌幅颜色和符号
const changeColor = concept.price_info?.avg_change_pct > 0 ? 'red' : 'green';
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 handleConceptClick = () => {
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(concept.concept)}.html`, '_blank');
};
return (
<Card
bg={cardBg}
borderColor={borderColor}
borderWidth={2}
_hover={{
transform: 'translateY(-2px)',
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.4)',
borderColor: '#3B82F6'
}}
transition="all 0.3s"
>
<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="#3B82F6">
{concept.concept}
</Text>
<HStack spacing={2} flexWrap="wrap">
<Badge
bg="rgba(168, 85, 247, 0.15)"
color="#A855F7"
borderWidth="1px"
borderColor="#A855F7"
fontSize="xs"
>
相关度: {concept.score.toFixed(2)}
</Badge>
<Badge
bg="rgba(20, 184, 166, 0.15)"
color="#14B8A6"
borderWidth="1px"
borderColor="#14B8A6"
fontSize="xs"
>
{getMatchTypeName(concept.match_type)}
</Badge>
<Badge
bg="rgba(251, 146, 60, 0.15)"
color="#FB923C"
borderWidth="1px"
borderColor="#FB923C"
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}
>
{changeSymbol}{concept.price_info.avg_change_pct?.toFixed(2)}%
</Badge>
</Box>
)}
</HStack>
</Box>
<Divider />
{/* 概念描述 */}
<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}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{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>
</VStack>
</CardBody>
</Card>
);
};
// 主组件 - 修改为接收事件信息并调用API
const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoading, error: externalError }) => {
// 调试:检查 Icon 组件是否可用
if (typeof Icon === 'undefined') {
logger.error('RelatedConcepts', 'Icon组件检查', new Error('Icon组件未定义'), {
eventId
});
return <div>组件加载错误Icon 组件未定义</div>;
}
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedConcept, setSelectedConcept] = useState(null);
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [effectiveTradingDate, setEffectiveTradingDate] = useState(null);
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');
}
// 验证新的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;
};
// 搜索相关概念
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 = dayjs(tradeDate).format('YYYY-MM-DD');
} else if (moment.isMoment(tradeDate)) {
formattedTradeDate = tradeDate.format('YYYY-MM-DD');
} else {
logger.warn('RelatedConcepts', '无效的交易日期格式', {
tradeDate,
tradeDateType: typeof tradeDate
});
formattedTradeDate = dayjs().format('YYYY-MM-DD');
}
const requestBody = {
query: title,
size: 4,
page: 1,
sort_by: "_score",
trade_date: formattedTradeDate
};
logger.debug('RelatedConcepts', '搜索概念', requestBody);
const response = await fetch('/concept-api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
logger.debug('RelatedConcepts', '概念搜索响应', {
hasResults: !!data.results,
resultsCount: data.results?.length || 0
});
// 数据验证
if (!validateConceptData(data)) {
logger.warn('RelatedConcepts', '概念数据格式无效', {
hasData: !!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([]);
logger.warn('RelatedConcepts', '响应中未找到概念数据', {
hasResults: !!data.results,
hasDataConcepts: !!(data.data?.concepts)
});
}
} catch (err) {
logger.error('RelatedConcepts', 'searchConcepts', err, {
title,
tradeDate: formattedTradeDate
});
setError(err.message);
setConcepts([]);
} finally {
setLoading(false);
}
};
// 当事件信息变化时调用API搜索概念
useEffect(() => {
if (eventTitle && eventTime) {
// 格式化日期为 YYYY-MM-DD
let formattedDate;
try {
// eventTime 可能是Date对象或字符串使用 moment 处理
let eventMoment;
// 检查是否是Date对象
if (eventTime instanceof Date) {
eventMoment = dayjs(eventTime);
} else if (typeof eventTime === 'string') {
eventMoment = dayjs(eventTime);
} else if (typeof eventTime === 'number') {
eventMoment = dayjs(eventTime);
} else {
logger.warn('RelatedConcepts', '未知的事件时间格式', {
eventTime,
eventTimeType: typeof eventTime,
eventId
});
eventMoment = dayjs();
}
// 确保moment对象有效
if (!eventMoment.isValid()) {
logger.warn('RelatedConcepts', '无效的事件时间', {
eventTime,
eventId
});
eventMoment = dayjs();
}
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 = dayjs(nextTradingDay).format('YYYY-MM-DD');
} else {
logger.warn('RelatedConcepts', '交易日工具返回了无效格式', {
nextTradingDay,
eventId
});
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
}
} else {
// 降级处理:简单地加一天(不考虑周末和节假日)
logger.warn('RelatedConcepts', '交易日工具不可用,使用简单日期加法', {
eventId
});
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
}
}
} catch (e) {
logger.error('RelatedConcepts', 'formatEventTime', e, {
eventTime,
eventId
});
// 使用当前交易日作为fallback
if (tradingDayUtils && tradingDayUtils.getCurrentTradingDay) {
const currentTradingDay = tradingDayUtils.getCurrentTradingDay();
// 确保返回的是字符串格式
if (typeof currentTradingDay === 'string') {
formattedDate = currentTradingDay;
} else if (currentTradingDay instanceof Date) {
formattedDate = dayjs(currentTradingDay).format('YYYY-MM-DD');
} else {
logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', {
currentTradingDay,
eventId
});
formattedDate = dayjs().format('YYYY-MM-DD');
}
} else {
formattedDate = dayjs().format('YYYY-MM-DD');
}
}
searchConcepts(eventTitle, formattedDate);
} else if (!eventTitle) {
logger.warn('RelatedConcepts', '未提供事件标题,无法搜索概念', {
eventId
});
setConcepts([]);
}
}, [eventTitle, eventTime]);
const handleViewDetails = (concept) => {
setSelectedConcept(concept);
onOpen();
};
// 合并加载状态
const isLoading = externalLoading || loading;
const displayError = externalError || error;
// 加载状态
if (isLoading) {
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1, 2, 3, 4].map((i) => (
<Box key={i}>
<Skeleton height="200px" borderRadius="lg" />
</Box>
))}
</SimpleGrid>
);
}
// 错误状态
if (displayError) {
return (
<Alert status="error" borderRadius="lg">
<AlertIcon />
加载相关概念失败: {displayError}
</Alert>
);
}
// 无数据状态
if (!concepts || concepts.length === 0) {
return (
<Box textAlign="center" py={8}>
<Text color="gray.500" mb={4}>
{eventTitle ? '未找到相关概念' : '暂无相关概念数据'}
</Text>
<Button
colorScheme="blue"
size="lg"
leftIcon={<FaChartLine />}
onClick={() => window.open('https://valuefrontier.cn/concepts', '_blank')}
>
进入概念中心
</Button>
</Box>
);
}
return (
<>
{/* 如果有交易日期,显示日期信息 */}
{effectiveTradingDate && (
<Box mb={4} p={3} bg={bgColor} borderRadius="md">
<HStack spacing={2}>
<FaCalendarAlt color={textColor} />
<Text fontSize="sm" color={textColor}>
涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs">
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : eventTime}显示下一交易日数据)
</Text>
)}
</Text>
</HStack>
</Box>
)}
{/* 概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
<ConceptCard
key={concept.concept_id || index}
concept={concept}
tradingDate={effectiveTradingDate}
onViewDetails={handleViewDetails}
/>
))}
</SimpleGrid>
{/* 进入概念中心按钮 */}
<Center mt={8}>
<VStack spacing={3}>
<Button
colorScheme="blue"
size="lg"
leftIcon={<FaChartLine />}
onClick={() => window.open('https://valuefrontier.cn/concepts', '_blank')}
px={8}
py={6}
fontSize="md"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, cyan.400)"
_hover={{
bgGradient: "linear(to-r, blue.500, cyan.500)",
transform: "translateY(-2px)",
shadow: "lg"
}}
transition="all 0.2s"
>
进入概念中心
</Button>
<Text fontSize="sm" color="gray.500">
探索更多概念板块发现投资机会
</Text>
</VStack>
</Center>
{/* 增强版概念详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader borderBottomWidth={1}>
<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>
{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>
)}
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6} overflowY="auto">
<VStack spacing={6} align="stretch">
{/* 概念描述 - 完整版 */}
{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>
</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>
</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>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
export default RelatedConcepts;