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) - [项目总览 - CLAUDE.md](../CLAUDE.md)

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import {
Heading, Heading,
Badge, Badge,
IconButton, IconButton,
Button,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
@@ -21,22 +22,53 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
* @param {Function} props.onToggle - 切换展开/收起的回调 * @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章 * @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件 * @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 sectionBg = useColorModeValue('gray.50', 'gray.750');
const hoverBg = useColorModeValue('gray.100', 'gray.700'); const hoverBg = useColorModeValue('gray.100', 'gray.700');
const headingColor = useColorModeValue('gray.700', 'gray.200'); 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 ( return (
<Flex <Flex
justify="space-between" justify="space-between"
align="center" align="center"
cursor="pointer" cursor={showModeToggle ? 'default' : 'pointer'}
onClick={onToggle} onClick={showModeToggle ? undefined : onToggle}
p={3} p={3}
bg={sectionBg} bg={sectionBg}
borderRadius="md" borderRadius="md"
_hover={{ bg: hoverBg }} _hover={showModeToggle ? {} : { bg: hoverBg }}
transition="background 0.2s" transition="background 0.2s"
> >
<HStack spacing={2}> <HStack spacing={2}>
@@ -54,12 +86,32 @@ const CollapsibleHeader = ({ title, isOpen, onToggle, count = null, subscription
</Badge> </Badge>
)} )}
</HStack> </HStack>
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />} {/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
size="sm" {showModeToggle && onModeToggle && (
variant="ghost" <Button
aria-label={isOpen ? '收起' : '展开'} 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> </Flex>
); );
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js // src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
// 通用可折叠区块组件 // 通用可折叠区块组件
import React from 'react'; import React, { useState } from 'react';
import { import {
Box, Box,
Collapse, Collapse,
@@ -19,7 +19,10 @@ import CollapsibleHeader from './CollapsibleHeader';
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件 * @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.isLocked - 是否锁定(不可展开) * @param {boolean} props.isLocked - 是否锁定(不可展开)
* @param {Function} props.onLockedClick - 锁定时点击的回调 * @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 = ({ const CollapsibleSection = ({
title, title,
@@ -29,10 +32,16 @@ const CollapsibleSection = ({
subscriptionBadge = null, subscriptionBadge = null,
isLocked = false, isLocked = false,
onLockedClick = null, onLockedClick = null,
children children,
simpleContent = null,
showModeToggle = false,
defaultMode = 'detailed'
}) => { }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750'); const sectionBg = useColorModeValue('gray.50', 'gray.750');
// 模式状态:'detailed' | 'simple'
const [displayMode, setDisplayMode] = useState(defaultMode);
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换 // 处理点击:如果锁定则触发锁定回调,否则触发正常切换
const handleToggle = () => { const handleToggle = () => {
if (isLocked && onLockedClick) { if (isLocked && onLockedClick) {
@@ -42,15 +51,43 @@ const CollapsibleSection = ({
} }
}; };
return ( // 处理模式切换
<Box> const handleModeToggle = (e) => {
<CollapsibleHeader e.stopPropagation(); // 阻止冒泡到标题栏的 onToggle
title={title}
isOpen={isOpen} if (isLocked && onLockedClick) {
onToggle={handleToggle} // 如果被锁定,触发付费弹窗
count={count} onLockedClick();
subscriptionBadge={subscriptionBadge} 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 <Collapse
in={isOpen && !isLocked} in={isOpen && !isLocked}
animateOpacity animateOpacity
@@ -61,6 +98,25 @@ const CollapsibleSection = ({
{children} {children}
</Box> </Box>
</Collapse> </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> </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, Text,
Spinner, Spinner,
Center, Center,
Wrap,
WrapItem,
useColorModeValue, useColorModeValue,
useToast, useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
@@ -19,9 +21,11 @@ import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice'; import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import { useAuth } from '../../../../contexts/AuthContext'; import { useAuth } from '../../../../contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo'; import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection'; import EventDescriptionSection from './EventDescriptionSection';
import RelatedConceptsSection from './RelatedConceptsSection'; import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection'; import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection'; import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents'; import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis'; import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
@@ -32,8 +36,9 @@ import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgrade
* 动态新闻详情面板主组件 * 动态新闻详情面板主组件
* @param {Object} props * @param {Object} props
* @param {Object} props.event - 事件对象(包含详情数据) * @param {Object} props.event - 事件对象(包含详情数据)
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true
*/ */
const DynamicNewsDetailPanel = ({ event }) => { const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { user } = useAuth(); const { user } = useAuth();
const cardBg = useColorModeValue('white', 'gray.800'); const cardBg = useColorModeValue('white', 'gray.800');
@@ -111,8 +116,8 @@ const DynamicNewsDetailPanel = ({ event }) => {
const canAccessTransmission = hasAccess('max'); const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪 // 子区块折叠状态管理 + 加载追踪
// PRO 会员的相关股票默认展开 // 初始值为 false由 useEffect 根据权限动态设置
const [isStocksOpen, setIsStocksOpen] = useState(canAccessStocks && userTier === 'pro'); const [isStocksOpen, setIsStocksOpen] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
const [isConceptsOpen, setIsConceptsOpen] = useState(false); const [isConceptsOpen, setIsConceptsOpen] = useState(false);
@@ -199,14 +204,18 @@ const DynamicNewsDetailPanel = ({ event }) => {
// 🎯 加载事件详情(增加浏览量) // 🎯 加载事件详情(增加浏览量)
loadEventDetail(); loadEventDetail();
// PRO 会员的相关股票默认展开,其他情况收起 // PRO 和 MAX 会员的相关股票默认展开,其他情况收起
const shouldOpenStocks = canAccessStocks && userTier === 'pro'; const shouldOpenStocks = canAccessStocks;
setIsStocksOpen(shouldOpenStocks); setIsStocksOpen(shouldOpenStocks);
setHasLoadedStocks(false); setHasLoadedStocks(false);
// PRO 会员默认展开时,自动加载股票数据 // PRO 和 MAX 会员自动加载股票数据(无论是否展开)
if (shouldOpenStocks) { const shouldLoadStocks = canAccessStocks; // PRO 或 MAX 都有权限
console.log('%c📊 [PRO会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id }); if (shouldLoadStocks) {
console.log('%c📊 [PRO/MAX会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
userTier
});
loadStocksData(); loadStocksData();
setHasLoadedStocks(true); setHasLoadedStocks(true);
} }
@@ -278,21 +287,34 @@ const DynamicNewsDetailPanel = ({ event }) => {
return ( return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px"> <Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody> <CardBody position="relative">
<VStack align="stretch" spacing={3}> {/* 无头部模式:显示右上角精简信息栏 */}
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) */} {!showHeader && (
<EventHeaderInfo <CompactMetaBar
event={fullEventDetail || event} event={fullEventDetail || event}
importance={importance} importance={importance}
isFollowing={isFollowing} isFollowing={isFollowing}
followerCount={followerCount} followerCount={followerCount}
onToggleFollow={handleToggleFollow} onToggleFollow={handleToggleFollow}
/> />
)}
<VStack align="stretch" spacing={3}>
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) - 可配置显示/隐藏 */}
{showHeader && (
<EventHeaderInfo
event={fullEventDetail || event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
)}
{/* 事件描述 */} {/* 事件描述 */}
<EventDescriptionSection description={event.description} /> <EventDescriptionSection description={event.description} />
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 */} {/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection <CollapsibleSection
title="相关股票" title="相关股票"
isOpen={isStocksOpen} isOpen={isStocksOpen}
@@ -306,6 +328,27 @@ const DynamicNewsDetailPanel = ({ event }) => {
})()} })()}
isLocked={!canAccessStocks} isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')} 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 ? ( {loading.stocks || loading.quotes ? (
<Center py={4}> <Center py={4}>

View File

@@ -38,9 +38,13 @@ const RelatedConceptsSection = ({
eventTime, eventTime,
subscriptionBadge = null, subscriptionBadge = null,
isLocked = false, 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 [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -179,10 +183,8 @@ const RelatedConceptsSection = ({
); );
} }
// 如果没有概念,不渲染 // 判断是否有数据
if (!concepts || concepts.length === 0) { const hasNoConcepts = !concepts || concepts.length === 0;
return null;
}
/** /**
* 根据相关度获取颜色(浅色背景 + 深色文字) * 根据相关度获取颜色(浅色背景 + 深色文字)
@@ -232,9 +234,12 @@ const RelatedConceptsSection = ({
// 如果被锁定且有回调函数,触发付费弹窗 // 如果被锁定且有回调函数,触发付费弹窗
if (isLocked && onLockedClick) { if (isLocked && onLockedClick) {
onLockedClick(); onLockedClick();
} else if (onToggle !== undefined) {
// 受控模式:调用外部回调
onToggle();
} else { } else {
// 否则正常展开/收起 // 非受控模式:使用内部状态
setIsExpanded(!isExpanded); setInternalExpanded(!internalExpanded);
} }
}} }}
> >
@@ -249,30 +254,49 @@ const RelatedConceptsSection = ({
</Box> </Box>
{/* 简单模式:横向卡片列表(总是显示) */} {/* 简单模式:横向卡片列表(总是显示) */}
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}> {hasNoConcepts ? (
{concepts.map((concept, index) => ( <Box mb={isExpanded ? 3 : 0}>
<SimpleConceptCard {error ? (
key={index} <Text color="red.500" fontSize="sm">{error}</Text>
concept={concept} ) : (
onClick={handleConceptClick} <Text color={textColor} fontSize="sm">暂无相关概念数据</Text>
getRelevanceColor={getRelevanceColor} )}
/> </Box>
))} ) : (
</Flex> <Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{/* 详细概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => ( {concepts.map((concept, index) => (
<DetailedConceptCard <SimpleConceptCard
key={index} key={index}
concept={concept} concept={concept}
onClick={handleConceptClick} 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> </Collapse>
</Box> </Box>
); );

View File

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

View File

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

View File

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

View File

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