Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui: refactor: 重构 StockDetailPanel 目录结构,清理未使用代码 chore: 清理 Community 目录下未使用的文件 fix: 修复 remeasure 依赖数组缺少 pageType refactor: 使用 performanceMonitor 替换 useFirstScreenMetrics 中的 performance.now() fix: 文案修改 fix: 修复个股搜索下拉弹窗被遮挡的问题 feat: 添加 React 性能追踪 Hooks (usePerformanceTracker) feat: 替换公众号文件
This commit is contained in:
@@ -1,83 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsCard/PageNavigationButton.js
|
||||
// 翻页导航按钮组件
|
||||
|
||||
import React from 'react';
|
||||
import { IconButton, useColorModeValue } from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 翻页导航按钮组件
|
||||
* @param {Object} props
|
||||
* @param {'prev'|'next'} props.direction - 按钮方向(prev=上一页,next=下一页)
|
||||
* @param {number} props.currentPage - 当前页码
|
||||
* @param {number} props.totalPages - 总页数
|
||||
* @param {Function} props.onPageChange - 翻页回调
|
||||
* @param {string} props.mode - 显示模式(只在carousel/grid模式下显示)
|
||||
*/
|
||||
const PageNavigationButton = ({
|
||||
direction,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
mode
|
||||
}) => {
|
||||
// 主题适配
|
||||
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
|
||||
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
|
||||
|
||||
// 根据方向计算配置
|
||||
const isPrev = direction === 'prev';
|
||||
const isNext = direction === 'next';
|
||||
|
||||
const Icon = isPrev ? ChevronLeftIcon : ChevronRightIcon;
|
||||
const position = isPrev ? 'left' : 'right';
|
||||
const label = isPrev ? '上一页' : '下一页';
|
||||
const targetPage = isPrev ? currentPage - 1 : currentPage + 1;
|
||||
const shouldShow = isPrev
|
||||
? currentPage > 1
|
||||
: currentPage < totalPages;
|
||||
const isDisabled = isNext ? currentPage >= totalPages : false;
|
||||
|
||||
// 判断是否显示(只在单排/双排模式显示)
|
||||
const shouldRender = shouldShow && (mode === 'carousel' || mode === 'grid');
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const handleClick = () => {
|
||||
console.log(
|
||||
`%c🔵 [翻页] 点击${label}: 当前页${currentPage} → 目标页${targetPage} (共${totalPages}页)`,
|
||||
'color: #3B82F6; font-weight: bold;'
|
||||
);
|
||||
onPageChange(targetPage);
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<Icon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
{...{ [position]: 0 }}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={handleClick}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
bg={arrowBtnBg}
|
||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||
_hover={{
|
||||
bg: arrowBtnHoverBg,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transform: 'translateY(-50%) scale(1.05)'
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageNavigationButton;
|
||||
@@ -1,88 +0,0 @@
|
||||
// src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js
|
||||
// 无限滚动 Hook
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 无限滚动 Hook
|
||||
* 监听容器滚动事件,当滚动到底部附近时触发加载更多数据
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.onLoadMore - 加载更多回调函数(返回 Promise)
|
||||
* @param {boolean} options.hasMore - 是否还有更多数据
|
||||
* @param {boolean} options.isLoading - 是否正在加载
|
||||
* @param {number} options.threshold - 触发阈值(距离底部多少像素时触发,默认200px)
|
||||
* @returns {Object} { containerRef } - 容器引用
|
||||
*/
|
||||
export const useInfiniteScroll = ({
|
||||
onLoadMore,
|
||||
hasMore = true,
|
||||
isLoading = false,
|
||||
threshold = 200
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 滚动处理函数
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
// 检查条件:容器存在、未加载中、还有更多数据
|
||||
if (!container || isLoadingRef.current || !hasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
// 距离底部小于阈值时触发加载
|
||||
if (distanceToBottom < threshold) {
|
||||
console.log(
|
||||
'%c⬇️ [懒加载] 触发加载下一页',
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
{
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToBottom,
|
||||
threshold
|
||||
}
|
||||
);
|
||||
|
||||
isLoadingRef.current = true;
|
||||
|
||||
// 调用加载函数并更新状态
|
||||
onLoadMore()
|
||||
.then(() => {
|
||||
console.log('%c✅ [懒加载] 加载完成', 'color: #10B981; font-weight: bold;');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('%c❌ [懒加载] 加载失败', 'color: #DC2626; font-weight: bold;', error);
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [onLoadMore, hasMore, threshold]);
|
||||
|
||||
// 绑定滚动事件
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 添加滚动监听
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
// 更新 loading 状态的 ref
|
||||
useEffect(() => {
|
||||
isLoadingRef.current = isLoading;
|
||||
}, [isLoading]);
|
||||
|
||||
return { containerRef };
|
||||
};
|
||||
@@ -1,614 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Avatar,
|
||||
Textarea,
|
||||
Button,
|
||||
Divider,
|
||||
useToast,
|
||||
Badge,
|
||||
Flex,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
useColorModeValue,
|
||||
Spinner,
|
||||
Center,
|
||||
Collapse,
|
||||
Input,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChatIcon,
|
||||
TimeIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
ChevronDownIcon,
|
||||
TriangleDownIcon,
|
||||
TriangleUpIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const EventDiscussionModal = ({ isOpen, onClose, eventId, eventTitle, discussionType = '事件讨论' }) => {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [expandedPosts, setExpandedPosts] = useState({});
|
||||
const [postComments, setPostComments] = useState({});
|
||||
const [replyContents, setReplyContents] = useState({});
|
||||
const [loadingComments, setLoadingComments] = useState({});
|
||||
|
||||
const toast = useToast();
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 加载帖子列表
|
||||
const loadPosts = async () => {
|
||||
if (!eventId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/events/${eventId}/posts?sort=latest&page=1&per_page=20`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
setPosts(result.data || []);
|
||||
logger.debug('EventDiscussionModal', '帖子列表加载成功', {
|
||||
eventId,
|
||||
postsCount: result.data?.length || 0
|
||||
});
|
||||
} else {
|
||||
logger.error('EventDiscussionModal', 'loadPosts', new Error('API返回错误'), {
|
||||
eventId,
|
||||
status: response.status,
|
||||
message: result.message
|
||||
});
|
||||
toast({
|
||||
title: '加载帖子失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDiscussionModal', 'loadPosts', error, { eventId });
|
||||
toast({
|
||||
title: '加载帖子失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载帖子的评论
|
||||
const loadPostComments = async (postId) => {
|
||||
setLoadingComments(prev => ({ ...prev, [postId]: true }));
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${postId}/comments?sort=latest`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
setPostComments(prev => ({ ...prev, [postId]: result.data || [] }));
|
||||
logger.debug('EventDiscussionModal', '评论加载成功', {
|
||||
postId,
|
||||
commentsCount: result.data?.length || 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDiscussionModal', 'loadPostComments', error, { postId });
|
||||
} finally {
|
||||
setLoadingComments(prev => ({ ...prev, [postId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 切换展开/收起评论
|
||||
const togglePostComments = async (postId) => {
|
||||
const isExpanded = expandedPosts[postId];
|
||||
if (!isExpanded) {
|
||||
// 展开时加载评论
|
||||
await loadPostComments(postId);
|
||||
}
|
||||
setExpandedPosts(prev => ({ ...prev, [postId]: !isExpanded }));
|
||||
};
|
||||
|
||||
// 提交新帖子
|
||||
const handleSubmitPost = async () => {
|
||||
if (!newPostContent.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await fetch(`/api/events/${eventId}/posts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
title: newPostTitle.trim(),
|
||||
content: newPostContent.trim(),
|
||||
content_type: 'text',
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
setNewPostContent('');
|
||||
setNewPostTitle('');
|
||||
loadPosts();
|
||||
logger.info('EventDiscussionModal', '帖子发布成功', {
|
||||
eventId,
|
||||
postId: result.data?.id
|
||||
});
|
||||
toast({
|
||||
title: '帖子发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
logger.error('EventDiscussionModal', 'handleSubmitPost', new Error('API返回错误'), {
|
||||
eventId,
|
||||
message: result.message
|
||||
});
|
||||
toast({
|
||||
title: result.message || '帖子发布失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDiscussionModal', 'handleSubmitPost', error, { eventId });
|
||||
toast({
|
||||
title: '帖子发布失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除帖子
|
||||
const handleDeletePost = async (postId) => {
|
||||
if (!window.confirm('确定要删除这个帖子吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
loadPosts();
|
||||
logger.info('EventDiscussionModal', '帖子删除成功', { postId });
|
||||
toast({
|
||||
title: '帖子已删除',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
logger.error('EventDiscussionModal', 'handleDeletePost', new Error('API返回错误'), {
|
||||
postId,
|
||||
message: result.message
|
||||
});
|
||||
toast({
|
||||
title: result.message || '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDiscussionModal', 'handleDeletePost', error, { postId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 点赞帖子
|
||||
const handleLikePost = async (postId) => {
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${postId}/like`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// 更新帖子列表中的点赞状态
|
||||
setPosts(prev => prev.map(post =>
|
||||
post.id === postId
|
||||
? { ...post, likes_count: result.likes_count, liked: result.liked }
|
||||
: post
|
||||
));
|
||||
logger.debug('EventDiscussionModal', '点赞操作成功', {
|
||||
postId,
|
||||
liked: result.liked,
|
||||
likesCount: result.likes_count
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDiscussionModal', 'handleLikePost', error, { postId });
|
||||
toast({
|
||||
title: '操作失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 提交评论
|
||||
const handleSubmitComment = async (postId) => {
|
||||
const content = replyContents[postId];
|
||||
if (!content?.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${postId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
content: content.trim(),
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
setReplyContents(prev => ({ ...prev, [postId]: '' }));
|
||||
// 重新加载该帖子的评论
|
||||
await loadPostComments(postId);
|
||||
// 更新帖子的评论数
|
||||
setPosts(prev => prev.map(post =>
|
||||
post.id === postId
|
||||
? { ...post, comments_count: (post.comments_count || 0) + 1 }
|
||||
: post
|
||||
));
|
||||
logger.info('EventDiscussionModal', '评论发布成功', {
|
||||
postId,
|
||||
commentId: result.data?.id
|
||||
});
|
||||
toast({
|
||||
title: '评论发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDiscussionModal', 'handleSubmitComment', error, { postId });
|
||||
toast({
|
||||
title: '评论发布失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除评论
|
||||
const handleDeleteComment = async (commentId, postId) => {
|
||||
if (!window.confirm('确定要删除这条评论吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// 重新加载该帖子的评论
|
||||
await loadPostComments(postId);
|
||||
// 更新帖子的评论数
|
||||
setPosts(prev => prev.map(post =>
|
||||
post.id === postId
|
||||
? { ...post, comments_count: Math.max(0, (post.comments_count || 0) - 1) }
|
||||
: post
|
||||
));
|
||||
logger.info('EventDiscussionModal', '评论删除成功', { commentId, postId });
|
||||
toast({
|
||||
title: '评论已删除',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDiscussionModal', 'handleDeleteComment', error, { commentId, postId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadPosts();
|
||||
}
|
||||
}, [isOpen, eventId]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="80vh">
|
||||
<ModalHeader>
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack>
|
||||
<ChatIcon />
|
||||
<Text>{discussionType}</Text>
|
||||
</HStack>
|
||||
{eventTitle && (
|
||||
<Text fontSize="sm" color="gray.500" fontWeight="normal">
|
||||
{eventTitle}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody overflowY="auto">
|
||||
{/* 发布新帖子 */}
|
||||
<Box mb={4}>
|
||||
<Input
|
||||
value={newPostTitle}
|
||||
onChange={(e) => setNewPostTitle(e.target.value)}
|
||||
placeholder="帖子标题(可选)"
|
||||
size="sm"
|
||||
mb={2}
|
||||
/>
|
||||
<Textarea
|
||||
value={newPostContent}
|
||||
onChange={(e) => setNewPostContent(e.target.value)}
|
||||
placeholder="分享您的观点..."
|
||||
size="sm"
|
||||
resize="vertical"
|
||||
minH="80px"
|
||||
/>
|
||||
<Flex justify="flex-end" mt={2}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={handleSubmitPost}
|
||||
isLoading={submitting}
|
||||
isDisabled={!newPostContent.trim()}
|
||||
>
|
||||
发布帖子
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Divider mb={4} />
|
||||
|
||||
{/* 帖子列表 */}
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="lg" />
|
||||
</Center>
|
||||
) : posts.length > 0 ? (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{posts.map((post) => (
|
||||
<Box
|
||||
key={post.id}
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
transition="background 0.2s"
|
||||
>
|
||||
{/* 帖子头部 */}
|
||||
<Flex justify="space-between" align="start" mb={3}>
|
||||
<HStack align="start" spacing={3}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={post.user?.username || '匿名用户'}
|
||||
src={post.user?.avatar_url}
|
||||
/>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{post.user?.username || '匿名用户'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack fontSize="xs" color="gray.500">
|
||||
<TimeIcon />
|
||||
<Text>
|
||||
{format(new Date(post.created_at), 'MM月dd日 HH:mm', {
|
||||
locale: zhCN,
|
||||
})}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 操作菜单 */}
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<ChevronDownIcon />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<DeleteIcon />}
|
||||
color="red.500"
|
||||
onClick={() => handleDeletePost(post.id)}
|
||||
>
|
||||
删除帖子
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Flex>
|
||||
|
||||
{/* 帖子标题 */}
|
||||
{post.title && (
|
||||
<Text fontSize="md" fontWeight="bold" mb={2}>
|
||||
{post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 帖子内容 */}
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap" mb={3}>
|
||||
{post.content}
|
||||
</Text>
|
||||
|
||||
{/* 帖子操作栏 */}
|
||||
<HStack spacing={4} mb={3}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={post.liked ? <FaHeart /> : <FaRegHeart />}
|
||||
color={post.liked ? 'red.500' : 'gray.600'}
|
||||
onClick={() => handleLikePost(post.id)}
|
||||
>
|
||||
{post.likes_count || 0}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<FaComment />}
|
||||
onClick={() => togglePostComments(post.id)}
|
||||
rightIcon={expandedPosts[post.id] ? <TriangleUpIcon /> : <TriangleDownIcon />}
|
||||
>
|
||||
{post.comments_count || 0} 评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论区 */}
|
||||
<Collapse in={expandedPosts[post.id]} animateOpacity>
|
||||
<Box borderTopWidth="1px" borderColor={borderColor} pt={3}>
|
||||
{/* 评论输入框 */}
|
||||
<HStack mb={3}>
|
||||
<Textarea
|
||||
size="sm"
|
||||
placeholder="写下你的评论..."
|
||||
value={replyContents[post.id] || ''}
|
||||
onChange={(e) => setReplyContents(prev => ({ ...prev, [post.id]: e.target.value }))}
|
||||
rows={2}
|
||||
flex={1}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={() => handleSubmitComment(post.id)}
|
||||
isDisabled={!replyContents[post.id]?.trim()}
|
||||
>
|
||||
评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论列表 */}
|
||||
{loadingComments[post.id] ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{postComments[post.id]?.map((comment) => (
|
||||
<Box key={comment.id} pl={4} borderLeftWidth="2px" borderColor="gray.200">
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<HStack spacing={2}>
|
||||
<Avatar size="xs" name={comment.user?.username} src={comment.user?.avatar_url} />
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{comment.user?.username || '匿名用户'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{format(new Date(comment.created_at), 'MM-dd HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<DeleteIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteComment(comment.id, post.id)}
|
||||
/>
|
||||
</HStack>
|
||||
<Text fontSize="sm" pl={7}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
|
||||
{/* 显示回复 */}
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
<VStack align="stretch" mt={2} spacing={1} pl={4}>
|
||||
{comment.replies.map((reply) => (
|
||||
<Box key={reply.id}>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" fontWeight="medium">
|
||||
{reply.user?.username}:
|
||||
</Text>
|
||||
<Text fontSize="xs">{reply.content}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{(!postComments[post.id] || postComments[post.id].length === 0) && (
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
|
||||
暂无评论
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<ChatIcon boxSize={8} color="gray.400" />
|
||||
<Text color="gray.500">暂无帖子,快来发表您的观点吧!</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDiscussionModal;
|
||||
@@ -1,202 +0,0 @@
|
||||
// src/views/Community/components/PopularKeywords.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tag, Space, Button } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// 使用相对路径,让 MSW 在开发环境可以拦截请求
|
||||
const API_BASE_URL = '/concept-api';
|
||||
|
||||
// 获取域名前缀
|
||||
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
|
||||
? ''
|
||||
: 'https://valuefrontier.cn';
|
||||
|
||||
const PopularKeywords = ({ onKeywordClick, keywords: propKeywords }) => {
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 加载热门概念(涨幅前20)
|
||||
const loadPopularConcepts = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: '', // 空查询获取所有概念
|
||||
size: 20, // 获取前20个
|
||||
page: 1,
|
||||
sort_by: 'change_pct' // 按涨跌幅排序
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.results) {
|
||||
// 转换数据格式
|
||||
const formattedData = data.results.map(item => ({
|
||||
keyword: item.concept,
|
||||
count: item.stock_count,
|
||||
change_pct: item.price_info?.avg_change_pct || 0,
|
||||
concept_id: item.concept_id
|
||||
}));
|
||||
setKeywords(formattedData);
|
||||
logger.debug('PopularKeywords', '热门概念加载成功(自己请求)', {
|
||||
count: formattedData.length
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PopularKeywords', 'loadPopularConcepts', error);
|
||||
setKeywords([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理从父组件传入的数据
|
||||
useEffect(() => {
|
||||
if (propKeywords && propKeywords.length > 0) {
|
||||
// 使用父组件传入的数据
|
||||
setKeywords(propKeywords);
|
||||
logger.debug('PopularKeywords', '使用父组件传入的数据', {
|
||||
count: propKeywords.length
|
||||
});
|
||||
} else {
|
||||
// 没有 prop 数据,自己加载
|
||||
loadPopularConcepts();
|
||||
}
|
||||
}, [propKeywords]);
|
||||
|
||||
// 根据涨跌幅获取标签颜色
|
||||
const getTagColor = (changePct) => {
|
||||
if (changePct > 5) return 'red';
|
||||
if (changePct > 3) return 'volcano';
|
||||
if (changePct > 1) return 'orange';
|
||||
if (changePct > 0) return 'gold';
|
||||
if (changePct === 0) return 'default';
|
||||
if (changePct > -1) return 'lime';
|
||||
if (changePct > -3) return 'green';
|
||||
if (changePct > -5) return 'cyan';
|
||||
return 'blue';
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
const formatChangePct = (pct) => {
|
||||
if (pct === null || pct === undefined) return '';
|
||||
const formatted = pct.toFixed(2);
|
||||
if (pct > 0) return `+${formatted}%`;
|
||||
return `${formatted}%`;
|
||||
};
|
||||
|
||||
// ✅ 修复:处理概念标签点击
|
||||
const handleConceptClick = (concept) => {
|
||||
// 优先调用父组件传入的回调(用于搜索框显示和触发搜索)
|
||||
if (onKeywordClick) {
|
||||
onKeywordClick(concept.keyword);
|
||||
logger.debug('PopularKeywords', '调用 onKeywordClick 回调', {
|
||||
keyword: concept.keyword
|
||||
});
|
||||
} else {
|
||||
// 如果没有回调,则跳转到对应概念的页面(原有行为)
|
||||
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
|
||||
window.open(url, '_blank');
|
||||
logger.debug('PopularKeywords', '跳转到概念页面', {
|
||||
keyword: concept.keyword,
|
||||
url
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理"更多概念"按钮点击 - 跳转到概念中心
|
||||
const handleMoreClick = () => {
|
||||
navigate('/concepts');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{keywords && keywords.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Space
|
||||
size={[6, 6]}
|
||||
wrap
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
maxHeight: '29px', // 约两行的高度 (每行约28-30px)
|
||||
overflow: 'hidden',
|
||||
paddingRight: '90px' // 为右侧按钮留出空间
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<span style={{
|
||||
color: '#ff4d4f',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginRight: 4
|
||||
}}>
|
||||
热门概念:
|
||||
</span>
|
||||
|
||||
{/* 所有标签 */}
|
||||
{keywords.map((item, index) => (
|
||||
<Tag
|
||||
key={item.concept_id || `keyword-${index}`}
|
||||
color={getTagColor(item.change_pct)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '1px 6px',
|
||||
fontSize: 12,
|
||||
transition: 'all 0.3s',
|
||||
margin: 0
|
||||
}}
|
||||
onClick={() => handleConceptClick(item)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span>{item.keyword}</span>
|
||||
<span style={{
|
||||
marginLeft: 4,
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{formatChangePct(item.change_pct)}
|
||||
</span>
|
||||
{/* <span style={{
|
||||
marginLeft: 3,
|
||||
fontSize: 10,
|
||||
opacity: 0.75
|
||||
}}> */}
|
||||
{/* ({item.count}股) */}
|
||||
{/* </span> */}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{/* 更多概念按钮 - 固定在第二行右侧 */}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleMoreClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
fontSize: 12,
|
||||
padding: '0 4px',
|
||||
height: 'auto'
|
||||
}}
|
||||
>
|
||||
更多概念 <RightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularKeywords;
|
||||
@@ -1,59 +0,0 @@
|
||||
// src/views/Community/components/SearchBox.js
|
||||
import React from 'react';
|
||||
import { Card, Input, Radio, Form, Button } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { useSearchEvents } from '../../../hooks/useSearchEvents';
|
||||
|
||||
const SearchBox = ({ onSearch }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 🎯 初始化搜索埋点Hook
|
||||
const searchEvents = useSearchEvents({ context: 'community' });
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
// 🎯 追踪搜索查询提交(在调用onSearch之前)
|
||||
if (values.q) {
|
||||
searchEvents.trackSearchQuerySubmitted(values.q, 0, {
|
||||
search_type: values.search_type || 'topic'
|
||||
});
|
||||
}
|
||||
onSearch(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="搜索事件" className="search-box" style={{ marginBottom: 16 }}>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{ search_type: 'topic' }}
|
||||
>
|
||||
<Form.Item name="q" style={{ marginBottom: 12 }}>
|
||||
<Input.Search
|
||||
placeholder="搜索关键词..."
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
onSearch={(value) => {
|
||||
form.setFieldsValue({ q: value });
|
||||
form.submit();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="search_type" style={{ marginBottom: 12 }}>
|
||||
<Radio.Group>
|
||||
<Radio value="topic">搜索话题</Radio>
|
||||
<Radio value="stock">搜索股票</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
搜索
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBox;
|
||||
@@ -1,278 +0,0 @@
|
||||
// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
klineDataCache,
|
||||
batchPendingRequests
|
||||
} from '@utils/stock/klineDataCache';
|
||||
|
||||
/**
|
||||
* 迷你分时图组件
|
||||
* 显示股票的分时价格走势,支持事件时间标记
|
||||
*
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间(可选)
|
||||
* @param {Function} onClick - 点击回调(可选)
|
||||
* @param {Array} preloadedData - 预加载的K线数据(可选,由父组件批量加载后传入)
|
||||
* @param {boolean} loading - 外部加载状态(可选)
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const loadedRef = useRef(false); // 标记是否已加载过数据
|
||||
const dataFetchedRef = useRef(false); // 防止重复请求的标记
|
||||
|
||||
// 稳定的事件时间,避免因为格式化导致的重复请求
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 从缓存或API获取数据的函数
|
||||
const loadData = useCallback(() => {
|
||||
if (!stockCode || !mountedRef.current) return false;
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
// 如果有缓存数据(包括空数组,表示已请求过但无数据),直接使用
|
||||
if (cachedData !== undefined) {
|
||||
setData(cachedData || []);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return true; // 表示数据已加载(或确认无数据)
|
||||
}
|
||||
return false; // 表示需要请求
|
||||
}, [stockCode, stableEventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData([]);
|
||||
loadedRef.current = false;
|
||||
dataFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先使用预加载的数据(由父组件批量请求后传入)
|
||||
if (preloadedData !== undefined) {
|
||||
setData(preloadedData || []);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果外部正在加载,显示loading状态,不发起单独请求
|
||||
// 父组件(StockTable)会通过 preloadedData 传入数据
|
||||
if (externalLoading) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经请求过数据,不再重复请求
|
||||
if (dataFetchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试从缓存加载
|
||||
if (loadData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查批量请求的函数
|
||||
const checkBatchAndLoad = () => {
|
||||
// 再次检查缓存(批量请求可能已完成)
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
if (cachedData !== undefined) {
|
||||
setData(cachedData || []);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return true; // 从缓存加载成功
|
||||
}
|
||||
|
||||
const batchKey = `${stableEventTime || 'today'}|timeline`;
|
||||
const pendingBatch = batchPendingRequests.get(batchKey);
|
||||
|
||||
if (pendingBatch) {
|
||||
// 等待批量请求完成后再从缓存读取
|
||||
setLoading(true);
|
||||
dataFetchedRef.current = true;
|
||||
pendingBatch.then(() => {
|
||||
if (mountedRef.current) {
|
||||
const newCachedData = klineDataCache.get(cacheKey);
|
||||
setData(newCachedData || []);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return true; // 找到批量请求
|
||||
}
|
||||
return false; // 没有批量请求
|
||||
};
|
||||
|
||||
// 先立即检查一次
|
||||
if (checkBatchAndLoad()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟检查(等待批量请求启动)
|
||||
// 注意:如果父组件正在批量加载,会传入 externalLoading=true,不会执行到这里
|
||||
setLoading(true);
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!mountedRef.current || dataFetchedRef.current) return;
|
||||
|
||||
// 再次检查批量请求
|
||||
if (checkBatchAndLoad()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 仍然没有批量请求,发起单独请求(备用方案 - 用于非批量加载场景)
|
||||
dataFetchedRef.current = true;
|
||||
|
||||
fetchKlineData(stockCode, stableEventTime)
|
||||
.then((result) => {
|
||||
if (mountedRef.current) {
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
});
|
||||
}, 200); // 延迟 200ms 等待批量请求(增加等待时间)
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [stockCode, stableEventTime, loadData, preloadedData, externalLoading]); // 添加 preloadedData 和 externalLoading 依赖
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number');
|
||||
const times = data.map(item => item.time);
|
||||
const hasData = prices.length > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return {
|
||||
title: {
|
||||
text: loading ? '加载中...' : '无数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { color: '#999', fontSize: 10 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const isUp = prices[prices.length - 1] >= prices[0];
|
||||
const lineColor = isUp ? '#ef5350' : '#26a69a';
|
||||
|
||||
// 计算事件时间对应的分时索引
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(times) && times.length > 0) {
|
||||
try {
|
||||
const eventMinute = dayjs(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const parseMinuteTime = (timeStr) => {
|
||||
const [h, m] = String(timeStr).split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
};
|
||||
const eventMin = parseMinuteTime(eventMinute);
|
||||
let nearestIdx = 0;
|
||||
for (let i = 1; i < times.length; i++) {
|
||||
if (Math.abs(parseMinuteTime(times[i]) - eventMin) < Math.abs(parseMinuteTime(times[nearestIdx]) - eventMin)) {
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
eventMarkLineData.push({
|
||||
xAxis: nearestIdx,
|
||||
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
|
||||
label: { show: false }
|
||||
});
|
||||
} catch (e) {
|
||||
// 忽略事件时间解析异常
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
|
||||
xAxis: { type: 'category', data: times, show: false, boundaryGap: false },
|
||||
yAxis: { type: 'value', show: false, min: minPrice * 0.995, max: maxPrice * 1.005, scale: true },
|
||||
series: [{
|
||||
data: prices,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' },
|
||||
{ offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)' }
|
||||
])
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { show: false },
|
||||
data: [
|
||||
...(prices.length ? [{ yAxis: prices[0], lineStyle: { color: '#aaa', type: 'dashed', width: 1 } }] : []),
|
||||
...eventMarkLineData
|
||||
]
|
||||
}
|
||||
}],
|
||||
tooltip: { show: false },
|
||||
animation: false
|
||||
};
|
||||
}, [data, loading, stableEventTime]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '35px',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数
|
||||
return prevProps.stockCode === nextProps.stockCode &&
|
||||
prevProps.eventTime === nextProps.eventTime &&
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.preloadedData === nextProps.preloadedData &&
|
||||
prevProps.loading === nextProps.loading;
|
||||
});
|
||||
|
||||
export default MiniTimelineChart;
|
||||
@@ -1,2 +0,0 @@
|
||||
// src/views/Community/components/StockDetailPanel/components/index.js
|
||||
export { default as MiniTimelineChart } from './MiniTimelineChart';
|
||||
@@ -1,173 +0,0 @@
|
||||
// src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
fetchEventStocks,
|
||||
fetchStockQuotes,
|
||||
fetchEventDetail,
|
||||
fetchHistoricalEvents,
|
||||
fetchChainAnalysis,
|
||||
fetchExpectationScore
|
||||
} from '../../../../../store/slices/stockSlice';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 事件股票数据 Hook
|
||||
* 封装事件相关的所有数据加载逻辑
|
||||
*
|
||||
* @param {string} eventId - 事件ID
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {boolean} options.autoLoad - 是否自动加载数据(默认true)
|
||||
* @param {boolean} options.autoLoadQuotes - 是否自动加载行情数据(默认true,设为false可延迟到展开时加载)
|
||||
* @returns {Object} 事件数据和加载状态
|
||||
*/
|
||||
export const useEventStocks = (eventId, eventTime, { autoLoad = true, autoLoadQuotes = true } = {}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const stocks = useSelector(state =>
|
||||
eventId ? (state.stock.eventStocksCache[eventId] || []) : [],
|
||||
shallowEqual // 防止不必要的引用变化
|
||||
);
|
||||
const quotes = useSelector(state => state.stock.quotes, shallowEqual);
|
||||
const eventDetail = useSelector(state =>
|
||||
eventId ? state.stock.eventDetailsCache[eventId] : null
|
||||
);
|
||||
const historicalEvents = useSelector(state =>
|
||||
eventId ? (state.stock.historicalEventsCache[eventId] || []) : [],
|
||||
shallowEqual // 防止不必要的引用变化
|
||||
);
|
||||
const chainAnalysis = useSelector(state =>
|
||||
eventId ? state.stock.chainAnalysisCache[eventId] : null
|
||||
);
|
||||
const expectationScore = useSelector(state =>
|
||||
eventId ? state.stock.expectationScores[eventId] : null
|
||||
);
|
||||
|
||||
// 加载状态
|
||||
const loading = useSelector(state => state.stock.loading, shallowEqual);
|
||||
|
||||
// 拆分加载函数 - 相关股票数据
|
||||
const loadStocksData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载股票数据', { eventId });
|
||||
dispatch(fetchEventStocks({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 拆分加载函数 - 历史事件数据
|
||||
const loadHistoricalData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载历史事件数据', { eventId });
|
||||
dispatch(fetchHistoricalEvents({ eventId }));
|
||||
dispatch(fetchExpectationScore({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 拆分加载函数 - 传导链分析数据
|
||||
const loadChainAnalysis = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载传导链数据', { eventId });
|
||||
dispatch(fetchChainAnalysis({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 加载所有数据(保留用于兼容性)
|
||||
const loadAllData = useCallback(() => {
|
||||
if (!eventId) {
|
||||
logger.warn('useEventStocks', 'eventId 为空,跳过数据加载');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('useEventStocks', '开始加载事件所有数据', { eventId });
|
||||
|
||||
// 并发加载所有数据
|
||||
dispatch(fetchEventDetail({ eventId }));
|
||||
loadStocksData();
|
||||
loadHistoricalData();
|
||||
loadChainAnalysis();
|
||||
}, [dispatch, eventId, loadStocksData, loadHistoricalData, loadChainAnalysis]);
|
||||
|
||||
// 强制刷新所有数据
|
||||
const refreshAllData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
|
||||
logger.debug('useEventStocks', '强制刷新事件数据', { eventId });
|
||||
|
||||
dispatch(fetchEventStocks({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchEventDetail({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchHistoricalEvents({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchChainAnalysis({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchExpectationScore({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 只刷新行情数据
|
||||
const refreshQuotes = useCallback(() => {
|
||||
if (stocks.length === 0) return;
|
||||
|
||||
const codes = stocks.map(s => s.stock_code);
|
||||
logger.debug('useEventStocks', '刷新行情数据', {
|
||||
stockCount: codes.length,
|
||||
eventTime
|
||||
});
|
||||
|
||||
dispatch(fetchStockQuotes({ codes, eventTime }));
|
||||
}, [dispatch, stocks, eventTime]);
|
||||
|
||||
// 自动加载事件数据(可通过 autoLoad 参数控制)
|
||||
useEffect(() => {
|
||||
if (eventId && autoLoad) {
|
||||
logger.debug('useEventStocks', '自动加载已启用,加载所有数据', { eventId, autoLoad });
|
||||
loadAllData();
|
||||
} else if (eventId && !autoLoad) {
|
||||
logger.debug('useEventStocks', '自动加载已禁用,等待手动触发', { eventId, autoLoad });
|
||||
// 禁用自动加载时,不加载任何数据
|
||||
}
|
||||
}, [eventId, autoLoad, loadAllData]); // 添加 loadAllData 依赖
|
||||
|
||||
// 自动加载行情数据(可通过 autoLoadQuotes 参数控制)
|
||||
useEffect(() => {
|
||||
if (stocks.length > 0 && autoLoadQuotes) {
|
||||
const codes = stocks.map(s => s.stock_code);
|
||||
logger.debug('useEventStocks', '自动加载行情数据', {
|
||||
stockCount: codes.length,
|
||||
eventTime
|
||||
});
|
||||
dispatch(fetchStockQuotes({ codes, eventTime }));
|
||||
}
|
||||
}, [stocks, eventTime, autoLoadQuotes, dispatch]); // 直接使用 stocks 而不是 refreshQuotes
|
||||
|
||||
// 计算股票行情合并数据
|
||||
const stocksWithQuotes = useMemo(() => {
|
||||
return stocks.map(stock => ({
|
||||
...stock,
|
||||
quote: quotes[stock.stock_code] || null
|
||||
}));
|
||||
}, [stocks, quotes]);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
stocks,
|
||||
stocksWithQuotes,
|
||||
quotes,
|
||||
eventDetail,
|
||||
historicalEvents,
|
||||
chainAnalysis,
|
||||
expectationScore,
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
stocks: loading.stocks,
|
||||
quotes: loading.quotes,
|
||||
eventDetail: loading.eventDetail,
|
||||
historicalEvents: loading.historicalEvents,
|
||||
chainAnalysis: loading.chainAnalysis
|
||||
},
|
||||
|
||||
// 方法
|
||||
loadAllData,
|
||||
loadStocksData, // 新增:加载股票数据
|
||||
loadHistoricalData, // 新增:加载历史事件数据
|
||||
loadChainAnalysis, // 新增:加载传导链数据(重命名避免冲突)
|
||||
refreshAllData,
|
||||
refreshQuotes
|
||||
};
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
useBreakpointValue,
|
||||
Skeleton,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
@@ -183,7 +183,7 @@ const CompanyIndex = () => {
|
||||
<CardBody>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="lg">个股信息分析</Heading>
|
||||
<Heading size="lg">个股详情</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
查看股票实时行情、财务数据和盈利预测
|
||||
</Text>
|
||||
|
||||
@@ -584,7 +584,7 @@ const StockOverview = () => {
|
||||
position="relative"
|
||||
bgGradient={heroBg}
|
||||
color="white"
|
||||
overflow="hidden"
|
||||
overflow="visible"
|
||||
pt={{ base: 20, md: 24 }}
|
||||
pb={{ base: 16, md: 20 }}
|
||||
borderBottom={colorMode === 'dark' ? `2px solid ${goldColor}` : 'none'}
|
||||
|
||||
Reference in New Issue
Block a user