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:
zdl
2025-12-08 12:09:24 +08:00
parent b4ddccfb92
commit ee33f7ffd7
30 changed files with 56 additions and 43 deletions

View File

@@ -0,0 +1,137 @@
// src/components/EventDetailPanel/CollapsibleHeader.js
// 可折叠模块标题组件
import React from 'react';
import {
Box,
Flex,
HStack,
Heading,
Badge,
IconButton,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
/**
* 可折叠模块标题组件
* @param {Object} props
* @param {string} props.title - 标题文本
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false
* @param {string} props.currentMode - 当前模式:'detailed' | 'simple'
* @param {Function} props.onModeToggle - 模式切换回调
* @param {boolean} props.isLocked - 是否锁定(不可展开)
*/
const CollapsibleHeader = ({
title,
isOpen,
onToggle,
count = null,
subscriptionBadge = null,
showModeToggle = false,
currentMode = 'detailed',
onModeToggle = null,
isLocked = false
}) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 获取按钮文案
const getButtonText = () => {
if (currentMode === 'simple') {
return '查看详情'; // 简单模式时,按钮显示"查看详情"
}
return '精简模式'; // 详细模式时,按钮显示"精简模式"
};
// 获取按钮图标
const getButtonIcon = () => {
if (currentMode === 'simple') {
return null; // 简单模式不显示图标
}
// 详细模式:展开显示向上箭头,收起显示向下箭头
return isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />;
};
return (
<Flex
justify="space-between"
align="center"
cursor="pointer"
onClick={showModeToggle ? undefined : onToggle}
p={3}
bg={sectionBg}
borderRadius="md"
_hover={{ bg: hoverBg }}
transition="background 0.2s"
>
{/* 左侧:标题区域(可点击切换展开) */}
<HStack
spacing={2}
cursor="pointer"
onClick={showModeToggle ? onToggle : undefined}
flex="1"
>
<Heading size="sm" color={headingColor}>
{title}
</Heading>
{subscriptionBadge && (
<Box>
{subscriptionBadge}
</Box>
)}
{count !== null && count > 0 && (
<Badge colorScheme="blue" borderRadius="full">
{count}
</Badge>
)}
{/* 展开/收起图标showModeToggle 时显示在标题旁边) */}
{showModeToggle && (
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="xs"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
/>
)}
</HStack>
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
{showModeToggle && onModeToggle && (
<Button
size="sm"
variant="ghost"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
onModeToggle(e);
}}
>
{currentMode === 'simple' ? '详细信息' : '精简模式'}
</Button>
)}
{/* showModeToggle=false 时显示原有的 IconButton */}
{!showModeToggle && (
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
)}
</Flex>
);
};
export default CollapsibleHeader;

View File

@@ -0,0 +1,125 @@
// src/components/EventDetailPanel/CollapsibleSection.js
// 通用可折叠区块组件
import React, { useState } from 'react';
import {
Box,
Collapse,
useColorModeValue,
} from '@chakra-ui/react';
import CollapsibleHeader from './CollapsibleHeader';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
/**
* 通用可折叠区块组件
* @param {Object} props
* @param {string} props.title - 标题文本
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.isLocked - 是否锁定(不可展开)
* @param {Function} props.onLockedClick - 锁定时点击的回调
* @param {React.ReactNode} props.children - 详细内容
* @param {React.ReactNode} props.simpleContent - 精简模式的内容(可选)
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false
* @param {string} props.defaultMode - 默认模式:'detailed' | 'simple'(默认 'detailed'
*/
const CollapsibleSection = ({
title,
isOpen,
onToggle,
count = null,
subscriptionBadge = null,
isLocked = false,
onLockedClick = null,
children,
simpleContent = null,
showModeToggle = false,
defaultMode = 'detailed'
}) => {
const sectionBg = PROFESSIONAL_COLORS.background.secondary;
// 模式状态:'detailed' | 'simple'
const [displayMode, setDisplayMode] = useState(defaultMode);
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
const handleToggle = () => {
if (isLocked && onLockedClick) {
onLockedClick();
} else if (!isLocked) {
onToggle();
}
};
// 处理模式切换
const handleModeToggle = (e) => {
e.stopPropagation(); // 阻止冒泡到标题栏的 onToggle
if (isLocked && onLockedClick) {
// 如果被锁定,触发付费弹窗
onLockedClick();
return;
}
if (displayMode === 'detailed') {
// 从详细模式切换到精简模式
setDisplayMode('simple');
} else {
// 从精简模式切换回详细模式
setDisplayMode('detailed');
// 切换回详细模式时,如果未展开则自动展开
if (!isOpen && onToggle) {
onToggle();
}
}
};
// 渲染精简模式
const renderSimpleMode = () => {
if (!simpleContent) return null;
return (
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{simpleContent}
</Box>
);
};
// 渲染详细模式
const renderDetailedMode = () => {
return (
<Collapse
in={isOpen && !isLocked}
animateOpacity
unmountOnExit={false}
startingHeight={0}
>
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{children}
</Box>
</Collapse>
);
};
return (
<Box>
<CollapsibleHeader
title={title}
isOpen={isOpen}
onToggle={handleToggle}
count={count}
subscriptionBadge={subscriptionBadge}
showModeToggle={showModeToggle}
currentMode={displayMode}
onModeToggle={handleModeToggle}
isLocked={isLocked}
/>
{/* 根据当前模式渲染对应内容 */}
{displayMode === 'simple' ? renderSimpleMode() : renderDetailedMode()}
</Box>
);
};
export default CollapsibleSection;

View File

