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:
@@ -34,7 +34,7 @@ import { useNotification } from '../../../contexts/NotificationContext';
|
||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
|
||||
import PaginationControl from './DynamicNewsCard/PaginationControl';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
|
||||
import CompactSearchBox from './CompactSearchBox';
|
||||
import {
|
||||
fetchDynamicNews,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,125 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,100 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,116 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,424 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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 '../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;
|
||||
@@ -1,43 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,142 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,204 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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 '../StockDetailPanel/utils/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;
|
||||
@@ -1,94 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,85 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,191 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,103 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,41 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,305 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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;
|
||||
@@ -1,182 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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 '../StockDetailPanel/utils/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;
|
||||
@@ -1,477 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/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 '../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;
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/index.js
|
||||
// 统一导出 DynamicNewsDetailPanel 组件
|
||||
|
||||
export { default } from './DynamicNewsDetailPanel';
|
||||
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';
|
||||
@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
|
||||
import { Drawer } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
|
||||
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
|
||||
import './EventDetailModal.less';
|
||||
|
||||
interface EventDetailModalProps {
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
/* --- 全局与通用增强 --- */
|
||||
|
||||
/* 为交互元素增加平滑的过渡效果,提升用户体验 */
|
||||
.ant-btn, .ant-tag, .ant-picker-cell {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* --- 投资日历卡片与日历本身 --- */
|
||||
|
||||
.investment-calendar .ant-card-body {
|
||||
/* 为紧凑型日历减少内边距,使其不那么空旷 */
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-calendar-date {
|
||||
position: relative; /* 保持相对定位,为角标提供定位锚点 */
|
||||
}
|
||||
|
||||
/* 日历单元格上的事件指示器 */
|
||||
.investment-calendar .calendar-events {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
pointer-events: none; /* 避免阻止点击事件 */
|
||||
}
|
||||
|
||||
.investment-calendar .event-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.investment-calendar .event-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.investment-calendar .event-count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8);
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 当事件数量很多时,调整样式 */
|
||||
.investment-calendar .event-count.many-events {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 8px;
|
||||
padding: 1px 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* 响应式设计 - 在小屏幕上调整指示器大小 */
|
||||
@media (max-width: 768px) {
|
||||
.investment-calendar .event-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.investment-calendar .event-count {
|
||||
font-size: 9px;
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell-in-view .ant-picker-calendar-date {
|
||||
min-height: 28px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 增强可访问性 - 为有事件的日期添加更明显的视觉提示 */
|
||||
.investment-calendar .ant-picker-cell-in-view:has(.calendar-events) .ant-picker-calendar-date {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell-in-view:has(.calendar-events) .ant-picker-calendar-date::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 1px solid rgba(24, 144, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 优化日历单元格的布局和显示 */
|
||||
.investment-calendar .ant-picker-cell-in-view .ant-picker-calendar-date {
|
||||
transition: background-color 0.3s;
|
||||
min-height: 32px; /* 确保有足够空间显示日期和事件指示器 */
|
||||
display: flex;
|
||||
align-items: flex-start; /* 日期数字置顶显示 */
|
||||
justify-content: flex-start;
|
||||
padding: 4px 6px; /* 适当的内边距 */
|
||||
}
|
||||
|
||||
/* 日期数字样式优化 */
|
||||
.investment-calendar .ant-picker-calendar-date-value {
|
||||
font-weight: 500;
|
||||
z-index: 1; /* 确保日期数字在最上层 */
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell-selected .ant-picker-calendar-date {
|
||||
background-color: #e6f7ff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1890ff;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell:hover .ant-picker-calendar-date {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 有事件的日期单元格特殊样式 */
|
||||
.investment-calendar .ant-picker-cell-in-view .ant-picker-calendar-date:has(.calendar-events) {
|
||||
background-color: rgba(24, 144, 255, 0.05); /* 淡蓝色背景提示有事件 */
|
||||
}
|
||||
|
||||
/* 突出显示“今天”的单元格 */
|
||||
.investment-calendar .ant-picker-calendar-date-today {
|
||||
border: 1px solid #1890ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
/* --- 事件与股票弹窗 (Modal) --- */
|
||||
|
||||
/* 为弹窗内的Tabs内容区增加一些上边距 */
|
||||
.ant-modal-body .ant-tabs-content-holder {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* --- 表格 (Table) 统一样式 --- */
|
||||
|
||||
/* 为表格行增加悬停背景色,提供清晰的视觉反馈 */
|
||||
.ant-table-tbody > tr.ant-table-row:hover > td {
|
||||
background: #fafafa !important;
|
||||
}
|
||||
|
||||
/* 默认将所有单元格内容垂直居中,使表格看起来更整洁 */
|
||||
.ant-table-cell {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
/* --- 事件表格列的特定样式 --- */
|
||||
|
||||
/* 将“重要度”列的表头和内容都居中对齐 */
|
||||
.ant-table-thead th:nth-child(2).ant-table-cell,
|
||||
.ant-table-tbody td:nth-child(2).ant-table-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- 股票表格列的特定样式 --- */
|
||||
|
||||
/* 美化“相关度”进度条 */
|
||||
.ant-table-tbody td:nth-child(6) > .ant-tooltip > div { /* 进度条外层容器 */
|
||||
height: 16px !important;
|
||||
background: #f0f2f5 !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden; /* 确保内层进度条圆角不溢出 */
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.ant-table-tbody td:nth-child(6) .ant-tooltip > div > div { /* 进度条内层填充 */
|
||||
border-radius: 8px !important;
|
||||
/* 增加渐变和细微阴影,使其更具质感 */
|
||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-size: 40px 40px;
|
||||
box-shadow: inset 0 -1px 1px rgba(0, 0, 0, 0.1);
|
||||
/* 让宽度变化的动画更平滑 */
|
||||
transition: width 0.5s cubic-bezier(0.23, 1, 0.32, 1) !important;
|
||||
}
|
||||
|
||||
/* 将“K线图”操作列居中 */
|
||||
.ant-table-thead th:nth-child(7).ant-table-cell,
|
||||
.ant-table-tbody td:nth-child(7).ant-table-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/* --- 内容详情抽屉 (Drawer) --- */
|
||||
/* (您提供的Markdown样式已经很完善,这里保留并整合) */
|
||||
|
||||
.markdown-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.markdown-content h1 { font-size: 24px; }
|
||||
.markdown-content h2 { font-size: 20px; border-bottom: 1px solid #eee; padding-bottom: 8px; }
|
||||
.markdown-content h3 { font-size: 18px; }
|
||||
.markdown-content h4 { font-size: 16px; }
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
padding-left: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
padding: 12px 20px;
|
||||
margin: 16px 0;
|
||||
border-left: 4px solid #1890ff;
|
||||
background-color: #f6f8fa;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 3px 6px;
|
||||
margin: 0 2px;
|
||||
font-size: 13px;
|
||||
background-color: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
background-color: #2d2d2d; /* 暗色背景代码块 */
|
||||
color: #f8f8f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f6f8fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,975 +0,0 @@
|
||||
// src/views/Community/components/InvestmentCalendar.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
|
||||
Drawer, Typography, Divider, Space, Tooltip, message, Alert
|
||||
} from 'antd';
|
||||
import {
|
||||
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
|
||||
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
|
||||
import { useSubscription } from '../../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
|
||||
import CitationMark from '../../../components/Citation/CitationMark';
|
||||
import CitedContent from '../../../components/Citation/CitedContent';
|
||||
import { processCitationData } from '../../../utils/citationUtils';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
const InvestmentCalendar = () => {
|
||||
// 权限控制
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
|
||||
const [eventCounts, setEventCounts] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs());
|
||||
|
||||
// 新增状态
|
||||
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
|
||||
const [selectedDetail, setSelectedDetail] = useState(null);
|
||||
const [stockModalVisible, setStockModalVisible] = useState(false);
|
||||
const [selectedStocks, setSelectedStocks] = useState([]);
|
||||
const [stockQuotes, setStockQuotes] = useState({});
|
||||
const [klineModalVisible, setKlineModalVisible] = useState(false);
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
const [selectedEventTime, setSelectedEventTime] = useState(null); // 记录事件时间
|
||||
const [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表
|
||||
const [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码
|
||||
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
|
||||
|
||||
// 加载月度事件统计
|
||||
const loadEventCounts = useCallback(async (date) => {
|
||||
try {
|
||||
const year = date.year();
|
||||
const month = date.month() + 1;
|
||||
const response = await eventService.calendar.getEventCounts(year, month);
|
||||
|
||||
if (response.success) {
|
||||
setEventCounts(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'loadEventCounts', error, {
|
||||
year: date.year(),
|
||||
month: date.month() + 1
|
||||
});
|
||||
}
|
||||
}, []); // eventService 是外部导入的稳定引用,不需要作为依赖
|
||||
|
||||
// 加载指定日期的事件
|
||||
const loadDateEvents = async (date) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const response = await eventService.calendar.getEventsForDate(dateStr);
|
||||
|
||||
if (response.success) {
|
||||
setSelectedDateEvents(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'loadDateEvents', error, {
|
||||
dateStr: date.format('YYYY-MM-DD')
|
||||
});
|
||||
setSelectedDateEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取六位股票代码(去掉后缀)
|
||||
const getSixDigitCode = (code) => {
|
||||
if (!code) return code;
|
||||
// 如果有.SH或.SZ后缀,去掉
|
||||
return code.split('.')[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 归一化股票数据格式
|
||||
* 支持两种格式:
|
||||
* 1. 旧格式数组:[code, name, description, score]
|
||||
* 2. 新格式对象:{ code, name, description, score, report }
|
||||
* 返回统一的对象格式
|
||||
*/
|
||||
const normalizeStock = (stock) => {
|
||||
if (!stock) return null;
|
||||
|
||||
// 新格式:对象
|
||||
if (typeof stock === 'object' && !Array.isArray(stock)) {
|
||||
return {
|
||||
code: stock.code || '',
|
||||
name: stock.name || '',
|
||||
description: stock.description || '',
|
||||
score: stock.score || 0,
|
||||
report: stock.report || null // 研报引用信息
|
||||
};
|
||||
}
|
||||
|
||||
// 旧格式:数组 [code, name, description, score]
|
||||
if (Array.isArray(stock)) {
|
||||
return {
|
||||
code: stock[0] || '',
|
||||
name: stock[1] || '',
|
||||
description: stock[2] || '',
|
||||
score: stock[3] || 0,
|
||||
report: null
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 归一化股票列表
|
||||
*/
|
||||
const normalizeStocks = (stocks) => {
|
||||
if (!stocks || !Array.isArray(stocks)) return [];
|
||||
return stocks.map(normalizeStock).filter(Boolean);
|
||||
};
|
||||
|
||||
// 加载股票行情
|
||||
const loadStockQuotes = async (stocks, eventTime) => {
|
||||
try {
|
||||
const normalizedStocks = normalizeStocks(stocks);
|
||||
const codes = normalizedStocks.map(stock => getSixDigitCode(stock.code));
|
||||
const quotes = {};
|
||||
|
||||
// 使用市场API获取最新行情数据
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const code = codes[i];
|
||||
const originalCode = normalizedStocks[i].code; // 使用归一化后的代码作为key
|
||||
try {
|
||||
const response = await fetch(`/api/market/trade/${code}?days=1`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
const latest = data.data[data.data.length - 1]; // 最新数据
|
||||
quotes[originalCode] = {
|
||||
price: latest.close,
|
||||
change: latest.change_amount,
|
||||
changePercent: latest.change_percent
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('InvestmentCalendar', 'loadStockQuotes.fetchQuote', err, { code });
|
||||
}
|
||||
}
|
||||
|
||||
setStockQuotes(quotes);
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'loadStockQuotes', error, {
|
||||
stockCount: stocks.length
|
||||
});
|
||||
message.error('加载股票行情失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEventCounts(currentMonth);
|
||||
}, [currentMonth, loadEventCounts]);
|
||||
|
||||
// 自定义日期单元格渲染(Ant Design 5.x API)
|
||||
const cellRender = (current, info) => {
|
||||
// 只处理日期单元格,月份单元格返回默认
|
||||
if (info.type !== 'date') return info.originNode;
|
||||
|
||||
const dateStr = current.format('YYYY-MM-DD');
|
||||
const dayEvents = eventCounts.find(item => item.date === dateStr);
|
||||
|
||||
if (dayEvents && dayEvents.count > 0) {
|
||||
return (
|
||||
<div className="calendar-events">
|
||||
{/* 使用小圆点指示器,不遮挡日期数字 */}
|
||||
<div className="event-indicators">
|
||||
<div
|
||||
className="event-dot"
|
||||
style={{ backgroundColor: getEventCountColor(dayEvents.count) }}
|
||||
title={`${dayEvents.count}个事件`}
|
||||
/>
|
||||
<span
|
||||
className={`event-count ${dayEvents.count >= 10 ? 'many-events' : ''}`}
|
||||
style={{ color: getEventCountColor(dayEvents.count) }}
|
||||
>
|
||||
{dayEvents.count > 99 ? '99+' : dayEvents.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 根据事件数量获取颜色 - 更丰富的渐进色彩
|
||||
const getEventCountColor = (count) => {
|
||||
if (count >= 15) return '#f5222d'; // 深红色 - 非常多
|
||||
if (count >= 10) return '#fa541c'; // 橙红色 - 很多
|
||||
if (count >= 8) return '#fa8c16'; // 橙色 - 较多
|
||||
if (count >= 5) return '#faad14'; // 金黄色 - 中等
|
||||
if (count >= 3) return '#52c41a'; // 绿色 - 少量
|
||||
return '#1890ff'; // 蓝色 - 很少
|
||||
};
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateSelect = (value) => {
|
||||
setSelectedDate(value);
|
||||
loadDateEvents(value);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 渲染重要性星级
|
||||
const renderStars = (star) => {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(
|
||||
<StarFilled
|
||||
key={i}
|
||||
style={{
|
||||
color: i <= star ? '#faad14' : '#d9d9d9',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>{stars}</span>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示内容详情
|
||||
* 支持两种数据格式:
|
||||
* 1. 字符串格式:直接显示文本,自动添加"(AI合成)"标识
|
||||
* 例如:showContentDetail("这是事件背景内容", "事件背景")
|
||||
*
|
||||
* 2. 引用格式:使用CitedContent组件渲染,显示引用来源
|
||||
* 例如:showContentDetail({
|
||||
* data: [
|
||||
* { sentence: "第一句话", citation: { source: "来源1", url: "..." } },
|
||||
* { sentence: "第二句话", citation: { source: "来源2", url: "..." } }
|
||||
* ]
|
||||
* }, "事件背景")
|
||||
*
|
||||
* 后端API返回数据格式说明:
|
||||
* - 字符串格式:former字段直接返回字符串
|
||||
* - 引用格式:former字段返回 { data: [...] } 对象,其中data是引用数组
|
||||
*/
|
||||
const showContentDetail = (content, title) => {
|
||||
let processedContent;
|
||||
|
||||
// 判断content类型:字符串或引用格式
|
||||
if (typeof content === 'string') {
|
||||
// 字符串类型:添加AI合成标识
|
||||
processedContent = {
|
||||
type: 'text',
|
||||
content: content + (content ? '\n\n(AI合成)' : '')
|
||||
};
|
||||
} else if (content && content.data && Array.isArray(content.data)) {
|
||||
// 引用格式:使用CitedContent渲染
|
||||
processedContent = {
|
||||
type: 'citation',
|
||||
content: content
|
||||
};
|
||||
} else {
|
||||
// 其他情况:转为字符串并添加AI标识
|
||||
processedContent = {
|
||||
type: 'text',
|
||||
content: String(content || '') + '\n\n(AI合成)'
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedDetail({ content: processedContent, title });
|
||||
setDetailDrawerVisible(true);
|
||||
};
|
||||
|
||||
// 显示相关股票
|
||||
const showRelatedStocks = (stocks, eventTime) => {
|
||||
// 检查权限
|
||||
if (!hasFeatureAccess('related_stocks')) {
|
||||
setUpgradeModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stocks || stocks.length === 0) {
|
||||
message.info('暂无相关股票');
|
||||
return;
|
||||
}
|
||||
|
||||
// 归一化数据后按相关度排序(降序)
|
||||
const normalizedList = normalizeStocks(stocks);
|
||||
const sortedStocks = normalizedList.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
setSelectedStocks(sortedStocks);
|
||||
setStockModalVisible(true);
|
||||
loadStockQuotes(stocks, eventTime); // 传原始数据给 loadStockQuotes,它内部会归一化
|
||||
};
|
||||
|
||||
// 添加交易所后缀
|
||||
const addExchangeSuffix = (code) => {
|
||||
const sixDigitCode = getSixDigitCode(code);
|
||||
// 如果已有后缀,直接返回
|
||||
if (code.includes('.')) return code;
|
||||
|
||||
// 根据股票代码规则添加后缀
|
||||
if (sixDigitCode.startsWith('6')) {
|
||||
return `${sixDigitCode}.SH`; // 上海
|
||||
} else if (sixDigitCode.startsWith('0') || sixDigitCode.startsWith('3')) {
|
||||
return `${sixDigitCode}.SZ`; // 深圳
|
||||
} else if (sixDigitCode.startsWith('688')) {
|
||||
return `${sixDigitCode}.SH`; // 科创板
|
||||
}
|
||||
return sixDigitCode;
|
||||
};
|
||||
|
||||
// 显示K线图(支持新旧格式)
|
||||
const showKline = (stock) => {
|
||||
// 兼容新旧格式
|
||||
const code = stock.code || stock[0];
|
||||
const name = stock.name || stock[1];
|
||||
const stockCode = addExchangeSuffix(code);
|
||||
|
||||
// 将 selectedDate 转换为 YYYY-MM-DD 格式(日K线只需要日期,不需要时间)
|
||||
const formattedEventTime = selectedDate ? selectedDate.format('YYYY-MM-DD') : null;
|
||||
|
||||
console.log('[InvestmentCalendar] 打开K线图:', {
|
||||
originalCode: code,
|
||||
processedCode: stockCode,
|
||||
stockName: name,
|
||||
selectedDate: selectedDate?.format('YYYY-MM-DD'),
|
||||
formattedEventTime: formattedEventTime
|
||||
});
|
||||
|
||||
setSelectedStock({
|
||||
stock_code: stockCode, // 添加交易所后缀
|
||||
stock_name: name
|
||||
});
|
||||
setSelectedEventTime(formattedEventTime);
|
||||
setKlineModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理关注切换
|
||||
const handleFollowToggle = async (eventId) => {
|
||||
setFollowingIds(prev => [...prev, eventId]);
|
||||
try {
|
||||
const response = await eventService.calendar.toggleFollow(eventId);
|
||||
if (response.success) {
|
||||
// 更新本地事件列表的关注状态
|
||||
setSelectedDateEvents(prev =>
|
||||
prev.map(event =>
|
||||
event.id === eventId
|
||||
? { ...event, is_following: response.data.is_following }
|
||||
: event
|
||||
)
|
||||
);
|
||||
message.success(response.data.is_following ? '关注成功' : '取消关注成功');
|
||||
} else {
|
||||
message.error(response.error || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleFollowToggle', error, { eventId });
|
||||
message.error('操作失败,请重试');
|
||||
} finally {
|
||||
setFollowingIds(prev => prev.filter(id => id !== eventId));
|
||||
}
|
||||
};
|
||||
|
||||
// 添加单只股票到自选(支持新旧格式)
|
||||
const addSingleToWatchlist = async (stock) => {
|
||||
// 兼容新旧格式
|
||||
const code = stock.code || stock[0];
|
||||
const name = stock.name || stock[1];
|
||||
const stockCode = getSixDigitCode(code);
|
||||
|
||||
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/watchlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
stock_code: stockCode, // 使用六位代码
|
||||
stock_name: name // 股票名称
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
message.success(`已将 ${name}(${stockCode}) 添加到自选`);
|
||||
} else {
|
||||
message.error(data.error || '添加失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'addSingleToWatchlist', error, {
|
||||
stockCode,
|
||||
stockName: name
|
||||
});
|
||||
message.error('添加失败,请重试');
|
||||
} finally {
|
||||
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 事件表格列定义
|
||||
const eventColumns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'calendar_time',
|
||||
key: 'time',
|
||||
width: 80,
|
||||
render: (time) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text>{dayjs(time).format('HH:mm')}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '重要度',
|
||||
dataIndex: 'star',
|
||||
key: 'star',
|
||||
width: 120,
|
||||
render: renderStars
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<Tooltip title={text}>
|
||||
<Text strong>{text}</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '背景',
|
||||
dataIndex: 'former',
|
||||
key: 'former',
|
||||
width: 80,
|
||||
render: (text) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => showContentDetail(text, '事件背景')}
|
||||
disabled={!text}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '未来推演',
|
||||
dataIndex: 'forecast',
|
||||
key: 'forecast',
|
||||
width: 80,
|
||||
render: (text) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => showContentDetail(text, '未来推演')}
|
||||
disabled={!text}
|
||||
>
|
||||
{text ? '查看' : '无'}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span>
|
||||
相关股票
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6, color: '#faad14' }} />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'related_stocks',
|
||||
key: 'stocks',
|
||||
width: 100,
|
||||
render: (stocks, record) => {
|
||||
const hasStocks = stocks && stocks.length > 0;
|
||||
const hasAccess = hasFeatureAccess('related_stocks');
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={hasAccess ? <StockOutlined /> : <LockOutlined />}
|
||||
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
|
||||
disabled={!hasStocks}
|
||||
style={!hasAccess ? { color: '#faad14' } : {}}
|
||||
>
|
||||
{hasStocks ? (hasAccess ? `${stocks.length}只` : '🔒需Pro') : '无'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '相关概念',
|
||||
dataIndex: 'concepts',
|
||||
key: 'concepts',
|
||||
width: 200,
|
||||
render: (concepts) => (
|
||||
<Space wrap>
|
||||
{concepts && concepts.length > 0 ? (
|
||||
concepts.slice(0, 3).map((concept, index) => (
|
||||
<Tag key={index} icon={<TagsOutlined />}>
|
||||
{Array.isArray(concept) ? concept[0] : concept}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">无</Text>
|
||||
)}
|
||||
{concepts && concepts.length > 3 && (
|
||||
<Tag>+{concepts.length - 3}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '关注',
|
||||
key: 'follow',
|
||||
width: 60,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type={record.is_following ? "primary" : "default"}
|
||||
icon={record.is_following ? <StarFilled /> : <StarOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleFollowToggle(record.id)}
|
||||
loading={followingIds.includes(record.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 股票表格列定义(使用归一化后的对象格式)
|
||||
const stockColumns = [
|
||||
{
|
||||
title: '代码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 100,
|
||||
render: (code) => {
|
||||
const sixDigitCode = getSixDigitCode(code);
|
||||
return (
|
||||
<a
|
||||
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Text code>{sixDigitCode}</Text>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
render: (name, record) => {
|
||||
const sixDigitCode = getSixDigitCode(record.code);
|
||||
return (
|
||||
<a
|
||||
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Text strong>{name}</Text>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '现价',
|
||||
key: 'price',
|
||||
width: 80,
|
||||
render: (_, record) => {
|
||||
const quote = stockQuotes[record.code];
|
||||
if (quote && quote.price !== undefined) {
|
||||
return (
|
||||
<Text type={quote.change > 0 ? 'danger' : 'success'}>
|
||||
{quote.price?.toFixed(2)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <Text>-</Text>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '涨跌幅',
|
||||
key: 'change',
|
||||
width: 100,
|
||||
render: (_, record) => {
|
||||
const quote = stockQuotes[record.code];
|
||||
if (quote && quote.changePercent !== undefined) {
|
||||
const changePercent = quote.changePercent || 0;
|
||||
return (
|
||||
<Tag color={changePercent > 0 ? 'red' : 'green'}>
|
||||
{changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}%
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return <Text>-</Text>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '关联理由',
|
||||
dataIndex: 'description',
|
||||
key: 'reason',
|
||||
render: (description, record) => {
|
||||
const stockCode = record.code;
|
||||
const isExpanded = expandedReasons[stockCode] || false;
|
||||
const reason = description || '';
|
||||
const shouldTruncate = reason && reason.length > 100;
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedReasons(prev => ({
|
||||
...prev,
|
||||
[stockCode]: !prev[stockCode]
|
||||
}));
|
||||
};
|
||||
|
||||
// 检查是否有引用数据
|
||||
const citationData = description;
|
||||
const hasCitation = citationData && citationData.data && Array.isArray(citationData.data);
|
||||
|
||||
if (hasCitation) {
|
||||
// 使用引用组件,支持展开/收起
|
||||
const processed = processCitationData(citationData);
|
||||
|
||||
if (processed) {
|
||||
// 计算所有段落的总长度
|
||||
const totalLength = processed.segments.reduce((sum, seg) => sum + seg.text.length, 0);
|
||||
const shouldTruncateProcessed = totalLength > 100;
|
||||
|
||||
// 确定要显示的段落
|
||||
let displaySegments = processed.segments;
|
||||
if (shouldTruncateProcessed && !isExpanded) {
|
||||
// 需要截断:计算应该显示到哪个段落
|
||||
let charCount = 0;
|
||||
displaySegments = [];
|
||||
for (const seg of processed.segments) {
|
||||
if (charCount + seg.text.length <= 100) {
|
||||
// 完整显示这个段落
|
||||
displaySegments.push(seg);
|
||||
charCount += seg.text.length;
|
||||
} else {
|
||||
// 截断这个段落
|
||||
const remainingChars = 100 - charCount;
|
||||
if (remainingChars > 0) {
|
||||
const truncatedText = seg.text.substring(0, remainingChars) + '...';
|
||||
displaySegments.push({ ...seg, text: truncatedText });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ lineHeight: '1.6' }}>
|
||||
{displaySegments.map((segment, index) => (
|
||||
<React.Fragment key={segment.citationId}>
|
||||
<Text>{segment.text}</Text>
|
||||
<CitationMark
|
||||
citationId={segment.citationId}
|
||||
citation={processed.citations[segment.citationId]}
|
||||
/>
|
||||
{index < displaySegments.length - 1 && <Text>,</Text>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{shouldTruncateProcessed && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={toggleExpanded}
|
||||
style={{ padding: 0, marginLeft: 4 }}
|
||||
>
|
||||
({isExpanded ? '收起' : '展开'})
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>(AI合成)</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 降级显示:纯文本 + 展开/收起
|
||||
return (
|
||||
<div>
|
||||
<Text>
|
||||
{isExpanded || !shouldTruncate
|
||||
? reason
|
||||
: `${reason?.slice(0, 100)}...`
|
||||
}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={toggleExpanded}
|
||||
style={{ padding: 0, marginLeft: 4 }}
|
||||
>
|
||||
({isExpanded ? '收起' : '展开'})
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>(AI合成)</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '研报引用',
|
||||
dataIndex: 'report',
|
||||
key: 'report',
|
||||
width: 200,
|
||||
render: (report, record) => {
|
||||
if (!report || !report.title) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<Tooltip title={report.sentences || report.title}>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 2 }}>
|
||||
{report.title.length > 20 ? `${report.title.slice(0, 20)}...` : report.title}
|
||||
</Text>
|
||||
{report.author && (
|
||||
<Text type="secondary" style={{ display: 'block', fontSize: '11px' }}>
|
||||
{report.author}
|
||||
</Text>
|
||||
)}
|
||||
{report.declare_date && (
|
||||
<Text type="secondary" style={{ fontSize: '11px' }}>
|
||||
{dayjs(report.declare_date).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
{report.match_score && (
|
||||
<Tag color={report.match_score === '好' ? 'green' : 'blue'} style={{ marginLeft: 4, fontSize: '10px' }}>
|
||||
匹配度: {report.match_score}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'K线图',
|
||||
key: 'kline',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => showKline(record)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => {
|
||||
const stockCode = getSixDigitCode(record.code);
|
||||
const isAdding = addingToWatchlist[stockCode] || false;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
loading={isAdding}
|
||||
onClick={() => addSingleToWatchlist(record)}
|
||||
>
|
||||
加自选
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
投资日历
|
||||
</span>
|
||||
}
|
||||
className="investment-calendar"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Calendar
|
||||
fullscreen={false}
|
||||
cellRender={cellRender}
|
||||
onSelect={handleDateSelect}
|
||||
onPanelChange={(date) => setCurrentMonth(date)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 事件列表模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<CalendarOutlined />
|
||||
<span>{selectedDate?.format('YYYY年MM月DD日')} 投资事件</span>
|
||||
</Space>
|
||||
}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={1200}
|
||||
footer={null}
|
||||
styles={{ body: { padding: '24px' } }}
|
||||
zIndex={1500}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Tabs defaultActiveKey="event">
|
||||
<TabPane tab={`事件 (${selectedDateEvents.filter(e => e.type === 'event').length})`} key="event">
|
||||
<Table
|
||||
dataSource={selectedDateEvents.filter(e => e.type === 'event')}
|
||||
columns={eventColumns}
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane tab={`数据 (${selectedDateEvents.filter(e => e.type === 'data').length})`} key="data">
|
||||
<Table
|
||||
dataSource={selectedDateEvents.filter(e => e.type === 'data')}
|
||||
columns={eventColumns}
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
||||
{/* 内容详情抽屉 */}
|
||||
<Drawer
|
||||
title={selectedDetail?.title}
|
||||
placement="right"
|
||||
width={600}
|
||||
onClose={() => setDetailDrawerVisible(false)}
|
||||
open={detailDrawerVisible}
|
||||
zIndex={1500}
|
||||
>
|
||||
{selectedDetail?.content?.type === 'citation' ? (
|
||||
<CitedContent
|
||||
data={selectedDetail.content.content}
|
||||
title={selectedDetail.title || '事件背景'}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown>{selectedDetail?.content?.content || '暂无内容'}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 相关股票模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<StockOutlined />
|
||||
<span>相关股票</span>
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<LockOutlined style={{ color: '#faad14' }} />
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={stockModalVisible}
|
||||
onCancel={() => {
|
||||
setStockModalVisible(false);
|
||||
setExpandedReasons({}); // 清理展开状态
|
||||
setAddingToWatchlist({}); // 清理加自选状态
|
||||
}}
|
||||
width={1000}
|
||||
footer={
|
||||
<Button onClick={() => setStockModalVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
zIndex={1500}
|
||||
>
|
||||
{hasFeatureAccess('related_stocks') ? (
|
||||
<Table
|
||||
dataSource={selectedStocks}
|
||||
columns={stockColumns}
|
||||
rowKey={(record) => record[0]}
|
||||
size="middle"
|
||||
pagination={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
|
||||
<LockOutlined />
|
||||
</div>
|
||||
<Alert
|
||||
message="相关股票功能已锁定"
|
||||
description="此功能需要Pro版订阅才能使用"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => setUpgradeModalOpen(true)}
|
||||
>
|
||||
升级到 Pro版
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* K线图弹窗 */}
|
||||
{selectedStock && (
|
||||
<KLineChartModal
|
||||
isOpen={klineModalVisible}
|
||||
onClose={() => {
|
||||
setKlineModalVisible(false);
|
||||
setSelectedStock(null);
|
||||
setSelectedEventTime(null);
|
||||
}}
|
||||
stock={selectedStock}
|
||||
eventTime={selectedEventTime}
|
||||
size="5xl"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 订阅升级模态框 */}
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModalOpen}
|
||||
onClose={() => setUpgradeModalOpen(false)}
|
||||
requiredLevel="pro"
|
||||
featureName="相关股票分析"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvestmentCalendar;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getCacheKey,
|
||||
klineDataCache,
|
||||
batchPendingRequests
|
||||
} from '../utils/klineDataCache';
|
||||
} from '@utils/stock/klineDataCache';
|
||||
|
||||
/**
|
||||
* 迷你分时图组件
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../../../../services/eventService';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
// ================= 全局缓存和请求管理 =================
|
||||
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}|${chartType}` -> data
|
||||
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}|${chartType}` -> Promise
|
||||
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}|${chartType}` -> timestamp
|
||||
export const batchPendingRequests = new Map(); // 批量请求的 Promise: key = `${eventTime}|${chartType}` -> Promise
|
||||
|
||||
// 请求间隔限制(毫秒)
|
||||
const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据
|
||||
|
||||
/**
|
||||
* 获取缓存键
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const date = eventTime ? dayjs(eventTime).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
return `${stockCode}|${date}|${chartType}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否需要刷新数据
|
||||
* @param {string} cacheKey - 缓存键
|
||||
* @returns {boolean} 是否需要刷新
|
||||
*/
|
||||
export const shouldRefreshData = (cacheKey) => {
|
||||
const lastTime = lastRequestTime.get(cacheKey);
|
||||
if (!lastTime) return true;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastTime;
|
||||
|
||||
// 如果是今天的数据且交易时间内,允许更频繁的更新
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const isToday = cacheKey.includes(today);
|
||||
const currentHour = new Date().getHours();
|
||||
const isTradingHours = currentHour >= 9 && currentHour < 16;
|
||||
|
||||
if (isToday && isTradingHours) {
|
||||
return elapsed > REQUEST_INTERVAL;
|
||||
}
|
||||
|
||||
// 历史数据不需要频繁更新
|
||||
return elapsed > 3600000; // 1小时
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取K线数据(带缓存和防重复请求)
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
* @returns {Promise<Array>} K线数据
|
||||
*/
|
||||
export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
|
||||
|
||||
// 1. 检查缓存
|
||||
if (klineDataCache.has(cacheKey)) {
|
||||
// 检查是否需要刷新
|
||||
if (!shouldRefreshData(cacheKey)) {
|
||||
logger.debug('klineDataCache', '使用缓存数据', { cacheKey });
|
||||
return klineDataCache.get(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否有正在进行的请求
|
||||
if (pendingRequests.has(cacheKey)) {
|
||||
logger.debug('klineDataCache', '等待进行中的请求', { cacheKey });
|
||||
return pendingRequests.get(cacheKey);
|
||||
}
|
||||
|
||||
// 3. 发起新请求
|
||||
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
|
||||
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const requestPromise = stockService
|
||||
.getKlineData(stockCode, chartType, normalizedEventTime)
|
||||
.then((res) => {
|
||||
const data = Array.isArray(res?.data) ? res.data : [];
|
||||
// 更新缓存
|
||||
klineDataCache.set(cacheKey, data);
|
||||
lastRequestTime.set(cacheKey, Date.now());
|
||||
// 清除pending状态
|
||||
pendingRequests.delete(cacheKey);
|
||||
logger.debug('klineDataCache', 'K线数据请求完成并缓存', {
|
||||
cacheKey,
|
||||
chartType,
|
||||
dataPoints: data.length
|
||||
});
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, chartType, cacheKey });
|
||||
// 清除pending状态
|
||||
pendingRequests.delete(cacheKey);
|
||||
// 如果有旧缓存,返回旧数据
|
||||
if (klineDataCache.has(cacheKey)) {
|
||||
return klineDataCache.get(cacheKey);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 保存pending请求
|
||||
pendingRequests.set(cacheKey, requestPromise);
|
||||
|
||||
return requestPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除指定股票的缓存
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间(可选)
|
||||
*/
|
||||
export const clearCache = (stockCode, eventTime = null) => {
|
||||
if (eventTime) {
|
||||
const cacheKey = getCacheKey(stockCode, eventTime);
|
||||
klineDataCache.delete(cacheKey);
|
||||
lastRequestTime.delete(cacheKey);
|
||||
pendingRequests.delete(cacheKey);
|
||||
logger.debug('klineDataCache', '清除缓存', { cacheKey });
|
||||
} else {
|
||||
// 清除该股票的所有缓存
|
||||
const prefix = `${stockCode}|`;
|
||||
for (const key of klineDataCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
klineDataCache.delete(key);
|
||||
lastRequestTime.delete(key);
|
||||
pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
logger.debug('klineDataCache', '清除股票所有缓存', { stockCode });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
export const clearAllCache = () => {
|
||||
klineDataCache.clear();
|
||||
lastRequestTime.clear();
|
||||
pendingRequests.clear();
|
||||
logger.debug('klineDataCache', '清除所有缓存');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
* @returns {Object} 缓存统计
|
||||
*/
|
||||
export const getCacheStats = () => {
|
||||
return {
|
||||
totalCached: klineDataCache.size,
|
||||
pendingRequests: pendingRequests.size,
|
||||
cacheKeys: Array.from(klineDataCache.keys())
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量获取多只股票的K线数据(一次API请求)
|
||||
* @param {string[]} stockCodes - 股票代码数组
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
* @returns {Promise<Object>} 股票代码到K线数据的映射 { [stockCode]: data[] }
|
||||
*/
|
||||
export const fetchBatchKlineData = async (stockCodes, eventTime, chartType = 'timeline') => {
|
||||
if (!stockCodes || stockCodes.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const batchKey = `${normalizedEventTime || 'today'}|${chartType}`;
|
||||
|
||||
// 过滤出未缓存的股票
|
||||
const uncachedCodes = stockCodes.filter(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
return !klineDataCache.has(cacheKey) || shouldRefreshData(cacheKey);
|
||||
});
|
||||
|
||||
logger.debug('klineDataCache', '批量请求分析', {
|
||||
totalCodes: stockCodes.length,
|
||||
uncachedCodes: uncachedCodes.length,
|
||||
cachedCodes: stockCodes.length - uncachedCodes.length
|
||||
});
|
||||
|
||||
// 如果所有股票都有缓存,直接返回缓存数据
|
||||
if (uncachedCodes.length === 0) {
|
||||
const result = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
result[code] = klineDataCache.get(cacheKey) || [];
|
||||
});
|
||||
logger.debug('klineDataCache', '所有股票数据来自缓存', { stockCount: stockCodes.length });
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查是否有正在进行的批量请求
|
||||
if (batchPendingRequests.has(batchKey)) {
|
||||
logger.debug('klineDataCache', '等待进行中的批量请求', { batchKey });
|
||||
return batchPendingRequests.get(batchKey);
|
||||
}
|
||||
|
||||
// 发起批量请求
|
||||
logger.debug('klineDataCache', '发起批量K线数据请求', {
|
||||
batchKey,
|
||||
stockCount: uncachedCodes.length,
|
||||
chartType
|
||||
});
|
||||
|
||||
const requestPromise = stockService
|
||||
.getBatchKlineData(uncachedCodes, chartType, normalizedEventTime)
|
||||
.then((response) => {
|
||||
const batchData = response?.data || {};
|
||||
const now = Date.now();
|
||||
|
||||
// 将批量数据存入缓存
|
||||
Object.entries(batchData).forEach(([code, stockData]) => {
|
||||
const data = Array.isArray(stockData?.data) ? stockData.data : [];
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
klineDataCache.set(cacheKey, data);
|
||||
lastRequestTime.set(cacheKey, now);
|
||||
});
|
||||
|
||||
// 对于请求中没有返回数据的股票,设置空数组
|
||||
uncachedCodes.forEach(code => {
|
||||
if (!batchData[code]) {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
if (!klineDataCache.has(cacheKey)) {
|
||||
klineDataCache.set(cacheKey, []);
|
||||
lastRequestTime.set(cacheKey, now);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 清除批量请求状态
|
||||
batchPendingRequests.delete(batchKey);
|
||||
|
||||
logger.debug('klineDataCache', '批量K线数据请求完成', {
|
||||
batchKey,
|
||||
stockCount: Object.keys(batchData).length
|
||||
});
|
||||
|
||||
// 返回所有请求股票的数据(包括之前缓存的)
|
||||
const result = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
result[code] = klineDataCache.get(cacheKey) || [];
|
||||
});
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('klineDataCache', 'fetchBatchKlineData', error, {
|
||||
stockCount: uncachedCodes.length,
|
||||
chartType
|
||||
});
|
||||
// 清除批量请求状态
|
||||
batchPendingRequests.delete(batchKey);
|
||||
|
||||
// 返回已缓存的数据
|
||||
const result = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
result[code] = klineDataCache.get(cacheKey) || [];
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// 保存批量请求
|
||||
batchPendingRequests.set(batchKey, requestPromise);
|
||||
|
||||
return requestPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 预加载多只股票的K线数据(后台执行,不阻塞UI)
|
||||
* @param {string[]} stockCodes - 股票代码数组
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
*/
|
||||
export const preloadBatchKlineData = (stockCodes, eventTime, chartType = 'timeline') => {
|
||||
// 异步执行,不返回Promise,不阻塞调用方
|
||||
fetchBatchKlineData(stockCodes, eventTime, chartType).catch(() => {
|
||||
// 静默处理错误,预加载失败不影响用户体验
|
||||
});
|
||||
};
|
||||
@@ -29,7 +29,7 @@ import type { InvestmentEvent } from '@/types';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
// 懒加载投资日历组件
|
||||
const InvestmentCalendar = lazy(() => import('@/views/Community/components/InvestmentCalendar'));
|
||||
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { decodeEventId } from '@/utils/idEncoder';
|
||||
import { eventService } from '@/services/eventService';
|
||||
import { DynamicNewsDetailPanel } from '@/views/Community/components/DynamicNewsDetail';
|
||||
import { DynamicNewsDetailPanel } from '@components/EventDetailPanel';
|
||||
import { logger } from '@/utils/logger';
|
||||
import ErrorPage from '@/components/ErrorPage';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user