Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event

This commit is contained in:
2025-11-07 19:55:16 +08:00
12 changed files with 607 additions and 251 deletions

View File

@@ -1101,6 +1101,70 @@ feat: 删除不需要的组件
---
### 2025-01-XX: CollapsibleSection 支持精简/详细模式切换
**变更类型**: 功能增强
**优化内容**: 为事件详情面板的 CollapsibleSection 组件添加精简/详细双模式切换功能,提升用户浏览体验。
**新增功能**:
1. **CollapsibleHeader.js** - 支持模式切换按钮
- 新增 `showModeToggle` prop是否显示模式切换按钮
- 新增 `currentMode` prop当前模式'detailed' | 'simple'
- 新增 `onModeToggle` prop模式切换回调
- 按钮样式:
- 详细模式展开:`详细模式 ▲`
- 详细模式收起:`详细模式 ▼`
- 精简模式:`精简模式`
- 向后兼容:不提供 `showModeToggle` 时,保持原有的 IconButton 样式
2. **CollapsibleSection.js** - 支持精简/详细模式
- 新增 `simpleContent` prop精简模式的内容
- 新增 `showModeToggle` prop是否显示模式切换按钮
- 新增 `defaultMode` prop默认模式默认 'detailed'
- 模式切换逻辑:
- 详细模式 → 精简模式:显示 simpleContent
- 精简模式 → 详细模式:自动展开 children
- 权限控制:锁定状态下点击模式切换,触发付费弹窗
3. **SimpleStocksList.js** - 新增精简股票列表组件
- 横向展示股票名称和涨跌幅
- 自动根据涨跌幅显示颜色(红涨绿跌)
- 支持加载中状态
- 响应式设计,自动折行
4. **DynamicNewsDetailPanel.js** - 相关股票模块启用双模式
- 添加 `showModeToggle={true}`
- 添加 `simpleContent`:使用 SimpleStocksList 显示精简股票列表
- `children`:保持原有的 RelatedStocksSection完整信息
- 默认详细模式展开PRO 会员)
**用户体验提升**:
- ✅ 支持精简/详细两种浏览模式,满足不同场景需求
- ✅ 精简模式:快速浏览股票名称和涨跌幅
- ✅ 详细模式:查看完整股票信息(价格、按钮等)
- ✅ 一键切换,无需重新加载数据
- ✅ PRO 会员默认展开详细模式,优化会员体验
**技术细节**:
- 使用 React useState 管理模式状态
- 模式切换不触发数据重新加载
- 向后兼容:不使用 `showModeToggle` 的 CollapsibleSection 保持原有行为
- 权限控制:锁定状态下切换模式触发付费引导
**影响范围**:
- `src/views/Community/components/DynamicNewsDetail/CollapsibleHeader.js`
- `src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js`
- `src/views/Community/components/DynamicNewsDetail/SimpleStocksList.js`(新增)
- `src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js`
**扩展性**:
- 其他模块(历史事件对比、传导链分析)可以复用此功能
- 只需提供对应的 `simpleContent` 即可启用双模式
---
## 🔗 相关文档
- [项目总览 - CLAUDE.md](../CLAUDE.md)

View File