@@ -0,0 +1,100 @@
// src/components/EventDetailPanel/CompactMetaBar.js
// 精简信息栏组件(无头部模式下右上角显示)
import React from 'react';
import {
HStack,
Badge,
Text,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import EventFollowButton from '../EventCard/EventFollowButton';
/**
* 精简信息栏组件
* 在无头部模式下,显示在 CardBody 右上角
* 包含:重要性徽章、浏览次数、关注按钮
*
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {Object} props.importance - 重要性配置对象(包含 level, icon 等)
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {Function} props.onToggleFollow - 切换关注回调
*/
const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
const viewCountBg = useColorModeValue('white', 'gray.700');
const viewCountTextColor = useColorModeValue('gray.600', 'gray.300');
// 获取重要性文本
const getImportanceText = () => {
const levelMap = {
'S': '极高',
'A': '高',
'B': '中',
'C': '低'
};
return levelMap[importance.level] || '中';
};
return (
<HStack
position="absolute"
top={3}
right={3}
spacing={3}
zIndex={1}
>
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 - H5 隐藏 */}
<Badge
px={3}
py={1.5}
borderRadius="full"
fontSize="sm"
fontWeight="bold"
bgGradient={
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
'linear(to-r, gray.500, gray.700)'
}
color="white"
boxShadow="lg"
display={{ base: 'none', lg: 'flex' }}
alignItems="center"
gap={1}
>
<Icon as={importance.icon} boxSize={4} />
<Text>重要性{getImportanceText()}</Text>
</Badge>
{/* 浏览次数 - 添加容器背景以提高可读性 */}
<HStack
spacing={1}
bg={viewCountBg}
px={2}
py={1}
borderRadius="md"
boxShadow="sm"
>
<ViewIcon color="gray.400" boxSize={4} />
<Text fontSize="sm" color={viewCountTextColor} whiteSpace="nowrap">
{(event.view_count || 0).toLocaleString()}
</Text>
</HStack>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={onToggleFollow}
size="sm"
showCount={true}
/>
</HStack>
);
};
export default CompactMetaBar;

View File

@@ -0,0 +1,116 @@
// src/components/EventDetailPanel/CompactStockItem.js
// 精简模式股票卡片组件(浮动卡片样式)
import React from 'react';
import { useSelector } from 'react-redux';
import {
Box,
Text,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { selectIsMobile } from '@store/slices/deviceSlice';
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '@utils/colorUtils';
/**
* 精简模式股票卡片组件
* @param {Object} props
* @param {Object} props.stock - 股票对象
* @param {Object} props.quote - 股票行情数据(可选)
*/
const CompactStockItem = ({ stock, quote = null }) => {
const isMobile = useSelector(selectIsMobile);
const nameColor = useColorModeValue('gray.700', 'gray.300');
const handleViewDetail = () => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
};
// 格式化涨跌幅显示
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅数据(优先使用 quotefallback 到 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;

View File

@@ -0,0 +1,424 @@
// src/components/EventDetailPanel/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback, useEffect, useReducer } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
CardBody,
VStack,
Text,
Spinner,
Center,
Wrap,
WrapItem,
Box,
} from '@chakra-ui/react';
import { getImportanceConfig } from '@constants/importanceLevels';
import { eventService } from '@services/eventService';
import { useEventStocks } from '@views/Community/components/StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
import { useAuth } from '@contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection';
import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '@views/EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '@views/EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '@components/SubscriptionBadge';
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import { useWatchlist } from '@hooks/useWatchlist';
import EventCommentSection from '@components/EventCommentSection';
// 折叠区块状态管理 - 使用 useReducer 整合
const initialSectionState = {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
const sectionReducer = (state, action) => {
switch (action.type) {
case 'TOGGLE':
return {
...state,
[action.section]: {
...state[action.section],
isOpen: !state[action.section].isOpen
}
};
case 'SET_LOADED':
return {
...state,
[action.section]: {
...state[action.section],
hasLoaded: true
}
};
case 'SET_QUOTES_LOADED':
return {
...state,
stocks: { ...state.stocks, hasLoadedQuotes: true }
};
case 'RESET_ALL':
return {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
default:
return state;
}
};
/**
* 动态新闻详情面板主组件
* @param {Object} props
* @param {Object} props.event - 事件对象(包含详情数据)
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true
*/
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const dispatch = useDispatch();
const { user } = useAuth();
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const textColor = PROFESSIONAL_COLORS.text.secondary;
// 使用 useWatchlist Hook 管理自选股
const {
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist,
loadWatchlistQuotes
} = useWatchlist();
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type
const userTier = user?.subscription_type || 'free';
// 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus);
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
// 🎯 浏览量机制:存储从 API 获取的完整事件详情(包含最新 view_count
const [fullEventDetail, setFullEventDetail] = useState(null);
const [loadingDetail, setLoadingDetail] = useState(false);
// 权限判断函数
const hasAccess = useCallback((requiredTier) => {
const tierLevel = { free: 0, pro: 1, max: 2 };
const result = tierLevel[userTier] >= tierLevel[requiredTier];
return result;
}, [userTier]);
// 升级弹窗状态
const [upgradeModal, setUpgradeModal] = useState({
isOpen: false,
requiredLevel: 'pro',
featureName: ''
});
// 使用 Hook 获取实时数据
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
// - autoLoadQuotes: true - 股票数据加载后自动加载行情(相关股票默认展开)
const {
stocks,
quotes,
eventDetail,
historicalEvents,
expectationScore,
loading,
loadStocksData,
loadHistoricalData,
loadChainAnalysis,
refreshQuotes
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: true });
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
const loadEventDetail = useCallback(async () => {
if (!event?.id) return;
setLoadingDetail(true);
try {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setFullEventDetail(response.data);
}
} catch (error) {
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
eventId: event?.id
});
} finally {
setLoadingDetail(false);
}
}, [event?.id]);
// 相关股票、相关概念、历史事件和传导链的权限
const canAccessStocks = hasAccess('pro');
const canAccessConcepts = hasAccess('pro');
const canAccessHistorical = hasAccess('pro');
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 - 使用 useReducer 整合
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
// 锁定点击处理 - 弹出升级弹窗
const handleLockedClick = useCallback((featureName, requiredLevel) => {
setUpgradeModal({
isOpen: true,
requiredLevel,
featureName
});
}, []);
// 关闭升级弹窗
const handleCloseUpgradeModal = useCallback(() => {
setUpgradeModal({
isOpen: false,
requiredLevel: 'pro',
featureName: ''
});
}, []);
// 相关股票 - 展开时加载行情(需要 PRO 权限)
const handleStocksToggle = useCallback(() => {
const willOpen = !sectionState.stocks.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'stocks' });
// 展开时加载行情数据(如果还没加载过)
if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
refreshQuotes();
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
}, [sectionState.stocks, stocks.length, refreshQuotes]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
}, []);
// 历史事件对比 - 数据已预加载,只需切换展开状态
const handleHistoricalToggle = useCallback(() => {
dispatchSection({ type: 'TOGGLE', section: 'historical' });
}, []);
// 传导链分析 - 展开时加载
const handleTransmissionToggle = useCallback(() => {
const willOpen = !sectionState.transmission.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'transmission' });
if (willOpen && !sectionState.transmission.hasLoaded) {
loadChainAnalysis();
dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
}
}, [sectionState.transmission, loadChainAnalysis]);
// 事件切换时重置所有子模块状态
useEffect(() => {
// 加载事件详情(增加浏览量)
loadEventDetail();
// 加载自选股数据(用于判断股票是否已关注)
loadWatchlistQuotes();
// 重置所有折叠区块状态
dispatchSection({ type: 'RESET_ALL' });
// 相关股票默认展开,预加载股票列表和行情数据
if (canAccessStocks) {
loadStocksData();
dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
if (canAccessHistorical) {
loadHistoricalData();
dispatchSection({ type: 'SET_LOADED', section: 'historical' });
}
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
if (!event?.id) return;
dispatch(toggleEventFollow(event.id));
}, [dispatch, event?.id]);
// 切换自选股(使用 useWatchlist Hook
const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
if (currentlyInWatchlist) {
await handleRemoveFromWatchlist(stockCode);
} else {
await handleAddToWatchlist(stockCode, stockName);
}
}, [handleAddToWatchlist, handleRemoveFromWatchlist]);
// 空状态
if (!event) {
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<Text color={textColor} textAlign="center">
请选择一个事件查看详情
</Text>
</CardBody>
</Card>
);
}
const importance = getImportanceConfig(event.importance);
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody position="relative">
{/* 无头部模式:显示右上角精简信息栏 */}
{!showHeader && (
<CompactMetaBar
event={fullEventDetail || event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
)}
<VStack align="stretch" spacing={3}>
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) - 可配置显示/隐藏 */}
{showHeader && (
<EventHeaderInfo
event={fullEventDetail || event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
)}
{/* 事件描述 */}
<EventDescriptionSection description={event.description} />
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection
title="相关股票"
isOpen={sectionState.stocks.isOpen}
onToggle={handleStocksToggle}
count={stocks?.length || 0}
subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
showModeToggle={canAccessStocks}
defaultMode="detailed"
simpleContent={
loading.stocks || loading.quotes ? (
<Center py={2}>
<Spinner size="sm" color="blue.500" />
<Text ml={2} color={textColor} fontSize="sm">加载股票数据中...</Text>
</Center>
) : (
<Wrap spacing={4}>
{stocks?.map((stock, index) => (
<WrapItem key={index}>
<CompactStockItem
stock={stock}
quote={quotes[stock.stock_code]}
/>
</WrapItem>
))}
</Wrap>
)
}
>
{loading.stocks || loading.quotes ? (
<Center py={4}>
<Spinner size="md" color="blue.500" />
<Text ml={2} color={textColor}>加载股票数据中...</Text>
</Center>
) : (
<RelatedStocksSection
stocks={stocks}
quotes={quotes}
eventTime={event.created_at}
isInWatchlist={isInWatchlist}
onWatchlistToggle={handleWatchlistToggle}
/>
)}
</CollapsibleSection>
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
<RelatedConceptsSection
eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
isOpen={sectionState.concepts.isOpen}
onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts}
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
/>
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
<CollapsibleSection
title="历史事件对比"
isOpen={sectionState.historical.isOpen}
onToggle={handleHistoricalToggle}
count={historicalEvents?.length || 0}
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessHistorical}
onLockedClick={() => handleLockedClick('历史事件对比', 'pro')}
>
{loading.historicalEvents ? (
<Center py={4}>
<Spinner size="sm" color="blue.500" />
<Text ml={2} color={textColor} fontSize="sm">加载历史事件...</Text>
</Center>
) : (
<HistoricalEvents
events={historicalEvents || []}
expectationScore={expectationScore}
/>
)}
</CollapsibleSection>
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
<CollapsibleSection
title="传导链分析"
isOpen={sectionState.transmission.isOpen}
onToggle={handleTransmissionToggle}
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
isLocked={!canAccessTransmission}
onLockedClick={() => handleLockedClick('传导链分析', 'max')}
>
<TransmissionChainAnalysis
eventId={event.id}
eventService={eventService}
/>
</CollapsibleSection>
{/* 讨论区(评论区) - 所有登录用户可用 */}
<Box>
<EventCommentSection eventId={event.id} />
</Box>
</VStack>
</CardBody>
{/* 升级弹窗 */}
{upgradeModal.isOpen ? (
<SubscriptionUpgradeModal
isOpen={upgradeModal.isOpen}
onClose={handleCloseUpgradeModal}
requiredLevel={upgradeModal.requiredLevel}
featureName={upgradeModal.featureName}
currentLevel={userTier}
/>
) : null}
</Card>
);
};
export default DynamicNewsDetailPanel;

