refactor: 重构 Community 目录,将公共组件迁移到 src/components/
- 迁移 klineDataCache.js 到 src/utils/stock/(被 StockChart 使用) - 迁移 InvestmentCalendar 到 src/components/InvestmentCalendar/(被 Navbar、Dashboard 使用) - 迁移 DynamicNewsDetail 到 src/components/EventDetailPanel/(被 EventDetail 使用) - 更新所有相关导入路径,使用路径别名 - 保持 Community 目录其余结构不变 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
137
src/components/EventDetailPanel/CollapsibleHeader.js
Normal file
137
src/components/EventDetailPanel/CollapsibleHeader.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// src/components/EventDetailPanel/CollapsibleHeader.js
|
||||
// 可折叠模块标题组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
IconButton,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 可折叠模块标题组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - 标题文本
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false)
|
||||
* @param {string} props.currentMode - 当前模式:'detailed' | 'simple'
|
||||
* @param {Function} props.onModeToggle - 模式切换回调
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
*/
|
||||
const CollapsibleHeader = ({
|
||||
title,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count = null,
|
||||
subscriptionBadge = null,
|
||||
showModeToggle = false,
|
||||
currentMode = 'detailed',
|
||||
onModeToggle = null,
|
||||
isLocked = false
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 获取按钮文案
|
||||
const getButtonText = () => {
|
||||
if (currentMode === 'simple') {
|
||||
return '查看详情'; // 简单模式时,按钮显示"查看详情"
|
||||
}
|
||||
return '精简模式'; // 详细模式时,按钮显示"精简模式"
|
||||
};
|
||||
|
||||
// 获取按钮图标
|
||||
const getButtonIcon = () => {
|
||||
if (currentMode === 'simple') {
|
||||
return null; // 简单模式不显示图标
|
||||
}
|
||||
// 详细模式:展开显示向上箭头,收起显示向下箭头
|
||||
return isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
onClick={showModeToggle ? undefined : onToggle}
|
||||
p={3}
|
||||
bg={sectionBg}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
{/* 左侧:标题区域(可点击切换展开) */}
|
||||
<HStack
|
||||
spacing={2}
|
||||
cursor="pointer"
|
||||
onClick={showModeToggle ? onToggle : undefined}
|
||||
flex="1"
|
||||
>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
{title}
|
||||
</Heading>
|
||||
{subscriptionBadge && (
|
||||
<Box>
|
||||
{subscriptionBadge}
|
||||
</Box>
|
||||
)}
|
||||
{count !== null && count > 0 && (
|
||||
<Badge colorScheme="blue" borderRadius="full">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 展开/收起图标(showModeToggle 时显示在标题旁边) */}
|
||||
{showModeToggle && (
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
|
||||
{showModeToggle && onModeToggle && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onModeToggle(e);
|
||||
}}
|
||||
>
|
||||
{currentMode === 'simple' ? '详细信息' : '精简模式'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* showModeToggle=false 时显示原有的 IconButton */}
|
||||
{!showModeToggle && (
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleHeader;
|
||||
125
src/components/EventDetailPanel/CollapsibleSection.js
Normal file
125
src/components/EventDetailPanel/CollapsibleSection.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// src/components/EventDetailPanel/CollapsibleSection.js
|
||||
// 通用可折叠区块组件
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import CollapsibleHeader from './CollapsibleHeader';
|
||||
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
|
||||
|
||||
/**
|
||||
* 通用可折叠区块组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - 标题文本
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
* @param {Function} props.onLockedClick - 锁定时点击的回调
|
||||
* @param {React.ReactNode} props.children - 详细内容
|
||||
* @param {React.ReactNode} props.simpleContent - 精简模式的内容(可选)
|
||||
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false)
|
||||
* @param {string} props.defaultMode - 默认模式:'detailed' | 'simple'(默认 'detailed')
|
||||
*/
|
||||
const CollapsibleSection = ({
|
||||
title,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count = null,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
children,
|
||||
simpleContent = null,
|
||||
showModeToggle = false,
|
||||
defaultMode = 'detailed'
|
||||
}) => {
|
||||
const sectionBg = PROFESSIONAL_COLORS.background.secondary;
|
||||
|
||||
// 模式状态:'detailed' | 'simple'
|
||||
const [displayMode, setDisplayMode] = useState(defaultMode);
|
||||
|
||||
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
|
||||
const handleToggle = () => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (!isLocked) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理模式切换
|
||||
const handleModeToggle = (e) => {
|
||||
e.stopPropagation(); // 阻止冒泡到标题栏的 onToggle
|
||||
|
||||
if (isLocked && onLockedClick) {
|
||||
// 如果被锁定,触发付费弹窗
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayMode === 'detailed') {
|
||||
// 从详细模式切换到精简模式
|
||||
setDisplayMode('simple');
|
||||
} else {
|
||||
// 从精简模式切换回详细模式
|
||||
setDisplayMode('detailed');
|
||||
// 切换回详细模式时,如果未展开则自动展开
|
||||
if (!isOpen && onToggle) {
|
||||
onToggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染精简模式
|
||||
const renderSimpleMode = () => {
|
||||
if (!simpleContent) return null;
|
||||
|
||||
return (
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{simpleContent}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染详细模式
|
||||
const renderDetailedMode = () => {
|
||||
return (
|
||||
<Collapse
|
||||
in={isOpen && !isLocked}
|
||||
animateOpacity
|
||||
unmountOnExit={false}
|
||||
startingHeight={0}
|
||||
>
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onToggle={handleToggle}
|
||||
count={count}
|
||||
subscriptionBadge={subscriptionBadge}
|
||||
showModeToggle={showModeToggle}
|
||||
currentMode={displayMode}
|
||||
onModeToggle={handleModeToggle}
|
||||
isLocked={isLocked}
|
||||
/>
|
||||
|
||||
{/* 根据当前模式渲染对应内容 */}
|
||||
{displayMode === 'simple' ? renderSimpleMode() : renderDetailedMode()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleSection;
|
||||
100
src/components/EventDetailPanel/CompactMetaBar.js
Normal file
100
src/components/EventDetailPanel/CompactMetaBar.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// src/components/EventDetailPanel/CompactMetaBar.js
|
||||
// 精简信息栏组件(无头部模式下右上角显示)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Badge,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
/**
|
||||
* 精简信息栏组件
|
||||
* 在无头部模式下,显示在 CardBody 右上角
|
||||
* 包含:重要性徽章、浏览次数、关注按钮
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {Object} props.importance - 重要性配置对象(包含 level, icon 等)
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {Function} props.onToggleFollow - 切换关注回调
|
||||
*/
|
||||
const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
|
||||
const viewCountBg = useColorModeValue('white', 'gray.700');
|
||||
const viewCountTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
// 获取重要性文本
|
||||
const getImportanceText = () => {
|
||||
const levelMap = {
|
||||
'S': '极高',
|
||||
'A': '高',
|
||||
'B': '中',
|
||||
'C': '低'
|
||||
};
|
||||
return levelMap[importance.level] || '中';
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack
|
||||
position="absolute"
|
||||
top={3}
|
||||
right={3}
|
||||
spacing={3}
|
||||
zIndex={1}
|
||||
>
|
||||
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 - H5 隐藏 */}
|
||||
<Badge
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
bgGradient={
|
||||
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
|
||||
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
|
||||
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
|
||||
'linear(to-r, gray.500, gray.700)'
|
||||
}
|
||||
color="white"
|
||||
boxShadow="lg"
|
||||
display={{ base: 'none', lg: 'flex' }}
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Icon as={importance.icon} boxSize={4} />
|
||||
<Text>重要性:{getImportanceText()}</Text>
|
||||
</Badge>
|
||||
|
||||
{/* 浏览次数 - 添加容器背景以提高可读性 */}
|
||||
<HStack
|
||||
spacing={1}
|
||||
bg={viewCountBg}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<ViewIcon color="gray.400" boxSize={4} />
|
||||
<Text fontSize="sm" color={viewCountTextColor} whiteSpace="nowrap">
|
||||
{(event.view_count || 0).toLocaleString()}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={onToggleFollow}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactMetaBar;
|
||||
116
src/components/EventDetailPanel/CompactStockItem.js
Normal file
116
src/components/EventDetailPanel/CompactStockItem.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// src/components/EventDetailPanel/CompactStockItem.js
|
||||
// 精简模式股票卡片组件(浮动卡片样式)
|
||||
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '@utils/colorUtils';
|
||||
|
||||
/**
|
||||
* 精简模式股票卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* @param {Object} props.quote - 股票行情数据(可选)
|
||||
*/
|
||||
const CompactStockItem = ({ stock, quote = null }) => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
const handleViewDetail = () => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined || isNaN(value)) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${stock.stock_name} - 点击查看详情`}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
bgGradient={getChangeBackgroundGradient(change)}
|
||||
borderWidth="1px"
|
||||
borderColor={getChangeBorderColor(change)}
|
||||
borderRadius="xl"
|
||||
p={2}
|
||||
onClick={handleViewDetail}
|
||||
cursor="pointer"
|
||||
boxShadow="lg"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
bg: getChangeBorderColor(change),
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: '2xl',
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
}}
|
||||
transition="all 0.3s ease-in-out"
|
||||
display="inline-block"
|
||||
minW="100px"
|
||||
>
|
||||
{/* 股票代码 */}
|
||||
<Text
|
||||
fontSize={isMobile ? "sm" : "md"}
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(change)}
|
||||
mb={isMobile ? 1 : 2}
|
||||
textAlign="center"
|
||||
>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
|
||||
{/* 涨跌幅 - 超大号显示 */}
|
||||
<Text
|
||||
fontSize={isMobile ? "xl" : "3xl"}
|
||||
fontWeight="black"
|
||||
color={getChangeColor(change)}
|
||||
textAlign="center"
|
||||
lineHeight="1"
|
||||
textShadow="0 1px 2px rgba(0,0,0,0.1)"
|
||||
>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
|
||||
{/* 股票名称(小字) */}
|
||||
<Text
|
||||
fontSize={isMobile ? "2xs" : "xs"}
|
||||
color={nameColor}
|
||||
mt={isMobile ? 1 : 2}
|
||||
textAlign="center"
|
||||
noOfLines={1}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactStockItem;
|
||||
424
src/components/EventDetailPanel/DynamicNewsDetailPanel.js
Normal file
424
src/components/EventDetailPanel/DynamicNewsDetailPanel.js
Normal file
@@ -0,0 +1,424 @@
|
||||
// src/components/EventDetailPanel/DynamicNewsDetailPanel.js
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
import React, { useState, useCallback, useEffect, useReducer } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
import { getImportanceConfig } from '@constants/importanceLevels';
|
||||
import { eventService } from '@services/eventService';
|
||||
import { useEventStocks } from '@views/Community/components/StockDetailPanel/hooks/useEventStocks';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
import CompactMetaBar from './CompactMetaBar';
|
||||
import EventDescriptionSection from './EventDescriptionSection';
|
||||
import RelatedConceptsSection from './RelatedConceptsSection';
|
||||
import RelatedStocksSection from './RelatedStocksSection';
|
||||
import CompactStockItem from './CompactStockItem';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import HistoricalEvents from '@views/EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '@views/EventDetail/components/TransmissionChainAnalysis';
|
||||
import SubscriptionBadge from '@components/SubscriptionBadge';
|
||||
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
|
||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||
import { useWatchlist } from '@hooks/useWatchlist';
|
||||
import EventCommentSection from '@components/EventCommentSection';
|
||||
|
||||
// 折叠区块状态管理 - 使用 useReducer 整合
|
||||
const initialSectionState = {
|
||||
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
|
||||
concepts: { isOpen: false },
|
||||
historical: { isOpen: false, hasLoaded: false },
|
||||
transmission: { isOpen: false, hasLoaded: false }
|
||||
};
|
||||
|
||||
const sectionReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE':
|
||||
return {
|
||||
...state,
|
||||
[action.section]: {
|
||||
...state[action.section],
|
||||
isOpen: !state[action.section].isOpen
|
||||
}
|
||||
};
|
||||
case 'SET_LOADED':
|
||||
return {
|
||||
...state,
|
||||
[action.section]: {
|
||||
...state[action.section],
|
||||
hasLoaded: true
|
||||
}
|
||||
};
|
||||
case 'SET_QUOTES_LOADED':
|
||||
return {
|
||||
...state,
|
||||
stocks: { ...state.stocks, hasLoadedQuotes: true }
|
||||
};
|
||||
case 'RESET_ALL':
|
||||
return {
|
||||
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
|
||||
concepts: { isOpen: false },
|
||||
historical: { isOpen: false, hasLoaded: false },
|
||||
transmission: { isOpen: false, hasLoaded: false }
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 动态新闻详情面板主组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象(包含详情数据)
|
||||
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true)
|
||||
*/
|
||||
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { user } = useAuth();
|
||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||
const borderColor = PROFESSIONAL_COLORS.border.default;
|
||||
const textColor = PROFESSIONAL_COLORS.text.secondary;
|
||||
|
||||
// 使用 useWatchlist Hook 管理自选股
|
||||
const {
|
||||
handleAddToWatchlist,
|
||||
handleRemoveFromWatchlist,
|
||||
isInWatchlist,
|
||||
loadWatchlistQuotes
|
||||
} = useWatchlist();
|
||||
|
||||
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
||||
const userTier = user?.subscription_type || 'free';
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
|
||||
|
||||
// 🎯 浏览量机制:存储从 API 获取的完整事件详情(包含最新 view_count)
|
||||
const [fullEventDetail, setFullEventDetail] = useState(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
|
||||
// 权限判断函数
|
||||
const hasAccess = useCallback((requiredTier) => {
|
||||
const tierLevel = { free: 0, pro: 1, max: 2 };
|
||||
const result = tierLevel[userTier] >= tierLevel[requiredTier];
|
||||
return result;
|
||||
}, [userTier]);
|
||||
|
||||
// 升级弹窗状态
|
||||
const [upgradeModal, setUpgradeModal] = useState({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
|
||||
// 使用 Hook 获取实时数据
|
||||
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
|
||||
// - autoLoadQuotes: true - 股票数据加载后自动加载行情(相关股票默认展开)
|
||||
const {
|
||||
stocks,
|
||||
quotes,
|
||||
eventDetail,
|
||||
historicalEvents,
|
||||
expectationScore,
|
||||
loading,
|
||||
loadStocksData,
|
||||
loadHistoricalData,
|
||||
loadChainAnalysis,
|
||||
refreshQuotes
|
||||
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: true });
|
||||
|
||||
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
|
||||
const loadEventDetail = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setFullEventDetail(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [event?.id]);
|
||||
|
||||
// 相关股票、相关概念、历史事件和传导链的权限
|
||||
const canAccessStocks = hasAccess('pro');
|
||||
const canAccessConcepts = hasAccess('pro');
|
||||
const canAccessHistorical = hasAccess('pro');
|
||||
const canAccessTransmission = hasAccess('max');
|
||||
|
||||
// 子区块折叠状态管理 - 使用 useReducer 整合
|
||||
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
|
||||
|
||||
// 锁定点击处理 - 弹出升级弹窗
|
||||
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
||||
setUpgradeModal({
|
||||
isOpen: true,
|
||||
requiredLevel,
|
||||
featureName
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 关闭升级弹窗
|
||||
const handleCloseUpgradeModal = useCallback(() => {
|
||||
setUpgradeModal({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 相关股票 - 展开时加载行情(需要 PRO 权限)
|
||||
const handleStocksToggle = useCallback(() => {
|
||||
const willOpen = !sectionState.stocks.isOpen;
|
||||
dispatchSection({ type: 'TOGGLE', section: 'stocks' });
|
||||
|
||||
// 展开时加载行情数据(如果还没加载过)
|
||||
if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
|
||||
refreshQuotes();
|
||||
dispatchSection({ type: 'SET_QUOTES_LOADED' });
|
||||
}
|
||||
}, [sectionState.stocks, stocks.length, refreshQuotes]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
|
||||
}, []);
|
||||
|
||||
// 历史事件对比 - 数据已预加载,只需切换展开状态
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
dispatchSection({ type: 'TOGGLE', section: 'historical' });
|
||||
}, []);
|
||||
|
||||
// 传导链分析 - 展开时加载
|
||||
const handleTransmissionToggle = useCallback(() => {
|
||||
const willOpen = !sectionState.transmission.isOpen;
|
||||
dispatchSection({ type: 'TOGGLE', section: 'transmission' });
|
||||
|
||||
if (willOpen && !sectionState.transmission.hasLoaded) {
|
||||
loadChainAnalysis();
|
||||
dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
|
||||
}
|
||||
}, [sectionState.transmission, loadChainAnalysis]);
|
||||
|
||||
// 事件切换时重置所有子模块状态
|
||||
useEffect(() => {
|
||||
// 加载事件详情(增加浏览量)
|
||||
loadEventDetail();
|
||||
|
||||
// 加载自选股数据(用于判断股票是否已关注)
|
||||
loadWatchlistQuotes();
|
||||
|
||||
// 重置所有折叠区块状态
|
||||
dispatchSection({ type: 'RESET_ALL' });
|
||||
|
||||
// 相关股票默认展开,预加载股票列表和行情数据
|
||||
if (canAccessStocks) {
|
||||
loadStocksData();
|
||||
dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
|
||||
dispatchSection({ type: 'SET_QUOTES_LOADED' });
|
||||
}
|
||||
|
||||
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
|
||||
if (canAccessHistorical) {
|
||||
loadHistoricalData();
|
||||
dispatchSection({ type: 'SET_LOADED', section: 'historical' });
|
||||
}
|
||||
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
dispatch(toggleEventFollow(event.id));
|
||||
}, [dispatch, event?.id]);
|
||||
|
||||
// 切换自选股(使用 useWatchlist Hook)
|
||||
const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
|
||||
if (currentlyInWatchlist) {
|
||||
await handleRemoveFromWatchlist(stockCode);
|
||||
} else {
|
||||
await handleAddToWatchlist(stockCode, stockName);
|
||||
}
|
||||
}, [handleAddToWatchlist, handleRemoveFromWatchlist]);
|
||||
|
||||
// 空状态
|
||||
if (!event) {
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<Text color={textColor} textAlign="center">
|
||||
请选择一个事件查看详情
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody position="relative">
|
||||
{/* 无头部模式:显示右上角精简信息栏 */}
|
||||
{!showHeader && (
|
||||
<CompactMetaBar
|
||||
event={fullEventDetail || event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) - 可配置显示/隐藏 */}
|
||||
{showHeader && (
|
||||
<EventHeaderInfo
|
||||
event={fullEventDetail || event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 事件描述 */}
|
||||
<EventDescriptionSection description={event.description} />
|
||||
|
||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
|
||||
<CollapsibleSection
|
||||
title="相关股票"
|
||||
isOpen={sectionState.stocks.isOpen}
|
||||
onToggle={handleStocksToggle}
|
||||
count={stocks?.length || 0}
|
||||
subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessStocks}
|
||||
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
||||
showModeToggle={canAccessStocks}
|
||||
defaultMode="detailed"
|
||||
simpleContent={
|
||||
loading.stocks || loading.quotes ? (
|
||||
<Center py={2}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text ml={2} color={textColor} fontSize="sm">加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Wrap spacing={4}>
|
||||
{stocks?.map((stock, index) => (
|
||||
<WrapItem key={index}>
|
||||
<CompactStockItem
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" />
|
||||
<Text ml={2} color={textColor}>加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<RelatedStocksSection
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
|
||||
<RelatedConceptsSection
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
isOpen={sectionState.concepts.isOpen}
|
||||
onToggle={handleConceptsToggle}
|
||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessConcepts}
|
||||
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
|
||||
/>
|
||||
|
||||
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
<CollapsibleSection
|
||||
title="历史事件对比"
|
||||
isOpen={sectionState.historical.isOpen}
|
||||
onToggle={handleHistoricalToggle}
|
||||
count={historicalEvents?.length || 0}
|
||||
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessHistorical}
|
||||
onLockedClick={() => handleLockedClick('历史事件对比', 'pro')}
|
||||
>
|
||||
{loading.historicalEvents ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text ml={2} color={textColor} fontSize="sm">加载历史事件...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<HistoricalEvents
|
||||
events={historicalEvents || []}
|
||||
expectationScore={expectationScore}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
|
||||
<CollapsibleSection
|
||||
title="传导链分析"
|
||||
isOpen={sectionState.transmission.isOpen}
|
||||
onToggle={handleTransmissionToggle}
|
||||
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
|
||||
isLocked={!canAccessTransmission}
|
||||
onLockedClick={() => handleLockedClick('传导链分析', 'max')}
|
||||
>
|
||||
<TransmissionChainAnalysis
|
||||
eventId={event.id}
|
||||
eventService={eventService}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 讨论区(评论区) - 所有登录用户可用 */}
|
||||
<Box>
|
||||
<EventCommentSection eventId={event.id} />
|
||||
</Box>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
{/* 升级弹窗 */}
|
||||
{upgradeModal.isOpen ? (
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
onClose={handleCloseUpgradeModal}
|
||||
requiredLevel={upgradeModal.requiredLevel}
|
||||
featureName={upgradeModal.featureName}
|
||||
currentLevel={userTier}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicNewsDetailPanel;
|
||||
43
src/components/EventDetailPanel/EventDescriptionSection.js
Normal file
43
src/components/EventDetailPanel/EventDescriptionSection.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// src/components/EventDetailPanel/EventDescriptionSection.js
|
||||
// 事件描述区组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
|
||||
|
||||
/**
|
||||
* 事件描述区组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.description - 事件描述文本
|
||||
*/
|
||||
const EventDescriptionSection = ({ description }) => {
|
||||
const sectionBg = PROFESSIONAL_COLORS.background.secondary;
|
||||
const headingColor = PROFESSIONAL_COLORS.text.primary;
|
||||
const textColor = PROFESSIONAL_COLORS.text.secondary;
|
||||
|
||||
// 如果没有描述,不渲染
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 事件描述 */}
|
||||
<Box>
|
||||
<Heading size="sm" color={headingColor} mb={2}>
|
||||
事件描述
|
||||
</Heading>
|
||||
<Text fontSize="sm" color={textColor} lineHeight="tall">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDescriptionSection;
|
||||
142
src/components/EventDetailPanel/EventHeaderInfo.js
Normal file
142
src/components/EventDetailPanel/EventHeaderInfo.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// src/components/EventDetailPanel/EventHeaderInfo.js
|
||||
// 事件头部信息区组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
/**
|
||||
* 事件头部信息区组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {Object} props.importance - 重要性配置对象(包含 level, color 等)
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {Function} props.onToggleFollow - 切换关注回调
|
||||
*/
|
||||
const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 获取重要性文本
|
||||
const getImportanceText = () => {
|
||||
const levelMap = {
|
||||
'S': '极高',
|
||||
'A': '高',
|
||||
'B': '中',
|
||||
'C': '低'
|
||||
};
|
||||
return levelMap[importance.level] || '中';
|
||||
};
|
||||
|
||||
// 格式化涨跌幅数字
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md" position="relative">
|
||||
{/* 粉色圆角标签(左上角绝对定位) */}
|
||||
{event.related_avg_chg !== null && event.related_avg_chg !== undefined && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
left="-8px"
|
||||
bg="pink.500"
|
||||
color="white"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
boxShadow="md"
|
||||
zIndex={1}
|
||||
>
|
||||
{formatChange(event.related_avg_chg)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 第一行:标题 + 关注按钮 */}
|
||||
<Flex align="center" justify="space-between" mb={3} gap={4}>
|
||||
{/* 标题 */}
|
||||
<Heading size="md" color={headingColor} flex={1}>
|
||||
{event.title}
|
||||
</Heading>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={onToggleFollow}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</Flex>
|
||||
{/* 第二行:浏览数 + 日期 */}
|
||||
<Flex align="left" mb={3} gap={4}>
|
||||
{/* 浏览数 */}
|
||||
<HStack spacing={1}>
|
||||
<ViewIcon color="gray.400" boxSize={4} />
|
||||
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
|
||||
{(event.view_count || 0).toLocaleString()}次浏览
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 日期 */}
|
||||
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
|
||||
{dayjs(event.created_at).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:涨跌幅指标 + 重要性徽章 */}
|
||||
<HStack spacing={3} align="center">
|
||||
<Box maxW="500px">
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 重要性徽章 - 使用渐变色和图标 */}
|
||||
<Badge
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
bgGradient={
|
||||
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
|
||||
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
|
||||
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
|
||||
'linear(to-r, gray.500, gray.700)'
|
||||
}
|
||||
color="white"
|
||||
boxShadow="lg"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
>
|
||||
<Icon as={importance.icon} boxSize={5} />
|
||||
<Text>重要性:{getImportanceText()}</Text>
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventHeaderInfo;
|
||||
204
src/components/EventDetailPanel/MiniKLineChart.js
Normal file
204
src/components/EventDetailPanel/MiniKLineChart.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/components/EventDetailPanel/MiniKLineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
klineDataCache
|
||||
} from '@utils/stock/klineDataCache';
|
||||
|
||||
/**
|
||||
* 迷你K线图组件
|
||||
* 显示股票的K线走势(蜡烛图),支持事件时间标记
|
||||
*
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间(可选)
|
||||
* @param {Function} onClick - 点击回调(可选)
|
||||
* @param {Array} preloadedData - 预加载的K线数据(可选,由父组件批量加载后传入)
|
||||
* @param {boolean} loading - 外部加载状态(可选)
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const loadedRef = useRef(false);
|
||||
const dataFetchedRef = useRef(false);
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData([]);
|
||||
loadedRef.current = false;
|
||||
dataFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先使用预加载的数据(由父组件批量请求后传入)
|
||||
if (preloadedData !== undefined) {
|
||||
setData(preloadedData || []);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果外部正在加载,显示loading状态,不发起单独请求
|
||||
if (externalLoading) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataFetchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存(K线图使用 'daily' 类型)
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
if (cachedData !== undefined) {
|
||||
setData(cachedData || []);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
dataFetchedRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// 获取日K线数据(备用方案)
|
||||
fetchKlineData(stockCode, stableEventTime, 'daily')
|
||||
.then((result) => {
|
||||
if (mountedRef.current) {
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
});
|
||||
}, [stockCode, stableEventTime, preloadedData, externalLoading]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
// 提取K线数据 [open, close, low, high]
|
||||
const klineData = data
|
||||
.filter(item => item.open && item.close && item.low && item.high)
|
||||
.map(item => [item.open, item.close, item.low, item.high]);
|
||||
|
||||
// 日K线使用 date 字段
|
||||
const dates = data.map(item => item.date || item.time);
|
||||
const hasData = klineData.length > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return {
|
||||
title: {
|
||||
text: loading ? '加载中...' : '无数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { color: '#999', fontSize: 10 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 计算事件时间标记
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
|
||||
try {
|
||||
const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventIdx = dates.findIndex(d => {
|
||||
const dateStr = typeof d === 'object' ? dayjs(d).format('YYYY-MM-DD') : String(d);
|
||||
return dateStr.includes(eventDate);
|
||||
});
|
||||
|
||||
if (eventIdx >= 0) {
|
||||
eventMarkLineData.push({
|
||||
xAxis: eventIdx,
|
||||
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
|
||||
label: { show: false }
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略异常
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
show: false,
|
||||
boundaryGap: true
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false,
|
||||
scale: true
|
||||
},
|
||||
series: [{
|
||||
type: 'candlestick',
|
||||
data: klineData,
|
||||
itemStyle: {
|
||||
color: '#ef5350', // 涨(阳线)
|
||||
color0: '#26a69a', // 跌(阴线)
|
||||
borderColor: '#ef5350', // 涨(边框)
|
||||
borderColor0: '#26a69a' // 跌(边框)
|
||||
},
|
||||
barWidth: '60%',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { show: false },
|
||||
data: eventMarkLineData
|
||||
}
|
||||
}],
|
||||
tooltip: { show: false },
|
||||
animation: false
|
||||
};
|
||||
}, [data, loading, stableEventTime]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '35px',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
return prevProps.stockCode === nextProps.stockCode &&
|
||||
prevProps.eventTime === nextProps.eventTime &&
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.preloadedData === nextProps.preloadedData &&
|
||||
prevProps.loading === nextProps.loading;
|
||||
});
|
||||
|
||||
export default MiniKLineChart;
|
||||
94
src/components/EventDetailPanel/MiniLineChart.js
Normal file
94
src/components/EventDetailPanel/MiniLineChart.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// src/components/EventDetailPanel/MiniLineChart.js
|
||||
// Mini 折线图组件(用于股票卡片)
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* Mini 折线图组件
|
||||
* @param {Object} props
|
||||
* @param {Array<number>} props.data - 价格走势数据数组(15个数据点:前5+中5+后5)
|
||||
* @param {number} props.width - 图表宽度(默认180)
|
||||
* @param {number} props.height - 图表高度(默认60)
|
||||
*/
|
||||
const MiniLineChart = ({ data = [], width = 180, height = 60 }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算最大值和最小值,用于归一化
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1; // 防止除以0
|
||||
|
||||
// 将数据点转换为 SVG 路径坐标
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * width;
|
||||
const y = height - ((value - min) / range) * height;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
|
||||
// 构建 SVG 路径字符串
|
||||
const pathD = `M ${points.join(' L ')}`;
|
||||
|
||||
// 判断整体趋势(比较第一个和最后一个值)
|
||||
const isPositive = data[data.length - 1] >= data[0];
|
||||
const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌
|
||||
|
||||
// 创建渐变填充区域路径
|
||||
const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`;
|
||||
|
||||
return (
|
||||
<Box width={`${width}px`} height={`${height}px`}>
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={fillPathD}
|
||||
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
|
||||
/>
|
||||
|
||||
{/* 折线 */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 垂直分隔线(标记三个时间段) */}
|
||||
{/* 前一天和当天之间 */}
|
||||
<line
|
||||
x1={width / 3}
|
||||
y1={0}
|
||||
x2={width / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
|
||||
{/* 当天和后一天之间 */}
|
||||
<line
|
||||
x1={(width * 2) / 3}
|
||||
y1={0}
|
||||
x2={(width * 2) / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniLineChart;
|
||||
@@ -0,0 +1,85 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/ConceptStockItem.js
|
||||
// 概念股票列表项组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 概念股票列表项组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象(兼容新旧API格式)
|
||||
* - name / stock_name: 股票名称
|
||||
* - code / stock_code: 股票代码
|
||||
* - change_pct: 涨跌幅(可选)
|
||||
* - reason: 关联原因(可选)
|
||||
*/
|
||||
const ConceptStockItem = ({ stock }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 兼容新旧API格式
|
||||
const stockName = stock.name || stock.stock_name || '未知';
|
||||
const stockCode = stock.code || stock.stock_code || '';
|
||||
|
||||
// 涨跌幅可能不存在(v2 API不返回单个股票涨跌幅)
|
||||
const hasChangePct = stock.change_pct !== undefined && stock.change_pct !== null;
|
||||
const stockChangePct = hasChangePct ? parseFloat(stock.change_pct) : 0;
|
||||
const stockChangeColor = stockChangePct > 0 ? 'red' : stockChangePct < 0 ? 'green' : 'gray';
|
||||
const stockChangeSymbol = stockChangePct > 0 ? '+' : '';
|
||||
|
||||
// 处理股票详情跳转
|
||||
const handleStockClick = (e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡到概念卡片
|
||||
const cleanCode = stockCode.replace(/\.(SZ|SH)$/i, '');
|
||||
window.open(`https://valuefrontier.cn/company?scode=${cleanCode}`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={sectionBg}
|
||||
fontSize="xs"
|
||||
cursor="pointer"
|
||||
onClick={handleStockClick}
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.700'),
|
||||
transform: 'translateX(4px)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="semibold" color={conceptNameColor}>
|
||||
{stockName}
|
||||
</Text>
|
||||
<Badge size="sm" variant="outline">
|
||||
{stockCode}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{hasChangePct && (
|
||||
<Badge
|
||||
colorScheme={stockChangeColor}
|
||||
fontSize="xs"
|
||||
>
|
||||
{stockChangeSymbol}{stockChangePct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{stock.reason && (
|
||||
<Text fontSize="xs" color={stockCountColor} mt={1} noOfLines={2}>
|
||||
{stock.reason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptStockItem;
|
||||
@@ -0,0 +1,191 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/DetailedConceptCard.js
|
||||
// 详细概念卡片组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import ConceptStockItem from './ConceptStockItem';
|
||||
|
||||
/**
|
||||
* 详细概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象(兼容 v1/v2 API)
|
||||
* - concept: 概念名称
|
||||
* - stock_count: 相关股票数量
|
||||
* - score: 相关度(0-1)
|
||||
* - price_info.avg_change_pct: 平均涨跌幅
|
||||
* - description: 概念描述
|
||||
* - outbreak_dates / happened_times: 爆发日期数组
|
||||
* - stocks: 相关股票数组 [{ name/stock_name, code/stock_code }]
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
*/
|
||||
const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 计算相关度百分比
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
|
||||
// 计算涨跌幅颜色
|
||||
const changePct = parseFloat(concept.price_info?.avg_change_pct);
|
||||
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
|
||||
const changeSymbol = changePct > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderWidth="2px"
|
||||
cursor="pointer"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'xl',
|
||||
borderColor: 'blue.400'
|
||||
}}
|
||||
onClick={() => onClick(concept)}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{/* 头部信息 */}
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
{/* 左侧:概念名称 + Badge */}
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<Text fontSize="md" fontWeight="bold" color="blue.600">
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {relevanceScore}%
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅 */}
|
||||
{concept.price_info?.avg_change_pct && (
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color={stockCountColor} mb={1}>
|
||||
平均涨跌幅
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
colorScheme={changeColor}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{changeSymbol}{changePct.toFixed(2)}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 概念描述 */}
|
||||
{concept.description && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={stockCountColor}
|
||||
lineHeight="1.6"
|
||||
noOfLines={3}
|
||||
>
|
||||
{concept.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 爆发日期(兼容 happened_times 和 outbreak_dates) */}
|
||||
{((concept.outbreak_dates && concept.outbreak_dates.length > 0) ||
|
||||
(concept.happened_times && concept.happened_times.length > 0)) && (
|
||||
<Box>
|
||||
<Text fontSize="xs" fontWeight="semibold" mb={2} color={stockCountColor}>
|
||||
爆发日期:
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{(concept.outbreak_dates || concept.happened_times).map((date, idx) => (
|
||||
<Badge key={idx} variant="subtle" colorScheme="orange" fontSize="xs">
|
||||
{date}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 核心相关股票 */}
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontSize="sm" fontWeight="semibold" color={headingColor}>
|
||||
核心相关股票
|
||||
</Text>
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
共 {concept.stock_count} 只
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 可滚动容器 - 默认显示4条,可滚动查看全部 */}
|
||||
<Box
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
pr={2}
|
||||
onWheel={(e) => {
|
||||
const element = e.currentTarget;
|
||||
const scrollTop = element.scrollTop;
|
||||
const scrollHeight = element.scrollHeight;
|
||||
const clientHeight = element.clientHeight;
|
||||
|
||||
// 如果在滚动范围内,阻止事件冒泡到父容器
|
||||
if (
|
||||
(e.deltaY < 0 && scrollTop > 0) || // 向上滚动且未到顶部
|
||||
(e.deltaY > 0 && scrollTop + clientHeight < scrollHeight) // 向下滚动且未到底部
|
||||
) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
css={{
|
||||
overscrollBehavior: 'contain', // 防止滚动链
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f1f1',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#888',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: '#555',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1 }} spacing={2}>
|
||||
{concept.stocks.map((stock, idx) => (
|
||||
<ConceptStockItem key={idx} stock={stock} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailedConceptCard;
|
||||
@@ -0,0 +1,103 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/SimpleConceptCard.js
|
||||
// 简单概念卡片组件(横向卡片)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
VStack,
|
||||
Box,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 简单概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象
|
||||
* - name: 概念名称
|
||||
* - stock_count: 相关股票数量
|
||||
* - relevance: 相关度(0-100)
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
|
||||
*/
|
||||
const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
const relevanceColors = getRelevanceColor(relevanceScore);
|
||||
|
||||
// 涨跌幅数据
|
||||
const changePct = concept.price_info?.avg_change_pct ? parseFloat(concept.price_info.avg_change_pct) : null;
|
||||
const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null;
|
||||
const changeSymbol = changePct !== null && changePct > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
minW="100px"
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
onClick={() => onClick(concept)}
|
||||
>
|
||||
{/* 第一行:概念名 + 数量(允许折行) */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
color={conceptNameColor}
|
||||
wordBreak="break-word"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{concept.concept}{' '}
|
||||
<Text as="span" color="gray.500">
|
||||
({concept.stock_count})
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{/* 第二行:相关度 + 涨跌幅 */}
|
||||
<Flex justify="space-between" align="center" gap={1} flexWrap="wrap">
|
||||
{/* 相关度标签 */}
|
||||
<Box
|
||||
bg={relevanceColors.bg}
|
||||
color={relevanceColors.color}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 涨跌幅数据 */}
|
||||
{changePct !== null && (
|
||||
<Badge
|
||||
colorScheme={changeColor}
|
||||
fontSize="10px"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
flexShrink={0}
|
||||
>
|
||||
{changeSymbol}{changePct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleConceptCard;
|
||||
@@ -0,0 +1,41 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/TradingDateInfo.js
|
||||
// 交易日期信息提示组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 交易日期信息提示组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
*/
|
||||
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
if (!effectiveTradingDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack spacing={2}>
|
||||
<FaCalendarAlt color="gray" size={12} />
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
涨跌幅数据:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradingDateInfo;
|
||||
305
src/components/EventDetailPanel/RelatedConceptsSection/index.js
Normal file
305
src/components/EventDetailPanel/RelatedConceptsSection/index.js
Normal file
@@ -0,0 +1,305 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件(主组件)
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Button,
|
||||
Collapse,
|
||||
Heading,
|
||||
Center,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 相关概念区组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.eventTitle - 事件标题(用于搜索概念)
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
|
||||
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费)
|
||||
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
|
||||
*/
|
||||
const RelatedConceptsSection = ({
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
|
||||
onToggle = undefined // 新增:受控模式(外部控制展开回调)
|
||||
}) => {
|
||||
// 使用外部 isOpen,如果没有则使用内部 useState
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 颜色配置
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
console.log('[RelatedConceptsSection] 组件渲染', {
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
loading,
|
||||
conceptsCount: concepts?.length || 0,
|
||||
error
|
||||
});
|
||||
|
||||
// 搜索相关概念
|
||||
useEffect(() => {
|
||||
const searchConcepts = async () => {
|
||||
console.log('[RelatedConceptsSection] useEffect 触发', {
|
||||
eventTitle,
|
||||
effectiveTradingDate
|
||||
});
|
||||
|
||||
if (!eventTitle || !effectiveTradingDate) {
|
||||
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
|
||||
hasEventTitle: !!eventTitle,
|
||||
hasEffectiveTradingDate: !!effectiveTradingDate
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 格式化交易日期 - 统一使用 moment 处理
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
query: eventTitle,
|
||||
size: 5,
|
||||
page: 1,
|
||||
sort_by: "_score",
|
||||
trade_date: formattedTradeDate
|
||||
};
|
||||
|
||||
console.log('[RelatedConceptsSection] 发送请求', {
|
||||
url: '/concept-api/search',
|
||||
requestBody
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
|
||||
|
||||
const response = await fetch('/concept-api/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('[RelatedConceptsSection] 响应状态', {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[RelatedConceptsSection] 响应数据', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0,
|
||||
hasDataConcepts: !!(data.data && data.data.concepts),
|
||||
data: data
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '概念搜索响应', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0
|
||||
});
|
||||
|
||||
// 设置概念数据
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
|
||||
setConcepts(data.results);
|
||||
} else if (data.data && data.data.concepts) {
|
||||
// 向后兼容
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
|
||||
setConcepts(data.data.concepts);
|
||||
} else {
|
||||
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
|
||||
setConcepts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RelatedConceptsSection] 搜索概念失败', err);
|
||||
logger.error('RelatedConceptsSection', 'searchConcepts', err);
|
||||
setError('加载概念数据失败');
|
||||
setConcepts([]);
|
||||
} finally {
|
||||
console.log('[RelatedConceptsSection] 加载完成');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
searchConcepts();
|
||||
}, [eventTitle, effectiveTradingDate]);
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" mr={2} />
|
||||
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 判断是否有数据
|
||||
const hasNoConcepts = !concepts || concepts.length === 0;
|
||||
|
||||
/**
|
||||
* 根据相关度获取颜色(浅色背景 + 深色文字)
|
||||
* @param {number} relevance - 相关度(0-100)
|
||||
* @returns {Object} 包含背景色和文字色
|
||||
*/
|
||||
const getRelevanceColor = (relevance) => {
|
||||
if (relevance >= 90) {
|
||||
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
|
||||
} else if (relevance >= 80) {
|
||||
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
|
||||
} else if (relevance >= 70) {
|
||||
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
|
||||
} else {
|
||||
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理概念点击
|
||||
* @param {Object} concept - 概念对象
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念中心,并搜索该概念
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 标题栏 - 两行布局 */}
|
||||
<Box mb={3}>
|
||||
{/* 第一行:标题 + Badge + 按钮 */}
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
相关概念
|
||||
</Heading>
|
||||
{/* 订阅徽章 */}
|
||||
{subscriptionBadge}
|
||||
</Flex>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
onClick={() => {
|
||||
// 如果被锁定且有回调函数,触发付费弹窗
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (onToggle !== undefined) {
|
||||
// 受控模式:调用外部回调
|
||||
onToggle();
|
||||
} else {
|
||||
// 非受控模式:使用内部状态
|
||||
setInternalExpanded(!internalExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '收起' : '查看详细'}
|
||||
</Button>
|
||||
</Flex>
|
||||
{/* 第二行:交易日期信息 */}
|
||||
<TradingDateInfo
|
||||
effectiveTradingDate={effectiveTradingDate}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||||
{hasNoConcepts ? (
|
||||
<Box mb={isExpanded ? 3 : 0}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm">{error}</Text>
|
||||
) : (
|
||||
<Text color={textColor} fontSize="sm">暂无相关概念数据</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{hasNoConcepts ? (
|
||||
<Box py={4}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
|
||||
) : (
|
||||
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
/* 详细概念卡片网格 */
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedConceptsSection;
|
||||
182
src/components/EventDetailPanel/RelatedStocksSection.js
Normal file
182
src/components/EventDetailPanel/RelatedStocksSection.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// src/components/EventDetailPanel/RelatedStocksSection.js
|
||||
// 相关股票列表区组件(纯内容,不含标题)
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import dayjs from 'dayjs';
|
||||
import StockListItem from './StockListItem';
|
||||
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '@utils/stock/klineDataCache';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 相关股票列表区组件(纯内容部分)
|
||||
* 只负责渲染详细的股票列表,精简模式由外层 CollapsibleSection 的 simpleContent 提供
|
||||
* @param {Object} props
|
||||
* @param {Array<Object>} props.stocks - 股票数组
|
||||
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
|
||||
* @param {string} props.eventTime - 事件时间
|
||||
* @param {Function} props.isInWatchlist - 检查股票是否在自选股中的函数
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
*/
|
||||
const RelatedStocksSection = ({
|
||||
stocks,
|
||||
quotes = {},
|
||||
eventTime = null,
|
||||
isInWatchlist = () => false,
|
||||
onWatchlistToggle
|
||||
}) => {
|
||||
// 分时图数据状态:{ [stockCode]: data[] }
|
||||
const [timelineDataMap, setTimelineDataMap] = useState({});
|
||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||
|
||||
// 日K线数据状态:{ [stockCode]: data[] }
|
||||
const [dailyDataMap, setDailyDataMap] = useState({});
|
||||
const [dailyLoading, setDailyLoading] = useState(false);
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
// 稳定的股票列表 key
|
||||
const stocksKey = useMemo(() => {
|
||||
if (!stocks || stocks.length === 0) return '';
|
||||
return stocks.map(s => s.stock_code).sort().join(',');
|
||||
}, [stocks]);
|
||||
|
||||
// 计算分时图是否应该显示 loading
|
||||
const shouldShowTimelineLoading = useMemo(() => {
|
||||
if (!stocks || stocks.length === 0) return false;
|
||||
const currentDataKeys = Object.keys(timelineDataMap).sort().join(',');
|
||||
if (stocksKey !== currentDataKeys) {
|
||||
return true;
|
||||
}
|
||||
return timelineLoading;
|
||||
}, [stocks, stocksKey, timelineDataMap, timelineLoading]);
|
||||
|
||||
// 计算日K线是否应该显示 loading
|
||||
const shouldShowDailyLoading = useMemo(() => {
|
||||
if (!stocks || stocks.length === 0) return false;
|
||||
const currentDataKeys = Object.keys(dailyDataMap).sort().join(',');
|
||||
if (stocksKey !== currentDataKeys) {
|
||||
return true;
|
||||
}
|
||||
return dailyLoading;
|
||||
}, [stocks, stocksKey, dailyDataMap, dailyLoading]);
|
||||
|
||||
// 批量加载分时图数据
|
||||
useEffect(() => {
|
||||
if (!stocks || stocks.length === 0) {
|
||||
setTimelineDataMap({});
|
||||
setTimelineLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimelineLoading(true);
|
||||
const stockCodes = stocks.map(s => s.stock_code);
|
||||
|
||||
// 检查缓存
|
||||
const cachedData = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, stableEventTime, 'timeline');
|
||||
const cached = klineDataCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
cachedData[code] = cached;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(cachedData).length === stockCodes.length) {
|
||||
setTimelineDataMap(cachedData);
|
||||
setTimelineLoading(false);
|
||||
logger.debug('RelatedStocksSection', '分时图数据全部来自缓存', { stockCount: stockCodes.length });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('RelatedStocksSection', '批量加载分时图数据', {
|
||||
totalCount: stockCodes.length,
|
||||
eventTime: stableEventTime
|
||||
});
|
||||
|
||||
fetchBatchKlineData(stockCodes, stableEventTime, 'timeline')
|
||||
.then((batchData) => {
|
||||
setTimelineDataMap({ ...cachedData, ...batchData });
|
||||
setTimelineLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('RelatedStocksSection', '批量加载分时图数据失败', error);
|
||||
setTimelineDataMap(cachedData);
|
||||
setTimelineLoading(false);
|
||||
});
|
||||
}, [stocksKey, stableEventTime]);
|
||||
|
||||
// 批量加载日K线数据
|
||||
useEffect(() => {
|
||||
if (!stocks || stocks.length === 0) {
|
||||
setDailyDataMap({});
|
||||
setDailyLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDailyLoading(true);
|
||||
const stockCodes = stocks.map(s => s.stock_code);
|
||||
|
||||
// 检查缓存
|
||||
const cachedData = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, stableEventTime, 'daily');
|
||||
const cached = klineDataCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
cachedData[code] = cached;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(cachedData).length === stockCodes.length) {
|
||||
setDailyDataMap(cachedData);
|
||||
setDailyLoading(false);
|
||||
logger.debug('RelatedStocksSection', '日K线数据全部来自缓存', { stockCount: stockCodes.length });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('RelatedStocksSection', '批量加载日K线数据', {
|
||||
totalCount: stockCodes.length,
|
||||
eventTime: stableEventTime
|
||||
});
|
||||
|
||||
fetchBatchKlineData(stockCodes, stableEventTime, 'daily')
|
||||
.then((batchData) => {
|
||||
setDailyDataMap({ ...cachedData, ...batchData });
|
||||
setDailyLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('RelatedStocksSection', '批量加载日K线数据失败', error);
|
||||
setDailyDataMap(cachedData);
|
||||
setDailyLoading(false);
|
||||
});
|
||||
}, [stocksKey, stableEventTime]);
|
||||
|
||||
// 如果没有股票数据,不渲染
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{stocks.map((stock, index) => (
|
||||
<StockListItem
|
||||
key={index}
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
eventTime={eventTime}
|
||||
isInWatchlist={isInWatchlist(stock.stock_code)}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
timelineData={timelineDataMap[stock.stock_code]}
|
||||
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}
|
||||
dailyData={dailyDataMap[stock.stock_code]}
|
||||
dailyLoading={shouldShowDailyLoading && !dailyDataMap[stock.stock_code]}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedStocksSection;
|
||||
477
src/components/EventDetailPanel/StockListItem.js
Normal file
477
src/components/EventDetailPanel/StockListItem.js
Normal file
@@ -0,0 +1,477 @@
|
||||
// src/components/EventDetailPanel/StockListItem.js
|
||||
// 股票卡片组件(融合表格功能的卡片样式)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { StarIcon } from '@chakra-ui/icons';
|
||||
import { Tag } from 'antd';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
import MiniTimelineChart from '@views/Community/components/StockDetailPanel/components/MiniTimelineChart';
|
||||
import MiniKLineChart from './MiniKLineChart';
|
||||
import TimelineChartModal from '@components/StockChart/TimelineChartModal';
|
||||
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
||||
import { getChangeColor } from '@utils/colorUtils';
|
||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||
|
||||
/**
|
||||
* 股票卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* @param {string} props.stock.stock_name - 股票名称
|
||||
* @param {string} props.stock.stock_code - 股票代码
|
||||
* @param {string} props.stock.relation_desc - 关联描述
|
||||
* @param {Object} props.quote - 股票行情数据(可选)
|
||||
* @param {number} props.quote.change - 涨跌幅
|
||||
* @param {string} props.eventTime - 事件时间(可选)
|
||||
* @param {boolean} props.isInWatchlist - 是否在自选股中
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
* @param {Array} props.timelineData - 预加载的分时图数据(可选,由父组件批量加载后传入)
|
||||
* @param {boolean} props.timelineLoading - 分时图数据加载状态
|
||||
* @param {Array} props.dailyData - 预加载的日K线数据(可选,由父组件批量加载后传入)
|
||||
* @param {boolean} props.dailyLoading - 日K线数据加载状态
|
||||
*/
|
||||
const StockListItem = ({
|
||||
stock,
|
||||
quote = null,
|
||||
eventTime = null,
|
||||
isInWatchlist = false,
|
||||
onWatchlistToggle,
|
||||
timelineData,
|
||||
timelineLoading = false,
|
||||
dailyData,
|
||||
dailyLoading = false
|
||||
}) => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||
const borderColor = PROFESSIONAL_COLORS.border.default;
|
||||
const codeColor = '#3B82F6';
|
||||
const nameColor = PROFESSIONAL_COLORS.text.primary;
|
||||
const descColor = PROFESSIONAL_COLORS.text.secondary;
|
||||
const dividerColor = PROFESSIONAL_COLORS.border.default;
|
||||
|
||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||
const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false);
|
||||
const [isKLineModalOpen, setIsKLineModalOpen] = useState(false);
|
||||
|
||||
const handleViewDetail = () => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
};
|
||||
|
||||
const handleWatchlistClick = (e) => {
|
||||
e.stopPropagation();
|
||||
onWatchlistToggle?.(stock.stock_code, stock.stock_name, isInWatchlist);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined || isNaN(value)) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 使用工具函数获取涨跌幅颜色(已从 colorUtils 导入)
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
|
||||
// 处理关联描述
|
||||
const getRelationDesc = () => {
|
||||
const relationDesc = stock.relation_desc;
|
||||
|
||||
if (!relationDesc) return '--';
|
||||
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
|
||||
return relationDesc.data
|
||||
.map(item => item.query_part || item.sentences || '')
|
||||
.filter(s => s)
|
||||
.join(';') || '--';
|
||||
}
|
||||
|
||||
return '--';
|
||||
};
|
||||
|
||||
const relationText = getRelationDesc();
|
||||
const maxLength = 50; // 收缩时显示的最大字符数
|
||||
const needTruncate = relationText && relationText !== '--' && relationText.length > maxLength;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
position="relative"
|
||||
overflow="visible"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
|
||||
borderTopLeftRadius: 'lg',
|
||||
borderTopRightRadius: 'lg',
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: 'lg',
|
||||
borderColor: 'blue.300',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
|
||||
<HStack spacing={2} align="center" flexWrap={isMobile ? 'wrap' : 'nowrap'}>
|
||||
{/* 左侧:股票信息区 */}
|
||||
<HStack spacing={2} overflow="hidden">
|
||||
{/* 股票代码 + 名称 + 涨跌幅 */}
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
minW="95px"
|
||||
maxW="110px"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Tooltip
|
||||
label="点击查看股票详情"
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<VStack spacing={0} align="stretch">
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={codeColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
onClick={handleViewDetail}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={nameColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
onClick={handleViewDetail}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Tooltip>
|
||||
<HStack spacing={1} align="center">
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(change)}
|
||||
>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
{onWatchlistToggle && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
|
||||
icon={<StarIcon color={isInWatchlist ? undefined : 'gray.400'} />}
|
||||
onClick={handleWatchlistClick}
|
||||
aria-label={isInWatchlist ? '已关注' : '加自选'}
|
||||
borderRadius="full"
|
||||
borderColor={isInWatchlist ? undefined : 'gray.300'}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 分时图 - 自适应 */}
|
||||
<VStack
|
||||
flex={1}
|
||||
minW="80px"
|
||||
maxW="150px"
|
||||
borderWidth="1px"
|
||||
borderColor="rgba(59, 130, 246, 0.3)"
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1.5}
|
||||
bg="rgba(59, 130, 246, 0.1)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsTimelineModalOpen(true);
|
||||
}}
|
||||
cursor="pointer"
|
||||
align="stretch"
|
||||
spacing={0}
|
||||
_hover={{
|
||||
borderColor: '#3B82F6',
|
||||
boxShadow: '0 0 10px rgba(59, 130, 246, 0.3)',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="10px"
|
||||
color="#3B82F6"
|
||||
fontWeight="semibold"
|
||||
whiteSpace="nowrap"
|
||||
mb={0.5}
|
||||
>
|
||||
📈 分时
|
||||
</Text>
|
||||
<Box h="28px">
|
||||
<MiniTimelineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
preloadedData={timelineData}
|
||||
loading={timelineLoading}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* K线图 - 自适应 */}
|
||||
<VStack
|
||||
flex={1}
|
||||
minW="80px"
|
||||
maxW="150px"
|
||||
borderWidth="1px"
|
||||
borderColor="rgba(168, 85, 247, 0.3)"
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1.5}
|
||||
bg="rgba(168, 85, 247, 0.1)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsKLineModalOpen(true);
|
||||
}}
|
||||
cursor="pointer"
|
||||
align="stretch"
|
||||
spacing={0}
|
||||
_hover={{
|
||||
borderColor: '#A855F7',
|
||||
boxShadow: '0 0 10px rgba(168, 85, 247, 0.3)',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="10px"
|
||||
color="#A855F7"
|
||||
fontWeight="semibold"
|
||||
whiteSpace="nowrap"
|
||||
mb={0.5}
|
||||
>
|
||||
📊 日线
|
||||
</Text>
|
||||
<Box h="28px">
|
||||
<MiniKLineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
preloadedData={dailyData}
|
||||
loading={dailyLoading}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 关联描述 - 升级和降级处理 */}
|
||||
{stock.relation_desc && (
|
||||
<Box flex={1} minW={0} flexBasis={isMobile ? '100%' : ''}>
|
||||
{Array.isArray(stock.relation_desc?.data) ? (
|
||||
// 升级:带引用来源的版本 - 添加折叠功能
|
||||
<Tooltip
|
||||
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="rgba(20, 20, 20, 0.95)"
|
||||
color={PROFESSIONAL_COLORS.gold[500]}
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
cursor="pointer"
|
||||
bg={PROFESSIONAL_COLORS.background.secondary}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
bg: PROFESSIONAL_COLORS.background.cardHover,
|
||||
}}
|
||||
transition="background 0.2s"
|
||||
position="relative"
|
||||
>
|
||||
<Collapse in={isDescExpanded} startingHeight={56}>
|
||||
{/* AI 标识 - 行内显示在文字前面 */}
|
||||
<Tag
|
||||
icon={<RobotOutlined />}
|
||||
color="purple"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '2px 8px',
|
||||
marginRight: 8,
|
||||
verticalAlign: 'middle',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
AI合成
|
||||
</Tag>
|
||||
{/* 渲染 query_part,每句带来源悬停提示 */}
|
||||
<Text
|
||||
as="span"
|
||||
fontSize="sm"
|
||||
color={PROFESSIONAL_COLORS.text.primary}
|
||||
lineHeight="1.8"
|
||||
>
|
||||
{Array.isArray(stock.relation_desc?.data) && stock.relation_desc.data.filter(item => item.query_part).map((item, index, arr) => (
|
||||
<React.Fragment key={index}>
|
||||
<Tooltip
|
||||
label={
|
||||
<Box maxW="400px" p={2}>
|
||||
{item.sentences && (
|
||||
<Text fontSize="xs" mb={2} whiteSpace="pre-wrap">
|
||||
{item.sentences}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="xs" color="gray.300" mt={1}>
|
||||
来源:{item.organization || '未知'}{item.author ? ` / ${item.author}` : ''}
|
||||
</Text>
|
||||
{item.report_title && (
|
||||
<Text fontSize="xs" color="gray.300" noOfLines={2}>
|
||||
{item.report_title}
|
||||
</Text>
|
||||
)}
|
||||
{item.declare_date && (
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{new Date(item.declare_date).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="rgba(20, 20, 20, 0.95)"
|
||||
color="white"
|
||||
maxW="420px"
|
||||
>
|
||||
<Text
|
||||
as="span"
|
||||
cursor="help"
|
||||
borderBottom="1px dashed"
|
||||
borderBottomColor="gray.400"
|
||||
_hover={{
|
||||
color: PROFESSIONAL_COLORS.gold[500],
|
||||
borderBottomColor: PROFESSIONAL_COLORS.gold[500],
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{item.query_part}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{index < arr.length - 1 && ';'}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// 降级:纯文本版本(保留展开/收起功能)
|
||||
<Tooltip
|
||||
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="rgba(20, 20, 20, 0.95)"
|
||||
color={PROFESSIONAL_COLORS.gold[500]}
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
cursor="pointer"
|
||||
bg={PROFESSIONAL_COLORS.background.secondary}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
bg: PROFESSIONAL_COLORS.background.cardHover,
|
||||
}}
|
||||
transition="background 0.2s"
|
||||
position="relative"
|
||||
>
|
||||
{/* 去掉"关联描述"标题 */}
|
||||
<Collapse in={isDescExpanded} startingHeight={56}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={nameColor}
|
||||
lineHeight="1.5"
|
||||
>
|
||||
{relationText}
|
||||
</Text>
|
||||
</Collapse>
|
||||
|
||||
{/* 提示信息 */}
|
||||
{isDescExpanded && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={2}
|
||||
fontStyle="italic"
|
||||
>
|
||||
⚠️ AI生成,仅供参考
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 分时图弹窗 */}
|
||||
{isTimelineModalOpen && (
|
||||
<TimelineChartModal
|
||||
isOpen={isTimelineModalOpen}
|
||||
onClose={() => setIsTimelineModalOpen(false)}
|
||||
stock={stock}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* K线图弹窗 */}
|
||||
{isKLineModalOpen && (
|
||||
<KLineChartModal
|
||||
isOpen={isKLineModalOpen}
|
||||
onClose={() => setIsKLineModalOpen(false)}
|
||||
stock={stock}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockListItem;
|
||||
6
src/components/EventDetailPanel/index.js
Normal file
6
src/components/EventDetailPanel/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/components/EventDetailPanel/index.js
|
||||
// 统一导出事件详情面板组件
|
||||
|
||||
export { default } from './DynamicNewsDetailPanel';
|
||||
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';
|
||||
export { default as EventDetailPanel } from './DynamicNewsDetailPanel';
|
||||
Reference in New Issue
Block a user