Compare commits
6 Commits
784202025c
...
9274323151
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9274323151 | ||
|
|
cedfd3978d | ||
|
|
89fe0cd10b | ||
|
|
d027071e98 | ||
|
|
e31e4118a0 | ||
|
|
5611c06991 |
@@ -696,17 +696,24 @@ function generateKeywords(industry, seed) {
|
||||
return selectedStocks;
|
||||
};
|
||||
|
||||
// 将字符串数组转换为对象数组,包含完整字段
|
||||
return keywordNames.map((name, index) => ({
|
||||
name: name,
|
||||
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
||||
relevance: 70 + Math.floor((seed * 7 + index * 11) % 30), // 70-99的相关度
|
||||
description: descriptionTemplates[name] || `${name}相关概念,市场关注度较高,具有一定的投资价值。`,
|
||||
avg_change_pct: (Math.random() * 15 - 5).toFixed(2), // -5% ~ +10% 的涨跌幅
|
||||
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
||||
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
||||
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
|
||||
}));
|
||||
// 将字符串数组转换为对象数组,匹配真实API数据结构
|
||||
return keywordNames.map((name, index) => {
|
||||
const score = (70 + Math.floor((seed * 7 + index * 11) % 30)) / 100; // 0.70-0.99的分数
|
||||
const avgChangePct = (Math.random() * 15 - 5).toFixed(2); // -5% ~ +10% 的涨跌幅
|
||||
|
||||
return {
|
||||
concept: name, // 使用 concept 字段而不是 name
|
||||
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
||||
score: parseFloat(score.toFixed(2)), // 0-1之间的分数,而不是0-100
|
||||
description: descriptionTemplates[name] || `${name}相关概念,市场关注度较高,具有一定的投资价值。`,
|
||||
price_info: { // 将 avg_change_pct 嵌套在 price_info 对象中
|
||||
avg_change_pct: parseFloat(avgChangePct)
|
||||
},
|
||||
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
||||
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
||||
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,8 +72,8 @@ const DynamicNewsCard = forwardRef(({
|
||||
// 发起 Redux action 获取新页面数据
|
||||
dispatch(fetchDynamicNews({ page: newPage, per_page: pageSize }));
|
||||
|
||||
// 重置选中事件(等新数据加载后自动选中第一个)
|
||||
setSelectedEvent(null);
|
||||
// 保持当前选中事件,避免详情面板消失导致页面抖动
|
||||
// 新数据加载完成后,useEffect 会自动选中第一个事件
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -112,27 +112,8 @@ const DynamicNewsCard = forwardRef(({
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody position="relative">
|
||||
{/* Loading 状态 */}
|
||||
{loading && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Empty 状态 */}
|
||||
{!loading && (!events || events.length === 0) && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 横向滚动事件列表 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
{/* 横向滚动事件列表 - 始终渲染(除非为空) */}
|
||||
{events && events.length > 0 ? (
|
||||
<EventScrollList
|
||||
events={events}
|
||||
selectedEvent={selectedEvent}
|
||||
@@ -141,11 +122,27 @@ const DynamicNewsCard = forwardRef(({
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
loading={loading}
|
||||
/>
|
||||
) : !loading ? (
|
||||
/* Empty 状态 - 只在非加载且无数据时显示 */
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
/* 首次加载状态 */
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 详情面板 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
{/* 详情面板 - 始终显示(如果有选中事件) */}
|
||||
{events && events.length > 0 && selectedEvent && (
|
||||
<Box mt={6}>
|
||||
<DynamicNewsDetailPanel event={selectedEvent} />
|
||||
</Box>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
|
||||
// 横向滚动事件列表组件
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Center,
|
||||
VStack,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
@@ -21,6 +25,7 @@ import PaginationControl from './PaginationControl';
|
||||
* @param {number} currentPage - 当前页码
|
||||
* @param {number} totalPages - 总页数(由服务端返回)
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
* @param {boolean} loading - 加载状态
|
||||
*/
|
||||
const EventScrollList = ({
|
||||
events,
|
||||
@@ -29,52 +34,10 @@ const EventScrollList = ({
|
||||
borderColor,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
onPageChange,
|
||||
loading = false
|
||||
}) => {
|
||||
const scrollContainerRef = useRef(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||
|
||||
// 页码变化时,滚动到左侧起始位置
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [currentPage]);
|
||||
|
||||
// 滚动到左侧
|
||||
const scrollLeft = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: -400,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 滚动到右侧
|
||||
const scrollRight = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: 400,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动位置,更新箭头显示状态
|
||||
const handleScroll = (e) => {
|
||||
const container = e.target;
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const scrollWidth = container.scrollWidth;
|
||||
const clientWidth = container.clientWidth;
|
||||
|
||||
setShowLeftArrow(scrollLeft > 0);
|
||||
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
|
||||
};
|
||||
|
||||
// 时间轴样式配置
|
||||
const getTimelineBoxStyle = () => {
|
||||
@@ -89,54 +52,51 @@ const EventScrollList = ({
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 分页控制器 - 右上角 */}
|
||||
{totalPages > 1 && (
|
||||
<Flex justify="flex-end" mb={3}>
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 横向滚动区域 */}
|
||||
<Box position="relative">
|
||||
{/* 左侧滚动按钮 */}
|
||||
{showLeftArrow && (
|
||||
{/* 左侧翻页按钮 - 上一页 */}
|
||||
{currentPage > 1 && (
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon boxSize={6} />}
|
||||
icon={<ChevronLeftIcon boxSize={8} />}
|
||||
position="absolute"
|
||||
left="-4"
|
||||
left="2"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={scrollLeft}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
size="md"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
shadow="md"
|
||||
aria-label="向左滚动"
|
||||
shadow="lg"
|
||||
bg="blue.500"
|
||||
_hover={{ bg: 'blue.600', transform: 'translateY(-50%) scale(1.1)' }}
|
||||
_active={{ bg: 'blue.700' }}
|
||||
aria-label="上一页"
|
||||
title="上一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧滚动按钮 */}
|
||||
{showRightArrow && (
|
||||
{/* 右侧翻页按钮 - 下一页 */}
|
||||
{currentPage < totalPages && (
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon boxSize={6} />}
|
||||
icon={<ChevronRightIcon boxSize={8} />}
|
||||
position="absolute"
|
||||
right="-4"
|
||||
right="2"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={scrollRight}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
size="md"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
shadow="md"
|
||||
aria-label="向右滚动"
|
||||
shadow="lg"
|
||||
bg="blue.500"
|
||||
_hover={{ bg: 'blue.600', transform: 'translateY(-50%) scale(1.1)' }}
|
||||
_active={{ bg: 'blue.700' }}
|
||||
aria-label="下一页"
|
||||
title="下一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -146,9 +106,10 @@ const EventScrollList = ({
|
||||
overflowX="auto"
|
||||
overflowY="hidden"
|
||||
gap={4}
|
||||
py={4}
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={2}
|
||||
onScroll={handleScroll}
|
||||
position="relative"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
@@ -170,6 +131,29 @@ const EventScrollList = ({
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* 加载遮罩 */}
|
||||
{loading && (
|
||||
<Center
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg={useColorModeValue('whiteAlpha.800', 'blackAlpha.700')}
|
||||
backdropFilter="blur(2px)"
|
||||
zIndex={10}
|
||||
borderRadius="md"
|
||||
>
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" thickness="3px" />
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.300')}>
|
||||
加载中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 事件卡片列表 */}
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
@@ -199,6 +183,17 @@ const EventScrollList = ({
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 分页控制器 - 右下角 */}
|
||||
{totalPages > 1 && (
|
||||
<Flex justify="flex-end" mt={3}>
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,8 +35,11 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
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.avg_change_pct);
|
||||
const changePct = parseFloat(concept.price_info?.avg_change_pct);
|
||||
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
|
||||
const changeSymbol = changePct > 0 ? '+' : '';
|
||||
|
||||
@@ -61,11 +64,11 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
{/* 左侧:概念名称 + Badge */}
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<Text fontSize="md" fontWeight="bold" color="blue.600">
|
||||
{concept.name}
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {concept.relevance}%
|
||||
相关度: {relevanceScore}%
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
@@ -74,7 +77,7 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅 */}
|
||||
{concept.avg_change_pct && (
|
||||
{concept.price_info?.avg_change_pct && (
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color={stockCountColor} mb={1}>
|
||||
平均涨跌幅
|
||||
|
||||
@@ -24,7 +24,8 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
|
||||
const relevanceColors = getRelevanceColor(concept.relevance);
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
const relevanceColors = getRelevanceColor(relevanceScore);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -47,7 +48,7 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
>
|
||||
{/* 左侧:概念名 + 数量 */}
|
||||
<Text fontSize="sm" fontWeight="normal" color={conceptNameColor} mr={3}>
|
||||
{concept.name}{' '}
|
||||
{concept.concept}{' '}
|
||||
<Text as="span" color="gray.500">
|
||||
({concept.stock_count})
|
||||
</Text>
|
||||
@@ -63,7 +64,7 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {concept.relevance}%
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
@@ -63,7 +63,7 @@ const RelatedConceptsSection = ({ keywords, effectiveTradingDate, eventTime }) =
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念中心,并搜索该概念
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.name)}`);
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -102,17 +102,7 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
|
||||
// 保持现有筛选条件,只更新页码
|
||||
updateFilters({ ...filters, page });
|
||||
|
||||
// 滚动到实时事件时间轴(平滑滚动)
|
||||
if (eventTimelineRef && eventTimelineRef.current) {
|
||||
setTimeout(() => {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动
|
||||
block: 'start' // 滚动到元素顶部
|
||||
});
|
||||
}, 100); // 延迟100ms,确保DOM更新
|
||||
}
|
||||
}, [filters, updateFilters, eventTimelineRef, track]);
|
||||
}, [filters, updateFilters, track]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
|
||||
@@ -60,6 +60,7 @@ const Community = () => {
|
||||
// Ref:用于滚动到实时事件时间轴
|
||||
const eventTimelineRef = useRef(null);
|
||||
const hasScrolledRef = useRef(false); // 标记是否已滚动
|
||||
const containerRef = useRef(null); // 用于首次滚动到内容区域
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { showCommunityGuide } = useNotification();
|
||||
@@ -145,6 +146,23 @@ const Community = () => {
|
||||
}
|
||||
}, [showCommunityGuide]); // 只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
|
||||
useEffect(() => {
|
||||
// 延迟执行,确保DOM已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
// 滚动到容器顶部,自动考虑导航栏的高度
|
||||
containerRef.current.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
|
||||
const scrollToTimeline = useCallback(() => {
|
||||
if (!hasScrolledRef.current && eventTimelineRef.current) {
|
||||
@@ -161,7 +179,7 @@ const Community = () => {
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 主内容区域 */}
|
||||
<Container maxW="container.xl" pt={6} pb={8}>
|
||||
<Container ref={containerRef} maxW="container.xl" pt={6} pb={8}>
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@ import moment from 'moment';
|
||||
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'https://valuefrontier.cn/concept-api';
|
||||
|
||||
// 增强版 ConceptCard 组件 - 展示更多数据细节
|
||||
const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -331,7 +328,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
|
||||
logger.debug('RelatedConcepts', '搜索概念', requestBody);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||
const response = await fetch('/concept-api/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -358,6 +358,9 @@ const EventDetail = () => {
|
||||
const { user } = useAuth();
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
|
||||
// 滚动位置管理
|
||||
const scrollPositionRef = useRef(0);
|
||||
|
||||
// State hooks
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [relatedStocks, setRelatedStocks] = useState([]);
|
||||
@@ -399,6 +402,16 @@ const EventDetail = () => {
|
||||
|
||||
const actualEventId = getEventIdFromPath();
|
||||
|
||||
// 保存当前滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
scrollPositionRef.current = window.scrollY || window.pageYOffset;
|
||||
};
|
||||
|
||||
// 恢复滚动位置
|
||||
const restoreScrollPosition = () => {
|
||||
window.scrollTo(0, scrollPositionRef.current);
|
||||
};
|
||||
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -540,8 +553,19 @@ const EventDetail = () => {
|
||||
// Effect hook - must be called after all state hooks
|
||||
useEffect(() => {
|
||||
if (actualEventId) {
|
||||
// 保存当前滚动位置
|
||||
saveScrollPosition();
|
||||
|
||||
loadEventData();
|
||||
loadPosts();
|
||||
|
||||
// 数据加载完成后恢复滚动位置
|
||||
// 使用 setTimeout 确保 DOM 已更新
|
||||
const timer = setTimeout(() => {
|
||||
restoreScrollPosition();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setError('无效的事件ID');
|
||||
setLoading(false);
|
||||
|
||||
Reference in New Issue
Block a user