View File

@@ -0,0 +1,43 @@
// src/components/EventDetailPanel/EventDescriptionSection.js
// 事件描述区组件
import React from 'react';
import {
Box,
Heading,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
/**
* 事件描述区组件
* @param {Object} props
* @param {string} props.description - 事件描述文本
*/
const EventDescriptionSection = ({ description }) => {
const sectionBg = PROFESSIONAL_COLORS.background.secondary;
const headingColor = PROFESSIONAL_COLORS.text.primary;
const textColor = PROFESSIONAL_COLORS.text.secondary;
// 如果没有描述,不渲染
if (!description) {
return null;
}
return (
<Box bg={sectionBg} p={3} borderRadius="md">
{/* 事件描述 */}
<Box>
<Heading size="sm" color={headingColor} mb={2}>
事件描述
</Heading>
<Text fontSize="sm" color={textColor} lineHeight="tall">
{description}
</Text>
</Box>
</Box>
);
};
export default EventDescriptionSection;

View File

@@ -0,0 +1,142 @@
// src/components/EventDetailPanel/EventHeaderInfo.js
// 事件头部信息区组件
import React from 'react';
import {
Box,
Flex,
HStack,
Heading,
Text,
Badge,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import dayjs from 'dayjs';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
import EventFollowButton from '../EventCard/EventFollowButton';
/**
* 事件头部信息区组件
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {Object} props.importance - 重要性配置对象(包含 level, color 等)
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {Function} props.onToggleFollow - 切换关注回调
*/
const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 获取重要性文本
const getImportanceText = () => {
const levelMap = {
'S': '极高',
'A': '高',
'B': '中',
'C': '低'
};
return levelMap[importance.level] || '中';
};
// 格式化涨跌幅数字
const formatChange = (value) => {
if (value === null || value === undefined) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${value.toFixed(2)}%`;
};
return (
<Box bg={sectionBg} p={3} borderRadius="md" position="relative">
{/* 粉色圆角标签(左上角绝对定位) */}
{event.related_avg_chg !== null && event.related_avg_chg !== undefined && (
<Box
position="absolute"
top="-8px"
left="-8px"
bg="pink.500"
color="white"
px={3}
py={1}
borderRadius="full"
fontSize="sm"
fontWeight="bold"
boxShadow="md"
zIndex={1}
>
{formatChange(event.related_avg_chg)}
</Box>
)}
{/* 第一行:标题 + 关注按钮 */}
<Flex align="center" justify="space-between" mb={3} gap={4}>
{/* 标题 */}
<Heading size="md" color={headingColor} flex={1}>
{event.title}
</Heading>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={onToggleFollow}
size="sm"
showCount={true}
/>
</Flex>
{/* 第二行:浏览数 + 日期 */}
<Flex align="left" mb={3} gap={4}>
{/* 浏览数 */}
<HStack spacing={1}>
<ViewIcon color="gray.400" boxSize={4} />
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
{(event.view_count || 0).toLocaleString()}次浏览
</Text>
</HStack>
{/* 日期 */}
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
{dayjs(event.created_at).format('YYYY年MM月DD日')}
</Text>
</Flex>
{/* 第三行:涨跌幅指标 + 重要性徽章 */}
<HStack spacing={3} align="center">
<Box maxW="500px">
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
/>
</Box>
{/* 重要性徽章 - 使用渐变色和图标 */}
<Badge
px={4}
py={2}
borderRadius="full"
fontSize="md"
fontWeight="bold"
bgGradient={
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
'linear(to-r, gray.500, gray.700)'
}
color="white"
boxShadow="lg"
display="flex"
alignItems="center"
gap={2}
>
<Icon as={importance.icon} boxSize={5} />
<Text>重要性{getImportanceText()}</Text>
</Badge>
</HStack>
</Box>
);
};
export default EventHeaderInfo;

View File

@@ -0,0 +1,204 @@
// src/components/EventDetailPanel/MiniKLineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import {
fetchKlineData,
getCacheKey,
klineDataCache
} from '@utils/stock/klineDataCache';
/**
* 迷你K线图组件
* 显示股票的K线走势蜡烛图支持事件时间标记
*
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
* @param {Array} preloadedData - 预加载的K线数据可选由父组件批量加载后传入
* @param {boolean} loading - 外部加载状态(可选)
* @returns {JSX.Element}
*/
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
const loadedRef = useRef(false);
const dataFetchedRef = useRef(false);
// 稳定的事件时间
const stableEventTime = useMemo(() => {
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!stockCode) {
setData([]);
loadedRef.current = false;
dataFetchedRef.current = false;
return;
}
// 优先使用预加载的数据(由父组件批量请求后传入)
if (preloadedData !== undefined) {
setData(preloadedData || []);
setLoading(false);
loadedRef.current = true;
dataFetchedRef.current = true;
return;
}
// 如果外部正在加载显示loading状态不发起单独请求
if (externalLoading) {
setLoading(true);
return;
}
if (dataFetchedRef.current) {
return;
}
// 检查缓存K线图使用 'daily' 类型)
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
const cachedData = klineDataCache.get(cacheKey);
if (cachedData !== undefined) {
setData(cachedData || []);
loadedRef.current = true;
dataFetchedRef.current = true;
return;
}
dataFetchedRef.current = true;
setLoading(true);
// 获取日K线数据备用方案
fetchKlineData(stockCode, stableEventTime, 'daily')
.then((result) => {
if (mountedRef.current) {
setData(result);
setLoading(false);
loadedRef.current = true;
}
})
.catch(() => {
if (mountedRef.current) {
setData([]);
setLoading(false);
loadedRef.current = true;
}
});
}, [stockCode, stableEventTime, preloadedData, externalLoading]);
const chartOption = useMemo(() => {
// 提取K线数据 [open, close, low, high]
const klineData = data
.filter(item => item.open && item.close && item.low && item.high)
.map(item => [item.open, item.close, item.low, item.high]);
// 日K线使用 date 字段
const dates = data.map(item => item.date || item.time);
const hasData = klineData.length > 0;
if (!hasData) {
return {
title: {
text: loading ? '加载中...' : '无数据',
left: 'center',
top: 'middle',
textStyle: { color: '#999', fontSize: 10 }
}
};
}
// 计算事件时间标记
let eventMarkLineData = [];
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
try {
const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
const eventIdx = dates.findIndex(d => {
const dateStr = typeof d === 'object' ? dayjs(d).format('YYYY-MM-DD') : String(d);
return dateStr.includes(eventDate);
});
if (eventIdx >= 0) {
eventMarkLineData.push({
xAxis: eventIdx,
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
label: { show: false }
});
}
} catch (e) {
// 忽略异常
}
}
return {
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
xAxis: {
type: 'category',
data: dates,
show: false,
boundaryGap: true
},
yAxis: {
type: 'value',
show: false,
scale: true
},
series: [{
type: 'candlestick',
data: klineData,
itemStyle: {
color: '#ef5350', // 涨(阳线)
color0: '#26a69a', // 跌(阴线)
borderColor: '#ef5350', // 涨(边框)
borderColor0: '#26a69a' // 跌(边框)
},
barWidth: '60%',
markLine: {
silent: true,
symbol: 'none',
label: { show: false },
data: eventMarkLineData
}
}],
tooltip: { show: false },
animation: false
};
}, [data, loading, stableEventTime]);
return (
<div
style={{
width: '100%',
height: '100%',
minHeight: '35px',
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}
>
<ReactECharts
option={chartOption}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
</div>
);
}, (prevProps, nextProps) => {
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick &&
prevProps.preloadedData === nextProps.preloadedData &&
prevProps.loading === nextProps.loading;
});
export default MiniKLineChart;

View File

@@ -0,0 +1,94 @@
// src/components/EventDetailPanel/MiniLineChart.js
// Mini 折线图组件(用于股票卡片)
import React from 'react';
import { Box } from '@chakra-ui/react';
/**
* Mini 折线图组件
* @param {Object} props
* @param {Array<number>} props.data - 价格走势数据数组15个数据点前5+中5+后5
* @param {number} props.width - 图表宽度默认180
* @param {number} props.height - 图表高度默认60
*/
const MiniLineChart = ({ data = [], width = 180, height = 60 }) => {
if (!data || data.length === 0) {
return null;
}
// 计算最大值和最小值,用于归一化
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1; // 防止除以0
// 将数据点转换为 SVG 路径坐标
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * width;
const y = height - ((value - min) / range) * height;
return `${x.toFixed(2)},${y.toFixed(2)}`;
});
// 构建 SVG 路径字符串
const pathD = `M ${points.join(' L ')}`;
// 判断整体趋势(比较第一个和最后一个值)
const isPositive = data[data.length - 1] >= data[0];
const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌
// 创建渐变填充区域路径
const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`;
return (
<Box width={`${width}px`} height={`${height}px`}>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<defs>
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
</linearGradient>
</defs>
{/* 填充区域 */}
<path
d={fillPathD}
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
/>
{/* 折线 */}
<path
d={pathD}
fill="none"
stroke={strokeColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 垂直分隔线(标记三个时间段) */}
{/* 前一天和当天之间 */}
<line
x1={width / 3}
y1={0}
x2={width / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
{/* 当天和后一天之间 */}
<line
x1={(width * 2) / 3}
y1={0}
x2={(width * 2) / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
</svg>
</Box>
);
};
export default MiniLineChart;

View File

@@ -0,0 +1,85 @@
// src/components/EventDetailPanel/RelatedConceptsSection/ConceptStockItem.js
// 概念股票列表项组件
import React from 'react';
import {
Box,
HStack,
Text,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 概念股票列表项组件
* @param {Object} props
* @param {Object} props.stock - 股票对象兼容新旧API格式
* - name / stock_name: 股票名称
* - code / stock_code: 股票代码
* - change_pct: 涨跌幅(可选)
* - reason: 关联原因(可选)
*/
const ConceptStockItem = ({ stock }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
// 兼容新旧API格式
const stockName = stock.name || stock.stock_name || '未知';
const stockCode = stock.code || stock.stock_code || '';
// 涨跌幅可能不存在v2 API不返回单个股票涨跌幅
const hasChangePct = stock.change_pct !== undefined && stock.change_pct !== null;
const stockChangePct = hasChangePct ? parseFloat(stock.change_pct) : 0;
const stockChangeColor = stockChangePct > 0 ? 'red' : stockChangePct < 0 ? 'green' : 'gray';
const stockChangeSymbol = stockChangePct > 0 ? '+' : '';
// 处理股票详情跳转
const handleStockClick = (e) => {
e.stopPropagation(); // 阻止事件冒泡到概念卡片
const cleanCode = stockCode.replace(/\.(SZ|SH)$/i, '');
window.open(`https://valuefrontier.cn/company?scode=${cleanCode}`, '_blank');
};
return (
<Box
p={2}
borderRadius="md"
bg={sectionBg}
fontSize="xs"
cursor="pointer"
onClick={handleStockClick}
_hover={{
bg: useColorModeValue('gray.100', 'gray.700'),
transform: 'translateX(4px)',
}}
transition="all 0.2s"
>
<HStack justify="space-between" mb={1}>
<HStack spacing={2}>
<Text fontWeight="semibold" color={conceptNameColor}>
{stockName}
</Text>
<Badge size="sm" variant="outline">
{stockCode}
</Badge>
</HStack>
{hasChangePct && (
<Badge
colorScheme={stockChangeColor}
fontSize="xs"
>
{stockChangeSymbol}{stockChangePct.toFixed(2)}%
</Badge>
)}
</HStack>
{stock.reason && (
<Text fontSize="xs" color={stockCountColor} mt={1} noOfLines={2}>
{stock.reason}
</Text>
)}
</Box>
);
};
export default ConceptStockItem;

View File

@@ -0,0 +1,191 @@
// src/components/EventDetailPanel/RelatedConceptsSection/DetailedConceptCard.js
// 详细概念卡片组件
import React from 'react';
import {
Box,
HStack,
VStack,
Text,
Badge,
Card,
CardBody,
Divider,
SimpleGrid,
useColorModeValue,
} from '@chakra-ui/react';
import ConceptStockItem from './ConceptStockItem';
/**
* 详细概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象(兼容 v1/v2 API
* - concept: 概念名称
* - stock_count: 相关股票数量
* - score: 相关度0-1
* - price_info.avg_change_pct: 平均涨跌幅
* - description: 概念描述
* - outbreak_dates / happened_times: 爆发日期数组
* - stocks: 相关股票数组 [{ name/stock_name, code/stock_code }]
* @param {Function} props.onClick - 点击回调
*/
const DetailedConceptCard = ({ concept, onClick }) => {
const cardBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
// 计算相关度百分比
const relevanceScore = Math.round((concept.score || 0) * 100);
// 计算涨跌幅颜色
const changePct = parseFloat(concept.price_info?.avg_change_pct);
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
const changeSymbol = changePct > 0 ? '+' : '';
return (
<Card
bg={cardBg}
borderColor={borderColor}
borderWidth="2px"
cursor="pointer"
transition="all 0.3s"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'xl',
borderColor: 'blue.400'
}}
onClick={() => onClick(concept)}
>
<CardBody p={4}>
<VStack spacing={3} align="stretch">
{/* 头部信息 */}
<HStack justify="space-between" align="flex-start">
{/* 左侧:概念名称 + Badge */}
<VStack align="start" spacing={2} flex={1}>
<Text fontSize="md" fontWeight="bold" color="blue.600">
{concept.concept}
</Text>
<HStack spacing={2} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="xs">
相关度: {relevanceScore}%
</Badge>
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
</HStack>
</VStack>
{/* 右侧:涨跌幅 */}
{concept.price_info?.avg_change_pct && (
<Box textAlign="right">
<Text fontSize="xs" color={stockCountColor} mb={1}>
平均涨跌幅
</Text>
<Badge
size="lg"
colorScheme={changeColor}
fontSize="md"
px={3}
py={1}
>
{changeSymbol}{changePct.toFixed(2)}%
</Badge>
</Box>
)}
</HStack>
<Divider />
{/* 概念描述 */}
{concept.description && (
<Text
fontSize="sm"
color={stockCountColor}
lineHeight="1.6"
noOfLines={3}
>
{concept.description}
</Text>
)}
{/* 爆发日期(兼容 happened_times 和 outbreak_dates */}
{((concept.outbreak_dates && concept.outbreak_dates.length > 0) ||
(concept.happened_times && concept.happened_times.length > 0)) && (
<Box>
<Text fontSize="xs" fontWeight="semibold" mb={2} color={stockCountColor}>
爆发日期
</Text>
<HStack spacing={2} flexWrap="wrap">
{(concept.outbreak_dates || concept.happened_times).map((date, idx) => (
<Badge key={idx} variant="subtle" colorScheme="orange" fontSize="xs">
{date}
</Badge>
))}
</HStack>
</Box>
)}
{/* 核心相关股票 */}
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<HStack justify="space-between" mb={2}>
<Text fontSize="sm" fontWeight="semibold" color={headingColor}>
核心相关股票
</Text>
<Text fontSize="xs" color={stockCountColor}>
{concept.stock_count}
</Text>
</HStack>
{/* 可滚动容器 - 默认显示4条可滚动查看全部 */}
<Box
maxH="300px"
overflowY="auto"
pr={2}
onWheel={(e) => {
const element = e.currentTarget;
const scrollTop = element.scrollTop;
const scrollHeight = element.scrollHeight;
const clientHeight = element.clientHeight;
// 如果在滚动范围内,阻止事件冒泡到父容器
if (
(e.deltaY < 0 && scrollTop > 0) || // 向上滚动且未到顶部
(e.deltaY > 0 && scrollTop + clientHeight < scrollHeight) // 向下滚动且未到底部
) {
e.stopPropagation();
}
}}
css={{
overscrollBehavior: 'contain', // 防止滚动链
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
},
'&::-webkit-scrollbar-thumb': {
background: '#888',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#555',
},
}}
>
<SimpleGrid columns={{ base: 1 }} spacing={2}>
{concept.stocks.map((stock, idx) => (
<ConceptStockItem key={idx} stock={stock} />
))}
</SimpleGrid>
</Box>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
};
export default DetailedConceptCard;

View File

@@ -0,0 +1,103 @@
// src/components/EventDetailPanel/RelatedConceptsSection/SimpleConceptCard.js
// 简单概念卡片组件(横向卡片)
import React from 'react';
import {
Flex,
VStack,
Box,
Text,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 简单概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象
* - name: 概念名称
* - stock_count: 相关股票数量
* - relevance: 相关度0-100
* @param {Function} props.onClick - 点击回调
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
*/
const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
const cardBg = useColorModeValue('white', 'gray.700');
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
const borderColor = useColorModeValue('gray.300', 'gray.600');
const relevanceScore = Math.round((concept.score || 0) * 100);
const relevanceColors = getRelevanceColor(relevanceScore);
// 涨跌幅数据
const changePct = concept.price_info?.avg_change_pct ? parseFloat(concept.price_info.avg_change_pct) : null;
const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null;
const changeSymbol = changePct !== null && changePct > 0 ? '+' : '';
return (
<VStack
align="stretch"
spacing={1}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
px={2}
py={1}
cursor="pointer"
transition="all 0.2s"
minW="100px"
_hover={{
transform: 'translateY(-1px)',
boxShadow: 'md',
}}
onClick={() => onClick(concept)}
>
{/* 第一行:概念名 + 数量(允许折行) */}
<Text
fontSize="sm"
fontWeight="normal"
color={conceptNameColor}
wordBreak="break-word"
lineHeight="1.4"
>
{concept.concept}{' '}
<Text as="span" color="gray.500">
({concept.stock_count})
</Text>
</Text>
{/* 第二行:相关度 + 涨跌幅 */}
<Flex justify="space-between" align="center" gap={1} flexWrap="wrap">
{/* 相关度标签 */}
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
px={1.5}
py={0.5}
borderRadius="sm"
flexShrink={0}
>
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}%
</Text>
</Box>
{/* 涨跌幅数据 */}
{changePct !== null && (
<Badge
colorScheme={changeColor}
fontSize="10px"
px={1.5}
py={0.5}
flexShrink={0}
>
{changeSymbol}{changePct.toFixed(2)}%
</Badge>
)}
</Flex>
</VStack>
);
};
export default SimpleConceptCard;

View File

@@ -0,0 +1,41 @@
// src/components/EventDetailPanel/RelatedConceptsSection/TradingDateInfo.js
// 交易日期信息提示组件
import React from 'react';
import {
HStack,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { FaCalendarAlt } from 'react-icons/fa';
import dayjs from 'dayjs';
/**
* 交易日期信息提示组件
* @param {Object} props
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间
*/
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
if (!effectiveTradingDate) {
return null;
}
return (
<HStack spacing={2}>
<FaCalendarAlt color="gray" size={12} />
<Text fontSize="xs" color={stockCountColor}>
涨跌幅数据{effectiveTradingDate}
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
</Text>
)}
</Text>
</HStack>
);
};
export default TradingDateInfo;

