Compare commits

...

6 Commits

Author SHA1 Message Date
zdl
9274323151 fix: 完全移除 EventScrollList 顶部间距
问题:
- EventScrollList 顶部间距 (pt={2}, 8px) 仍然过大
- 用户期望事件列表紧贴搜索框,无顶部间距

修改:
- pt={2} 改为 pt={0}
- 顶部间距从 8px 完全移除为 0px
- 保持底部 pb={4} (16px) 和左右 px={2} (8px) 不变

视觉效果:
- EventScrollList 紧贴 CardHeader,更加紧凑
- 其他方向间距保持不变

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:41:05 +08:00
zdl
cedfd3978d fix: 减少 EventScrollList 顶部间距
问题:
- EventScrollList 的 Flex 容器设置了 py={4}(上下各 16px padding)
- 导致顶部间距过大,视觉不够紧凑

修改:
- py={4} 改为 pt={2} pb={4}
- 顶部间距从 16px 减少到 8px
- 保持底部 16px 间距,为滚动条留出足够空间

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:32:28 +08:00
zdl
89fe0cd10b fix: 修改 EventScrollList 左右箭头为翻页功能
问题:
- 左边箭头位置 (left: -4) 超出容器,看不到
- 右边箭头点击只是滚动 400px,而不是切换页面
- 用户期望左右箭头用于翻页,而不是横向滚动

修改内容:
1. 删除滚动相关函数和状态
   - 删除 scrollLeft()、scrollRight() 函数
   - 删除 handleScroll() 监听函数
   - 删除 showLeftArrow、showRightArrow state
   - 删除 useEffect 重置滚动位置逻辑
   - 移除 useState、useEffect 导入

2. 修改箭头功能从"滚动"改为"翻页"
   - 左箭头: onClick={scrollLeft} → onClick={() => onPageChange(currentPage - 1)}
   - 右箭头: onClick={scrollRight} → onClick={() => onPageChange(currentPage + 1)}

3. 修改箭头显隐逻辑为基于页码
   - 左箭头: showLeftArrow → currentPage > 1
   - 右箭头: showRightArrow → currentPage < totalPages

4. 优化箭头位置和样式
   - 位置: left/right: "-4" → "2" (在容器内部边缘)
   - 图标尺寸: boxSize={6} → boxSize={8}
   - 按钮尺寸: size="md" → size="lg"
   - 阴影: shadow="md" → shadow="lg"
   - 明确背景色: bg="blue.500"
   - 增强 hover 效果: 放大 scale(1.1) + 加深颜色
   - 更新说明文字: "向左/右滚动" → "上一页/下一页"

预期效果:
- 左箭头点击后加载上一页数据
- 右箭头点击后加载下一页数据
- 第1页时左箭头隐藏,最后一页时右箭头隐藏
- 箭头位置清晰可见,视觉效果突出

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:29:44 +08:00
zdl
d027071e98 feat: 优化社区页面滚动和分页交互体验…)
⎿  [feature_2025/1028_event 5dedbb3] feat: 优化社区页面滚动和分页交互体验
      6 files changed, 1355 insertions(+), 49 deletions(-)
      create mode 100644 docs/test-cases/Community351241265351235242346265213350257225347224250344276213.md
2025-11-03 14:24:41 +08:00
zdl
e31e4118a0 fix: 修改相关概念组件以匹配真实API数据结构
修改内容:
- SimpleConceptCard.js: 改用 concept.concept 和 concept.score 字段
- DetailedConceptCard.js: 改用 concept.concept、concept.score 和 concept.price_info.avg_change_pct
- RelatedConceptsSection/index.js: 导航时使用 concept.concept 字段
- events.js mock数据: 更新keywords生成函数,使用concept/score/price_info结构

数据结构变更:
- name → concept (概念名称)
- relevance (0-100) → score (0-1)
- avg_change_pct → price_info.avg_change_pct (嵌套结构)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:18:17 +08:00
zdl
5611c06991 refactor: 移除 RelatedConcepts 组件中的 API_BASE_URL 配置
移除硬编码的 API 基础地址配置,改为直接使用 API 路径:
- 删除 API_BASE_URL 常量定义
- 修改 fetch 请求直接使用 '/concept-api/search'
- 依赖项目的环境配置文件进行代理配置

优点:
- 代码更简洁,不需要环境判断
- 统一使用项目级别的代理配置
- 便于维护和部署

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 13:55:32 +08:00
10 changed files with 166 additions and 134 deletions

View File

@@ -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) // 核心相关股票
};
});
}
/**

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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}>
平均涨跌幅

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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) => {

View File

@@ -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} />

View File

@@ -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',

View File

@@ -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);