@@ -13,13 +13,21 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail';
* @param {string} scrollbarTrackBg - 滚动条轨道背景色
* @param {string} scrollbarThumbBg - 滚动条滑块背景色
* @param {string} scrollbarThumbHoverBg - 滚动条滑块悬浮背景色
* @param {string} detailMode - 详情模式:'full' | 'no-header'(默认 'full'
* @param {boolean} showHeader - 是否显示头部(可选,优先级高于 detailMode
*/
const EventDetailScrollPanel = ({
selectedEvent,
scrollbarTrackBg,
scrollbarThumbBg,
scrollbarThumbHoverBg,
detailMode = 'full',
showHeader,
}) => {
// 计算是否显示头部showHeader 显式指定时优先,否则根据 detailMode 判断
const shouldShowHeader = showHeader !== undefined
? showHeader
: detailMode === 'full';
return (
<Box
pl={2}
@@ -47,7 +55,7 @@ const EventDetailScrollPanel = ({
}}
>
{selectedEvent ? (
<DynamicNewsDetailPanel event={selectedEvent} />
<DynamicNewsDetailPanel event={selectedEvent} showHeader={shouldShowHeader} />
) : (
<Center h="100%" minH="400px">
<VStack spacing={4}>

View File

@@ -112,6 +112,7 @@ const VerticalModeLayout = ({
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
layout="vertical"
/>
))}
</VStack>
@@ -161,6 +162,7 @@ const VerticalModeLayout = ({
{/* 详情面板 */}
<EventDetailScrollPanel
key={detailPanelKey}
detailMode="no-header"
selectedEvent={selectedEvent}
/>
</Box>

View File

@@ -9,6 +9,7 @@ import {
Heading,
Badge,
IconButton,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
@@ -21,22 +22,53 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
* @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 }) => {
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={onToggle}
cursor={showModeToggle ? 'default' : 'pointer'}
onClick={showModeToggle ? undefined : onToggle}
p={3}
bg={sectionBg}
borderRadius="md"
_hover={{ bg: hoverBg }}
_hover={showModeToggle ? {} : { bg: hoverBg }}
transition="background 0.2s"
>
<HStack spacing={2}>
@@ -54,12 +86,32 @@ const CollapsibleHeader = ({ title, isOpen, onToggle, count = null, subscription
</Badge>
)}
</HStack>
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
{showModeToggle && onModeToggle && (
<Button
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={getButtonIcon()}
onClick={(e) => {
e.stopPropagation();
onModeToggle(e);
}}
>
{getButtonText()}
</Button>
)}
{/* showModeToggle=false 时显示原有的 IconButton */}
{!showModeToggle && (
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
)}
</Flex>
);
};

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
// 通用可折叠区块组件
import React from 'react';
import React, { useState } from 'react';
import {
Box,
Collapse,
@@ -19,7 +19,10 @@ import CollapsibleHeader from './CollapsibleHeader';
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.isLocked - 是否锁定(不可展开)
* @param {Function} props.onLockedClick - 锁定时点击的回调
* @param {React.ReactNode} props.children - 内容
* @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,
@@ -29,10 +32,16 @@ const CollapsibleSection = ({
subscriptionBadge = null,
isLocked = false,
onLockedClick = null,
children
children,
simpleContent = null,
showModeToggle = false,
defaultMode = 'detailed'
}) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
// 模式状态:'detailed' | 'simple'
const [displayMode, setDisplayMode] = useState(defaultMode);
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
const handleToggle = () => {
if (isLocked && onLockedClick) {
@@ -42,15 +51,43 @@ const CollapsibleSection = ({
}
};
return (
<Box>
<CollapsibleHeader
title={title}
isOpen={isOpen}
onToggle={handleToggle}
count={count}
subscriptionBadge={subscriptionBadge}
/>
// 处理模式切换
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
@@ -61,6 +98,25 @@ const CollapsibleSection = ({
{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>
);
};

View File

@@ -0,0 +1,100 @@
// src/views/Community/components/DynamicNewsDetail/CompactMetaBar.js
// 精简信息栏组件(无头部模式下右上角显示)
import React from 'react';
import {
HStack,
Badge,
Text,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import EventFollowButton from '../EventCard/EventFollowButton';
/**
* 精简信息栏组件
* 在无头部模式下,显示在 CardBody 右上角
* 包含:重要性徽章、浏览次数、关注按钮
*
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {Object} props.importance - 重要性配置对象(包含 level, icon 等)
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {Function} props.onToggleFollow - 切换关注回调
*/
const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
const viewCountBg = useColorModeValue('white', 'gray.700');
const viewCountTextColor = useColorModeValue('gray.600', 'gray.300');
// 获取重要性文本
const getImportanceText = () => {
const levelMap = {
'S': '极高',
'A': '高',
'B': '中',
'C': '低'
};
return levelMap[importance.level] || '中';
};
return (
<HStack
position="absolute"
top={3}
right={3}
spacing={3}
zIndex={1}
>
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 */}
<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="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

@@ -10,6 +10,8 @@ import {
Text,
Spinner,
Center,
Wrap,
WrapItem,
useColorModeValue,
useToast,
} from '@chakra-ui/react';
@@ -19,9 +21,11 @@ import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import { useAuth } from '../../../../contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection';
import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
@@ -32,8 +36,9 @@ import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgrade
* 动态新闻详情面板主组件
* @param {Object} props
* @param {Object} props.event - 事件对象(包含详情数据)
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true
*/
const DynamicNewsDetailPanel = ({ event }) => {
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const dispatch = useDispatch();
const { user } = useAuth();
const cardBg = useColorModeValue('white', 'gray.800');
@@ -111,8 +116,8 @@ const DynamicNewsDetailPanel = ({ event }) => {
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// PRO 会员的相关股票默认展开
const [isStocksOpen, setIsStocksOpen] = useState(canAccessStocks && userTier === 'pro');
// 初始值为 false由 useEffect 根据权限动态设置
const [isStocksOpen, setIsStocksOpen] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
@@ -199,14 +204,18 @@ const DynamicNewsDetailPanel = ({ event }) => {
// 🎯 加载事件详情(增加浏览量)
loadEventDetail();
// PRO 会员的相关股票默认展开,其他情况收起
const shouldOpenStocks = canAccessStocks && userTier === 'pro';
// PRO 和 MAX 会员的相关股票默认展开,其他情况收起
const shouldOpenStocks = canAccessStocks;
setIsStocksOpen(shouldOpenStocks);
setHasLoadedStocks(false);
// PRO 会员默认展开时,自动加载股票数据
if (shouldOpenStocks) {
console.log('%c📊 [PRO会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
// PRO 和 MAX 会员自动加载股票数据(无论是否展开)
const shouldLoadStocks = canAccessStocks; // PRO 或 MAX 都有权限
if (shouldLoadStocks) {
console.log('%c📊 [PRO/MAX会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
userTier
});
loadStocksData();
setHasLoadedStocks(true);
}
@@ -278,21 +287,34 @@ const DynamicNewsDetailPanel = ({ event }) => {
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<VStack align="stretch" spacing={3}>
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) */}
<EventHeaderInfo
<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 权限 */}
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection
title="相关股票"
isOpen={isStocksOpen}
@@ -306,6 +328,27 @@ const DynamicNewsDetailPanel = ({ event }) => {
})()}
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}>

View File

@@ -38,9 +38,13 @@ const RelatedConceptsSection = ({
eventTime,
subscriptionBadge = null,
isLocked = false,
onLockedClick = null
onLockedClick = null,
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
onToggle = undefined // 新增:受控模式(外部控制展开回调)
}) => {
const [isExpanded, setIsExpanded] = useState(false);
// 使用外部 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);
@@ -179,10 +183,8 @@ const RelatedConceptsSection = ({
);
}
// 如果没有概念,不渲染
if (!concepts || concepts.length === 0) {
return null;
}
// 判断是否有数据
const hasNoConcepts = !concepts || concepts.length === 0;
/**
* 根据相关度获取颜色(浅色背景 + 深色文字)
@@ -232,9 +234,12 @@ const RelatedConceptsSection = ({
// 如果被锁定且有回调函数,触发付费弹窗
if (isLocked && onLockedClick) {
onLockedClick();
} else if (onToggle !== undefined) {
// 受控模式:调用外部回调
onToggle();
} else {
// 否则正常展开/收起
setIsExpanded(!isExpanded);
// 非受控模式:使用内部状态
setInternalExpanded(!internalExpanded);
}
}}
>
@@ -249,30 +254,49 @@ const RelatedConceptsSection = ({
</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>
{/* 详细概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{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) => (
<DetailedConceptCard
<SimpleConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
getRelevanceColor={getRelevanceColor}
/>
))}
</SimpleGrid>
</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>
);

View File

@@ -1,21 +1,13 @@
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
// 相关股票列表区组件(纯内容,不含标题)
import React, { useState } from 'react';
import {
VStack,
Flex,
Button,
ButtonGroup,
Wrap,
WrapItem,
} from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import React from 'react';
import { VStack } from '@chakra-ui/react';
import StockListItem from './StockListItem';
import CompactStockItem from './CompactStockItem';
/**
* 相关股票列表区组件(纯内容部分)
* 只负责渲染详细的股票列表,精简模式由外层 CollapsibleSection 的 simpleContent 提供
* @param {Object} props
* @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
@@ -30,67 +22,23 @@ const RelatedStocksSection = ({
watchlistSet = new Set(),
onWatchlistToggle
}) => {
// 显示模式:'detail' 详情模式, 'compact' 精简模式
const [viewMode, setViewMode] = useState('detail');
// 如果没有股票数据,不渲染
if (!stocks || stocks.length === 0) {
return null;
}
return (
<VStack align="stretch" spacing={4}>
{/* 模式切换按钮 */}
<Flex justify="flex-end">
<ButtonGroup size="sm" isAttached variant="outline">
<Button
leftIcon={<ViewIcon />}
colorScheme={viewMode === 'detail' ? 'blue' : 'gray'}
variant={viewMode === 'detail' ? 'solid' : 'outline'}
onClick={() => setViewMode('detail')}
>
详情模式
</Button>
<Button
leftIcon={<ViewOffIcon />}
colorScheme={viewMode === 'compact' ? 'blue' : 'gray'}
variant={viewMode === 'compact' ? 'solid' : 'outline'}
onClick={() => setViewMode('compact')}
>
精简模式
</Button>
</ButtonGroup>
</Flex>
{/* 详情模式 */}
{viewMode === 'detail' && (
<VStack align="stretch" spacing={3}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</VStack>
)}
{/* 精简模式 */}
{viewMode === 'compact' && (
<Wrap spacing={4}>
{stocks.map((stock, index) => (
<WrapItem key={index}>
<CompactStockItem
stock={stock}
quote={quotes[stock.stock_code]}
/>
</WrapItem>
))}
</Wrap>
)}
<VStack align="stretch" spacing={3}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</VStack>
);
};

View File

@@ -12,12 +12,14 @@ import {
IconButton,
Collapse,
Tooltip,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import StockChartModal from '../../../../components/StockChart/StockChartModal';
import CitedContent from '../../../../components/Citation/CitedContent';
import { getChangeColor } from '../../../../utils/colorUtils';
/**
@@ -104,7 +106,7 @@ const StockListItem = ({
borderRadius="lg"
p={3}
position="relative"
overflow="hidden"
overflow="visible"
_before={{
content: '""',
position: 'absolute',
@@ -113,6 +115,8 @@ const StockListItem = ({
right: 0,
height: '3px',
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
}}
_hover={{
boxShadow: 'lg',
@@ -241,55 +245,73 @@ const StockListItem = ({
/>
</Box>
{/* 关联描述(单行显示,点击展开)- 占据更多空间 */}
{relationText && relationText !== '--' && (
<Tooltip
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
placement="top"
hasArrow
bg="gray.600"
color="white"
fontSize="xs"
>
<Box
flex={1}
minW={0}
onClick={(e) => {
e.stopPropagation();
setIsDescExpanded(!isDescExpanded);
}}
cursor="pointer"
px={3}
py={2}
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="md"
_hover={{
bg: useColorModeValue('gray.100', 'gray.600'),
}}
transition="background 0.2s"
>
{/* 去掉"关联描述"标题 */}
<Collapse in={isDescExpanded} startingHeight={40}>
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
{/* 关联描述 - 升级和降级处理 */}
{stock.relation_desc && (
<Box flex={1} minW={0}>
{stock.relation_desc?.data ? (
// 升级:带引用来源的版本
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
padding: '0',
}}
/>
) : (
// 降级:纯文本版本(保留展开/收起功能)
<Tooltip
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
placement="top"
hasArrow
bg="gray.600"
color="white"
fontSize="xs"
>
<Box
onClick={(e) => {
e.stopPropagation();
setIsDescExpanded(!isDescExpanded);
}}
cursor="pointer"
px={3}
py={2}
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="md"
_hover={{
bg: useColorModeValue('gray.100', 'gray.600'),
}}
transition="background 0.2s"
position="relative"
>
{relationText}
</Text>
</Collapse>
{isDescExpanded && (
<Text
fontSize="xs"
color="gray.500"
mt={2}
fontStyle="italic"
>
AI生成仅供参考
</Text>
)}
</Box>
</Tooltip>
{/* 去掉"关联描述"标题 */}
<Collapse in={isDescExpanded} startingHeight={40}>
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
>
{relationText}
</Text>
</Collapse>
{/* 提示信息 */}
{isDescExpanded && (
<Text
fontSize="xs"
color="gray.500"
mt={2}
fontStyle="italic"
>
AI生成仅供参考
</Text>
)}
</Box>
</Tooltip>
)}
</Box>
)}
</HStack>
</Box>

View File

@@ -34,6 +34,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
* @param {Object} props.timelineStyle - 时间轴样式配置
* @param {string} props.borderColor - 边框颜色
* @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large')
* @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度
*/
const HorizontalDynamicNewsEventCard = ({
event,
@@ -47,6 +48,7 @@ const HorizontalDynamicNewsEventCard = ({
timelineStyle,
borderColor,
indicatorSize = 'comfortable',
layout = 'vertical',
}) => {
const importance = getImportanceConfig(event.importance);
@@ -98,7 +100,7 @@ const HorizontalDynamicNewsEventCard = ({
createdAt={event.created_at}
timelineStyle={timelineStyle}
borderColor={borderColor}
minHeight="60px"
minHeight={layout === 'four-row' ? '60px' : 0}
/>
{/* 右侧事件卡片容器(带印章) */}

View File

@@ -31,6 +31,7 @@ import {
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
import CitedContent from '../../../components/Citation/CitedContent';
const HistoricalEvents = ({
events = [],
@@ -224,8 +225,8 @@ const HistoricalEvents = ({
</Box>
)}
{/* 历史事件卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{/* 历史事件卡片列表 - 混合布局 */}
<VStack spacing={3} align="stretch">
{events.map((event) => {
const importanceColor = getImportanceColor(event.importance);
@@ -235,92 +236,126 @@ const HistoricalEvents = ({
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
borderRadius="lg"
position="relative"
overflow="visible"
cursor="pointer"
onClick={() => handleCardClick(event)}
_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.400',
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 事件名称 */}
<Text
fontSize="md"
fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')}
noOfLines={2}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
handleCardClick(event);
}}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Text>
{/* 日期 + Badges */}
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}>
{formatDate(getEventDate(event))}
</Text>
<Text fontSize="sm" color={textSecondary}>
({getRelativeTime(getEventDate(event))})
</Text>
{event.relevance && (
<Badge colorScheme="blue" size="sm">
相关度: {event.relevance}
</Badge>
)}
{event.importance && (
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
)}
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
<Badge
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
size="sm"
<VStack align="stretch" spacing={2} p={3}>
{/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */}
<HStack align="flex-start" spacing={3}>
{/* 左侧:标题 + 时间信息(允许折行) */}
<VStack flex="1" align="flex-start" spacing={1}>
{/* 标题 */}
<Text
fontSize="md"
fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
handleCardClick(event);
}}
_hover={{ textDecoration: 'underline' }}
>
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
{event.title || '未命名事件'}
</Text>
{/* 时间 + Badges允许折行 */}
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}>
{formatDate(getEventDate(event))}
</Text>
<Text fontSize="sm" color={textSecondary}>
({getRelativeTime(getEventDate(event))})
</Text>
{event.importance && (
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
)}
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
<Badge
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
size="sm"
>
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
</HStack>
</VStack>
{/* 右侧:相关股票按钮 */}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={(e) => {
e.stopPropagation();
handleViewStocks(event);
}}
colorScheme="blue"
variant="outline"
flexShrink={0}
>
相关股票
</Button>
</HStack>
{/* 事件描述 */}
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
noOfLines={4}
>
{getEventContent(event) ? `${getEventContent(event)}AI合成` : '暂无内容'}
</Text>
{/* 相关股票按钮 */}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={(e) => {
e.stopPropagation();
handleViewStocks(event);
}}
colorScheme="blue"
variant="outline"
width="full"
>
相关股票
</Button>
{/* 底部:描述(独占整行)- 升级和降级处理 */}
<Box>
{(() => {
const content = getEventContent(event);
// 检查是否有 data 结构(升级版本)
if (content && typeof content === 'object' && content.data) {
return (
<CitedContent
data={content}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
padding: '0',
}}
/>
);
}
// 降级版本:纯文本
return (
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
noOfLines={2}
>
{content ? `${content}AI合成` : '暂无内容'}
</Text>
);
})()}
</Box>
</VStack>
</Box>
);
})}
</SimpleGrid>
</VStack>
{/* 相关股票 Modal - 条件渲染 */}
{stocksModalOpen && (