View File

@@ -0,0 +1,305 @@
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
// 相关概念区组件(主组件)
import React, { useState, useEffect } from 'react';
import {
Box,
SimpleGrid,
Flex,
Button,
Collapse,
Heading,
Center,
Spinner,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import SimpleConceptCard from './SimpleConceptCard';
import DetailedConceptCard from './DetailedConceptCard';
import TradingDateInfo from './TradingDateInfo';
import { logger } from '../../../../../utils/logger';
/**
* 相关概念区组件
* @param {Object} props
* @param {string} props.eventTitle - 事件标题(用于搜索概念)
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费)
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
*/
const RelatedConceptsSection = ({
eventTitle,
effectiveTradingDate,
eventTime,
subscriptionBadge = null,
isLocked = false,
onLockedClick = null,
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
onToggle = undefined // 新增:受控模式(外部控制展开回调)
}) => {
// 使用外部 isOpen如果没有则使用内部 useState
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useNavigate();
// 颜色配置
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const textColor = useColorModeValue('gray.600', 'gray.400');
console.log('[RelatedConceptsSection] 组件渲染', {
eventTitle,
effectiveTradingDate,
eventTime,
loading,
conceptsCount: concepts?.length || 0,
error
});
// 搜索相关概念
useEffect(() => {
const searchConcepts = async () => {
console.log('[RelatedConceptsSection] useEffect 触发', {
eventTitle,
effectiveTradingDate
});
if (!eventTitle || !effectiveTradingDate) {
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
hasEventTitle: !!eventTitle,
hasEffectiveTradingDate: !!effectiveTradingDate
});
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// 格式化交易日期 - 统一使用 moment 处理
let formattedTradeDate;
try {
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
// 验证日期是否有效
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
formattedTradeDate = dayjs().format('YYYY-MM-DD');
}
} catch (error) {
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
formattedTradeDate = dayjs().format('YYYY-MM-DD');
}
const requestBody = {
query: eventTitle,
size: 5,
page: 1,
sort_by: "_score",
trade_date: formattedTradeDate
};
console.log('[RelatedConceptsSection] 发送请求', {
url: '/concept-api/search',
requestBody
});
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
const response = await fetch('/concept-api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
console.log('[RelatedConceptsSection] 响应状态', {
ok: response.ok,
status: response.status,
statusText: response.statusText
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('[RelatedConceptsSection] 响应数据', {
hasResults: !!data.results,
resultsCount: data.results?.length || 0,
hasDataConcepts: !!(data.data && data.data.concepts),
data: data
});
logger.debug('RelatedConceptsSection', '概念搜索响应', {
hasResults: !!data.results,
resultsCount: data.results?.length || 0
});
// 设置概念数据
if (data.results && Array.isArray(data.results)) {
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
setConcepts(data.results);
} else if (data.data && data.data.concepts) {
// 向后兼容
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
setConcepts(data.data.concepts);
} else {
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
setConcepts([]);
}
} catch (err) {
console.error('[RelatedConceptsSection] 搜索概念失败', err);
logger.error('RelatedConceptsSection', 'searchConcepts', err);
setError('加载概念数据失败');
setConcepts([]);
} finally {
console.log('[RelatedConceptsSection] 加载完成');
setLoading(false);
}
};
searchConcepts();
}, [eventTitle, effectiveTradingDate]);
// 加载中状态
if (loading) {
return (
<Box bg={sectionBg} p={3} borderRadius="md">
<Center py={4}>
<Spinner size="md" color="blue.500" mr={2} />
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
</Center>
</Box>
);
}
// 判断是否有数据
const hasNoConcepts = !concepts || concepts.length === 0;
/**
* 根据相关度获取颜色(浅色背景 + 深色文字)
* @param {number} relevance - 相关度0-100
* @returns {Object} 包含背景色和文字色
*/
const getRelevanceColor = (relevance) => {
if (relevance >= 90) {
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
} else if (relevance >= 80) {
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
} else if (relevance >= 70) {
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
} else {
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
}
};
/**
* 处理概念点击
* @param {Object} concept - 概念对象
*/
const handleConceptClick = (concept) => {
// 跳转到概念中心,并搜索该概念
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
};
return (
<Box bg={sectionBg} p={3} borderRadius="md">
{/* 标题栏 - 两行布局 */}
<Box mb={3}>
{/* 第一行:标题 + Badge + 按钮 */}
<Flex justify="space-between" align="center" mb={2}>
<Flex align="center" gap={2}>
<Heading size="sm" color={headingColor}>
相关概念
</Heading>
{/* 订阅徽章 */}
{subscriptionBadge}
</Flex>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={() => {
// 如果被锁定且有回调函数,触发付费弹窗
if (isLocked && onLockedClick) {
onLockedClick();
} else if (onToggle !== undefined) {
// 受控模式:调用外部回调
onToggle();
} else {
// 非受控模式:使用内部状态
setInternalExpanded(!internalExpanded);
}
}}
>
{isExpanded ? '收起' : '查看详细'}
</Button>
</Flex>
{/* 第二行:交易日期信息 */}
<TradingDateInfo
effectiveTradingDate={effectiveTradingDate}
eventTime={eventTime}
/>
</Box>
{/* 简单模式:横向卡片列表(总是显示) */}
{hasNoConcepts ? (
<Box mb={isExpanded ? 3 : 0}>
{error ? (
<Text color="red.500" fontSize="sm">{error}</Text>
) : (
<Text color={textColor} fontSize="sm">暂无相关概念数据</Text>
)}
</Box>
) : (
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
{concepts.map((concept, index) => (
<SimpleConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
getRelevanceColor={getRelevanceColor}
/>
))}
</Flex>
)}
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{hasNoConcepts ? (
<Box py={4}>
{error ? (
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
) : (
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
)}
</Box>
) : (
/* 详细概念卡片网格 */
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
<DetailedConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
/>
))}
</SimpleGrid>
)}
</Collapse>
</Box>
);
};
export default RelatedConceptsSection;

View File

@@ -0,0 +1,182 @@
// src/components/EventDetailPanel/RelatedStocksSection.js
// 相关股票列表区组件(纯内容,不含标题)
import React, { useState, useEffect, useMemo } from 'react';
import { VStack } from '@chakra-ui/react';
import dayjs from 'dayjs';
import StockListItem from './StockListItem';
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '@utils/stock/klineDataCache';
import { logger } from '@utils/logger';
/**
* 相关股票列表区组件(纯内容部分)
* 只负责渲染详细的股票列表,精简模式由外层 CollapsibleSection 的 simpleContent 提供
* @param {Object} props
* @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
* @param {string} props.eventTime - 事件时间
* @param {Function} props.isInWatchlist - 检查股票是否在自选股中的函数
* @param {Function} props.onWatchlistToggle - 切换自选股回调
*/
const RelatedStocksSection = ({
stocks,
quotes = {},
eventTime = null,
isInWatchlist = () => false,
onWatchlistToggle
}) => {
// 分时图数据状态:{ [stockCode]: data[] }
const [timelineDataMap, setTimelineDataMap] = useState({});
const [timelineLoading, setTimelineLoading] = useState(false);
// 日K线数据状态{ [stockCode]: data[] }
const [dailyDataMap, setDailyDataMap] = useState({});
const [dailyLoading, setDailyLoading] = useState(false);
// 稳定的事件时间
const stableEventTime = useMemo(() => {
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
// 稳定的股票列表 key
const stocksKey = useMemo(() => {
if (!stocks || stocks.length === 0) return '';
return stocks.map(s => s.stock_code).sort().join(',');
}, [stocks]);
// 计算分时图是否应该显示 loading
const shouldShowTimelineLoading = useMemo(() => {
if (!stocks || stocks.length === 0) return false;
const currentDataKeys = Object.keys(timelineDataMap).sort().join(',');
if (stocksKey !== currentDataKeys) {
return true;
}
return timelineLoading;
}, [stocks, stocksKey, timelineDataMap, timelineLoading]);
// 计算日K线是否应该显示 loading
const shouldShowDailyLoading = useMemo(() => {
if (!stocks || stocks.length === 0) return false;
const currentDataKeys = Object.keys(dailyDataMap).sort().join(',');
if (stocksKey !== currentDataKeys) {
return true;
}
return dailyLoading;
}, [stocks, stocksKey, dailyDataMap, dailyLoading]);
// 批量加载分时图数据
useEffect(() => {
if (!stocks || stocks.length === 0) {
setTimelineDataMap({});
setTimelineLoading(false);
return;
}
setTimelineLoading(true);
const stockCodes = stocks.map(s => s.stock_code);
// 检查缓存
const cachedData = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, stableEventTime, 'timeline');
const cached = klineDataCache.get(cacheKey);
if (cached !== undefined) {
cachedData[code] = cached;
}
});
if (Object.keys(cachedData).length === stockCodes.length) {
setTimelineDataMap(cachedData);
setTimelineLoading(false);
logger.debug('RelatedStocksSection', '分时图数据全部来自缓存', { stockCount: stockCodes.length });
return;
}
logger.debug('RelatedStocksSection', '批量加载分时图数据', {
totalCount: stockCodes.length,
eventTime: stableEventTime
});
fetchBatchKlineData(stockCodes, stableEventTime, 'timeline')
.then((batchData) => {
setTimelineDataMap({ ...cachedData, ...batchData });
setTimelineLoading(false);
})
.catch((error) => {
logger.error('RelatedStocksSection', '批量加载分时图数据失败', error);
setTimelineDataMap(cachedData);
setTimelineLoading(false);
});
}, [stocksKey, stableEventTime]);
// 批量加载日K线数据
useEffect(() => {
if (!stocks || stocks.length === 0) {
setDailyDataMap({});
setDailyLoading(false);
return;
}
setDailyLoading(true);
const stockCodes = stocks.map(s => s.stock_code);
// 检查缓存
const cachedData = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, stableEventTime, 'daily');
const cached = klineDataCache.get(cacheKey);
if (cached !== undefined) {
cachedData[code] = cached;
}
});
if (Object.keys(cachedData).length === stockCodes.length) {
setDailyDataMap(cachedData);
setDailyLoading(false);
logger.debug('RelatedStocksSection', '日K线数据全部来自缓存', { stockCount: stockCodes.length });
return;
}
logger.debug('RelatedStocksSection', '批量加载日K线数据', {
totalCount: stockCodes.length,
eventTime: stableEventTime
});
fetchBatchKlineData(stockCodes, stableEventTime, 'daily')
.then((batchData) => {
setDailyDataMap({ ...cachedData, ...batchData });
setDailyLoading(false);
})
.catch((error) => {
logger.error('RelatedStocksSection', '批量加载日K线数据失败', error);
setDailyDataMap(cachedData);
setDailyLoading(false);
});
}, [stocksKey, stableEventTime]);
// 如果没有股票数据,不渲染
if (!stocks || stocks.length === 0) {
return null;
}
return (
<VStack align="stretch" spacing={3}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={isInWatchlist(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
timelineData={timelineDataMap[stock.stock_code]}
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}
dailyData={dailyDataMap[stock.stock_code]}
dailyLoading={shouldShowDailyLoading && !dailyDataMap[stock.stock_code]}
/>
))}
</VStack>
);
};
export default RelatedStocksSection;

View File

@@ -0,0 +1,477 @@
// src/components/EventDetailPanel/StockListItem.js
// 股票卡片组件(融合表格功能的卡片样式)
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
Flex,
VStack,
HStack,
Text,
Button,
IconButton,
Collapse,
Tooltip,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import { Tag } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import MiniTimelineChart from '@views/Community/components/StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import TimelineChartModal from '@components/StockChart/TimelineChartModal';
import KLineChartModal from '@components/StockChart/KLineChartModal';
import { getChangeColor } from '@utils/colorUtils';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
/**
* 股票卡片组件
* @param {Object} props
* @param {Object} props.stock - 股票对象
* @param {string} props.stock.stock_name - 股票名称
* @param {string} props.stock.stock_code - 股票代码
* @param {string} props.stock.relation_desc - 关联描述
* @param {Object} props.quote - 股票行情数据(可选)
* @param {number} props.quote.change - 涨跌幅
* @param {string} props.eventTime - 事件时间(可选)
* @param {boolean} props.isInWatchlist - 是否在自选股中
* @param {Function} props.onWatchlistToggle - 切换自选股回调
* @param {Array} props.timelineData - 预加载的分时图数据(可选,由父组件批量加载后传入)
* @param {boolean} props.timelineLoading - 分时图数据加载状态
* @param {Array} props.dailyData - 预加载的日K线数据可选由父组件批量加载后传入
* @param {boolean} props.dailyLoading - 日K线数据加载状态
*/
const StockListItem = ({
stock,
quote = null,
eventTime = null,
isInWatchlist = false,
onWatchlistToggle,
timelineData,
timelineLoading = false,
dailyData,
dailyLoading = false
}) => {
const isMobile = useSelector(selectIsMobile);
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const codeColor = '#3B82F6';
const nameColor = PROFESSIONAL_COLORS.text.primary;
const descColor = PROFESSIONAL_COLORS.text.secondary;
const dividerColor = PROFESSIONAL_COLORS.border.default;
const [isDescExpanded, setIsDescExpanded] = useState(false);
const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false);
const [isKLineModalOpen, setIsKLineModalOpen] = useState(false);
const handleViewDetail = () => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
};
const handleWatchlistClick = (e) => {
e.stopPropagation();
onWatchlistToggle?.(stock.stock_code, stock.stock_name, isInWatchlist);
};
// 格式化涨跌幅显示
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 使用工具函数获取涨跌幅颜色(已从 colorUtils 导入)
// 获取涨跌幅数据(优先使用 quotefallback 到 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;

View File

@@ -0,0 +1,6 @@
// src/components/EventDetailPanel/index.js
// 统一导出事件详情面板组件
export { default } from './DynamicNewsDetailPanel';
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';
export { default as EventDetailPanel } from './DynamicNewsDetailPanel';

View File

@@ -0,0 +1,277 @@
/* --- 全局与通用增强 --- */
/* 为交互元素增加平滑的过渡效果,提升用户体验 */
.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;
}

View File

@@ -0,0 +1,975 @@
// src/components/InvestmentCalendar/index.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;

View File

@@ -10,7 +10,7 @@ import {
ModalCloseButton
} from '@chakra-ui/react';
import { FiCalendar } from 'react-icons/fi';
import InvestmentCalendar from '../../../views/Community/components/InvestmentCalendar';
import InvestmentCalendar from '@components/InvestmentCalendar';
/**
* 投资日历按钮组件

View File

@@ -19,7 +19,7 @@ import {
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache';
import { selectIsMobile } from '@store/slices/deviceSlice';
import { StockInfo } from './types';

View File

@@ -9,7 +9,7 @@ import type { Chart } from 'klinecharts';
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
import { processChartData } from '../utils/dataAdapter';
import { stockService } from '@services/eventService';
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
import { klineDataCache, getCacheKey } from '@utils/stock/klineDataCache';
export interface UseKLineDataOptions {
/** KLineChart 实例 */