Initial commit
This commit is contained in:
256
src/views/Community/components/EventDetailModal.js
Normal file
256
src/views/Community/components/EventDetailModal.js
Normal file
@@ -0,0 +1,256 @@
|
||||
// src/views/Community/components/EventDetailModal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import moment from 'moment';
|
||||
|
||||
const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventDetail, setEventDetail] = useState(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||
|
||||
const loadEventDetail = async () => {
|
||||
if (!event) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setEventDetail(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load event detail:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!event) return;
|
||||
|
||||
setCommentsLoading(true);
|
||||
try {
|
||||
// 使用统一的posts API获取评论
|
||||
const result = await eventService.getPosts(event.id);
|
||||
if (result.success) {
|
||||
setComments(result.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments:', error);
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && event) {
|
||||
loadEventDetail();
|
||||
loadComments();
|
||||
}
|
||||
}, [visible, event]);
|
||||
|
||||
const getImportanceColor = (importance) => {
|
||||
const colors = {
|
||||
S: 'red',
|
||||
A: 'orange',
|
||||
B: 'blue',
|
||||
C: 'green'
|
||||
};
|
||||
return colors[importance] || 'default';
|
||||
};
|
||||
|
||||
const renderPriceTag = (value, label) => {
|
||||
if (value === null || value === undefined) return `${label}: --`;
|
||||
|
||||
const color = value > 0 ? '#ff4d4f' : '#52c41a';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}: <span style={{ color }}>{prefix}{value.toFixed(2)}%</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmitComment = async () => {
|
||||
if (!commentText.trim()) {
|
||||
message.warning('请输入评论内容');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 使用统一的createPost API
|
||||
const result = await eventService.createPost(event.id, {
|
||||
content: commentText.trim(),
|
||||
content_type: 'text'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
message.success('评论发布成功');
|
||||
setCommentText('');
|
||||
// 重新加载评论列表
|
||||
loadComments();
|
||||
} else {
|
||||
throw new Error(result.message || '评论失败');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '评论失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={eventDetail?.title || '事件详情'}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{eventDetail && (
|
||||
<>
|
||||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{moment(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建者">
|
||||
{eventDetail.creator?.username || 'Anonymous'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="重要性">
|
||||
<Badge color={getImportanceColor(eventDetail.importance)} text={`${eventDetail.importance}级`} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="浏览数">
|
||||
{eventDetail.view_count || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涨幅统计" span={2}>
|
||||
<Tag>{renderPriceTag(eventDetail.related_avg_chg, '平均涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_max_chg, '最大涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事件描述" span={2}>
|
||||
{eventDetail.description}(AI合成)
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{eventDetail.keywords && eventDetail.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h4>相关概念</h4>
|
||||
{eventDetail.keywords.map((keyword, index) => (
|
||||
<Tag key={index} color="blue" style={{ marginBottom: 8 }}>
|
||||
{keyword}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetail.related_stocks && eventDetail.related_stocks.length > 0 && (
|
||||
<div>
|
||||
<h4>相关股票</h4>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={eventDetail.related_stocks}
|
||||
renderItem={stock => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
}}
|
||||
>
|
||||
股票详情
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={`${stock.stock_name} (${stock.stock_code})`}
|
||||
description={stock.relation_desc ? `${stock.relation_desc}(AI合成)` : ''}
|
||||
/>
|
||||
{stock.change !== null && (
|
||||
<Tag color={stock.change > 0 ? 'red' : 'green'}>
|
||||
{stock.change > 0 ? '+' : ''}{stock.change.toFixed(2)}%
|
||||
</Tag>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 讨论区 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h4>讨论区</h4>
|
||||
|
||||
{/* 评论列表 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Spin spinning={commentsLoading}>
|
||||
{comments.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无评论"
|
||||
style={{ padding: '20px 0' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={comments}
|
||||
renderItem={comment => (
|
||||
<List.Item key={comment.id}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<strong>{comment.author?.username || 'Anonymous'}</strong>
|
||||
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
|
||||
{moment(comment.created_at).format('MM-DD HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
marginTop: 8
|
||||
}}>
|
||||
{comment.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
{/* 评论输入框(登录后可用,未登录后端会返回401) */}
|
||||
<div>
|
||||
<h4>发表评论</h4>
|
||||
<Input.TextArea
|
||||
placeholder="说点什么..."
|
||||
rows={3}
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
<div style={{ textAlign: 'right', marginTop: 8 }}>
|
||||
<Button type="primary" loading={submitting} onClick={handleSubmitComment}>
|
||||
发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
580
src/views/Community/components/EventDiscussionModal.js
Normal file
580
src/views/Community/components/EventDiscussionModal.js
Normal file
@@ -0,0 +1,580 @@
|
||||
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';
|
||||
|
||||
// 获取 API 基础地址
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
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_BASE_URL}/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 || []);
|
||||
} else {
|
||||
toast({
|
||||
title: '加载帖子失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts:', error);
|
||||
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_BASE_URL}/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 || [] }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments:', error);
|
||||
} 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_BASE_URL}/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();
|
||||
toast({
|
||||
title: '帖子发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: result.message || '帖子发布失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit post:', error);
|
||||
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_BASE_URL}/api/posts/${postId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
loadPosts();
|
||||
toast({
|
||||
title: '帖子已删除',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: result.message || '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete post:', error);
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 点赞帖子
|
||||
const handleLikePost = async (postId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/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
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to like post:', error);
|
||||
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_BASE_URL}/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
|
||||
));
|
||||
toast({
|
||||
title: '评论发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit comment:', error);
|
||||
toast({
|
||||
title: '评论发布失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除评论
|
||||
const handleDeleteComment = async (commentId, postId) => {
|
||||
if (!window.confirm('确定要删除这条评论吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/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
|
||||
));
|
||||
toast({
|
||||
title: '评论已删除',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
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;
|
||||
248
src/views/Community/components/EventFilters.js
Normal file
248
src/views/Community/components/EventFilters.js
Normal file
@@ -0,0 +1,248 @@
|
||||
// src/views/Community/components/EventFilters.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Button, Select, Form, Input } from 'antd';
|
||||
import { FilterOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import locale from 'antd/es/date-picker/locale/zh_CN';
|
||||
import { industryService } from '../../../services/industryService';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = Select;
|
||||
|
||||
const EventFilters = ({ filters, onFilterChange, loading }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [industryData, setIndustryData] = useState({
|
||||
classifications: [],
|
||||
level1: [],
|
||||
level2: [],
|
||||
level3: [],
|
||||
level4: []
|
||||
});
|
||||
|
||||
// 初始化表单值
|
||||
useEffect(() => {
|
||||
const initialValues = {
|
||||
date_range: filters.date_range ? filters.date_range.split(' 至 ').map(d => moment(d)) : null,
|
||||
sort: filters.sort,
|
||||
importance: filters.importance,
|
||||
industry_classification: filters.industry_classification,
|
||||
industry_code: filters.industry_code
|
||||
};
|
||||
form.setFieldsValue(initialValues);
|
||||
}, [filters, form]);
|
||||
|
||||
// 加载行业分类数据
|
||||
const loadIndustryClassifications = async () => {
|
||||
try {
|
||||
const response = await industryService.getClassifications();
|
||||
setIndustryData(prev => ({ ...prev, classifications: response.data }));
|
||||
} catch (error) {
|
||||
console.error('Failed to load industry classifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载行业层级数据
|
||||
const loadIndustryLevels = async (level, params) => {
|
||||
try {
|
||||
const response = await industryService.getLevels(params);
|
||||
setIndustryData(prev => ({ ...prev, [`level${level}`]: response.data }));
|
||||
// 清空下级
|
||||
for (let l = level + 1; l <= 4; l++) {
|
||||
setIndustryData(prev => ({ ...prev, [`level${l}`]: [] }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load industry levels:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadIndustryClassifications();
|
||||
}, []);
|
||||
|
||||
const handleDateRangeChange = (dates) => {
|
||||
if (dates && dates.length === 2) {
|
||||
const dateRange = `${dates[0].format('YYYY-MM-DD')} 至 ${dates[1].format('YYYY-MM-DD')}`;
|
||||
onFilterChange('date_range', dateRange);
|
||||
} else {
|
||||
onFilterChange('date_range', '');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (value) => {
|
||||
onFilterChange('sort', value);
|
||||
};
|
||||
|
||||
const handleImportanceChange = (value) => {
|
||||
onFilterChange('importance', value);
|
||||
};
|
||||
|
||||
// 行业分类体系变化时,加载一级行业
|
||||
const handleIndustryClassificationChange = (value) => {
|
||||
form.setFieldsValue({ industry_code: '' });
|
||||
onFilterChange('industry_classification', value);
|
||||
setIndustryData(prev => ({ ...prev, level1: [], level2: [], level3: [], level4: [] }));
|
||||
if (value) {
|
||||
loadIndustryLevels(1, { classification: value, level: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
// 级联选择行业
|
||||
const handleLevelChange = (level, value) => {
|
||||
// 直接从state里查找name
|
||||
let name = '';
|
||||
if (level === 1) {
|
||||
const found = industryData.level1.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
} else if (level === 2) {
|
||||
const found = industryData.level2.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
} else if (level === 3) {
|
||||
const found = industryData.level3.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
} else if (level === 4) {
|
||||
const found = industryData.level4.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
}
|
||||
form.setFieldsValue({ [`level${level}`]: value });
|
||||
form.setFieldsValue({ industry_code: value });
|
||||
onFilterChange('industry_code', value);
|
||||
for (let l = level + 1; l <= 4; l++) {
|
||||
form.setFieldsValue({ [`level${l}`]: undefined });
|
||||
}
|
||||
const params = { classification: form.getFieldValue('industry_classification'), level: level + 1 };
|
||||
if (level === 1) params.level1_name = name;
|
||||
if (level === 2) {
|
||||
params.level1_name = form.getFieldValue('level1_name');
|
||||
params.level2_name = name;
|
||||
}
|
||||
if (level === 3) {
|
||||
params.level1_name = form.getFieldValue('level1_name');
|
||||
params.level2_name = form.getFieldValue('level2_name');
|
||||
params.level3_name = name;
|
||||
}
|
||||
if (level < 4 && name) {
|
||||
loadIndustryLevels(level + 1, params);
|
||||
}
|
||||
form.setFieldsValue({ [`level${level}_name`]: name });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="event-filters" title="事件筛选" style={{ marginBottom: 16 }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="日期范围" name="date_range">
|
||||
<RangePicker
|
||||
style={{ width: '100%' }}
|
||||
locale={locale}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
onChange={handleDateRangeChange}
|
||||
disabled={loading}
|
||||
allowEmpty={[true, true]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="排序方式" name="sort">
|
||||
<Select onChange={handleSortChange} disabled={loading}>
|
||||
<Option value="new">最新</Option>
|
||||
<Option value="hot">热门</Option>
|
||||
<Option value="returns">收益率</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="重要性" name="importance">
|
||||
<Select onChange={handleImportanceChange} disabled={loading}>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="S">S级</Option>
|
||||
<Option value="A">A级</Option>
|
||||
<Option value="B">B级</Option>
|
||||
<Option value="C">C级</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Form.Item label="行业分类" name="industry_classification">
|
||||
<Select
|
||||
placeholder="选择行业分类体系"
|
||||
onChange={handleIndustryClassificationChange}
|
||||
disabled={loading}
|
||||
allowClear
|
||||
>
|
||||
{industryData.classifications.map(item => (
|
||||
<Option key={item.name} value={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="一级行业" name="level1">
|
||||
<Select
|
||||
placeholder="选择一级行业"
|
||||
onChange={value => handleLevelChange(1, value)}
|
||||
disabled={loading || !form.getFieldValue('industry_classification')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level1.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="二级行业" name="level2">
|
||||
<Select
|
||||
placeholder="选择二级行业"
|
||||
onChange={value => handleLevelChange(2, value)}
|
||||
disabled={loading || !form.getFieldValue('level1')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level2.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="三级行业" name="level3">
|
||||
<Select
|
||||
placeholder="选择三级行业"
|
||||
onChange={value => handleLevelChange(3, value)}
|
||||
disabled={loading || !form.getFieldValue('level2')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level3.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="四级行业" name="level4">
|
||||
<Select
|
||||
placeholder="选择四级行业"
|
||||
onChange={value => handleLevelChange(4, value)}
|
||||
disabled={loading || !form.getFieldValue('level3')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level4.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventFilters;
|
||||
387
src/views/Community/components/EventList.css
Normal file
387
src/views/Community/components/EventList.css
Normal file
@@ -0,0 +1,387 @@
|
||||
/* src/views/Community/components/EventList.css */
|
||||
|
||||
/* 时间轴容器样式 */
|
||||
.event-timeline {
|
||||
padding: 0 0 0 24px;
|
||||
}
|
||||
|
||||
/* 时间轴圆点样式 */
|
||||
.timeline-dot {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* 时间轴事件卡片 */
|
||||
.timeline-event-card {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
transform: translateX(8px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 重要性标记线 */
|
||||
.importance-marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-marker {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* 事件标题 */
|
||||
.event-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.event-title a:hover {
|
||||
color: #40a9ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 事件元信息 */
|
||||
.event-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.event-meta .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.event-meta .separator {
|
||||
margin: 0;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
/* 事件描述 */
|
||||
.event-description {
|
||||
margin: 0 0 12px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 事件统计标签 */
|
||||
.event-stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag {
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 事件操作区域 */
|
||||
.event-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.event-actions > span {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.event-actions .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 事件按钮 */
|
||||
.event-buttons {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-sm {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 重要性指示器 */
|
||||
.importance-indicator {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-badge {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-avatar {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-indicator .ant-avatar {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 分页容器 */
|
||||
.pagination-container {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.event-timeline {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.timeline-event-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.importance-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色主题支持(可选) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.timeline-event-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
border-color: #434343;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间轴项目动画 */
|
||||
.ant-timeline-item {
|
||||
animation: fadeInLeft 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-timeline-item:nth-child(1) { animation-delay: 0.1s; }
|
||||
.ant-timeline-item:nth-child(2) { animation-delay: 0.2s; }
|
||||
.ant-timeline-item:nth-child(3) { animation-delay: 0.3s; }
|
||||
.ant-timeline-item:nth-child(4) { animation-delay: 0.4s; }
|
||||
.ant-timeline-item:nth-child(5) { animation-delay: 0.5s; }
|
||||
.ant-timeline-item:nth-child(6) { animation-delay: 0.6s; }
|
||||
.ant-timeline-item:nth-child(7) { animation-delay: 0.7s; }
|
||||
.ant-timeline-item:nth-child(8) { animation-delay: 0.8s; }
|
||||
.ant-timeline-item:nth-child(9) { animation-delay: 0.9s; }
|
||||
.ant-timeline-item:nth-child(10) { animation-delay: 1s; }
|
||||
|
||||
/* 时间轴连接线样式 */
|
||||
.ant-timeline-item-tail {
|
||||
border-left-style: dashed;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
/* 涨跌幅标签特殊样式 */
|
||||
.event-stats .ant-tag[color="#ff4d4f"] {
|
||||
background-color: #fff1f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag[color="#52c41a"] {
|
||||
background-color: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
/* 快速查看和详细信息按钮悬停效果 */
|
||||
.event-buttons .ant-btn-default:hover {
|
||||
color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 工具提示样式 */
|
||||
.ant-tooltip-inner {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 徽章计数样式 */
|
||||
.importance-indicator .ant-badge-count {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* 加载状态动画 */
|
||||
.timeline-event-card.loading {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 特殊重要性等级样式增强 */
|
||||
.timeline-event-card[data-importance="S"] {
|
||||
border-left: 4px solid #722ed1;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="A"] {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="B"] {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="C"] {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
/* 时间轴左侧内容区域优化 */
|
||||
.ant-timeline-item-content {
|
||||
padding-bottom: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 确保最后一个时间轴项目没有连接线 */
|
||||
.ant-timeline-item:last-child .ant-timeline-item-tail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 打印样式优化 */
|
||||
@media print {
|
||||
.timeline-event-card {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.importance-marker {
|
||||
width: 2px !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
}
|
||||
807
src/views/Community/components/EventList.js
Normal file
807
src/views/Community/components/EventList.js
Normal file
@@ -0,0 +1,807 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Flex,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Circle,
|
||||
Stat,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
ButtonGroup,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
Center,
|
||||
Link,
|
||||
Spacer,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ViewIcon,
|
||||
ChatIcon,
|
||||
StarIcon,
|
||||
TimeIcon,
|
||||
InfoIcon,
|
||||
WarningIcon,
|
||||
WarningTwoIcon,
|
||||
CheckCircleIcon,
|
||||
TriangleUpIcon,
|
||||
TriangleDownIcon,
|
||||
ArrowForwardIcon,
|
||||
ExternalLinkIcon,
|
||||
ViewOffIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
|
||||
// ========== 工具函数定义在组件外部 ==========
|
||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||
const getPriceChangeColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.500';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨用红色,根据涨幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'red.600'; // 深红色:3%以上
|
||||
if (absValue >= 1) return 'red.500'; // 中红色:1-3%
|
||||
return 'red.400'; // 浅红色:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌用绿色,根据跌幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'green.600'; // 深绿色:3%以上
|
||||
if (absValue >= 1) return 'green.500'; // 中绿色:1-3%
|
||||
return 'green.400'; // 浅绿色:0-1%
|
||||
}
|
||||
return 'gray.500';
|
||||
};
|
||||
|
||||
const getPriceChangeBg = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.50';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨背景色
|
||||
if (absValue >= 3) return 'red.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'red.50'; // 中色背景:1-3%
|
||||
return 'red.50'; // 浅色背景:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌背景色
|
||||
if (absValue >= 3) return 'green.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'green.50'; // 中色背景:1-3%
|
||||
return 'green.50'; // 浅色背景:0-1%
|
||||
}
|
||||
return 'gray.50';
|
||||
};
|
||||
|
||||
const getPriceChangeBorderColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.300';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨边框色
|
||||
if (absValue >= 3) return 'red.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'red.400'; // 中边框:1-3%
|
||||
return 'red.300'; // 浅边框:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌边框色
|
||||
if (absValue >= 3) return 'green.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'green.400'; // 中边框:1-3%
|
||||
return 'green.300'; // 浅边框:0-1%
|
||||
}
|
||||
return 'gray.300';
|
||||
};
|
||||
|
||||
// 重要性等级配置 - 金融配色方案
|
||||
const importanceLevels = {
|
||||
'S': {
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
},
|
||||
'A': {
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
},
|
||||
'B': {
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
},
|
||||
'C': {
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
}
|
||||
};
|
||||
|
||||
const getImportanceConfig = (importance) => {
|
||||
return importanceLevels[importance] || importanceLevels['C'];
|
||||
};
|
||||
|
||||
// 自定义的涨跌箭头组件(修复颜色问题)
|
||||
const PriceArrow = ({ value }) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
|
||||
const color = value > 0 ? 'red.500' : 'green.500';
|
||||
|
||||
return <Icon color={color} boxSize="16px" />;
|
||||
};
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
events.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
console.warn('load following failed', e);
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 events 更新时重跑
|
||||
}, [events]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
} catch (e) {
|
||||
console.warn('toggle follow failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const renderPriceChange = (value, label) => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
|
||||
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
// 根据涨跌幅大小选择不同的颜色深浅
|
||||
let colorScheme = 'gray';
|
||||
let variant = 'solid';
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨用红色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'red';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'red';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'red';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
} else {
|
||||
// 下跌用绿色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'green';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'green';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'green';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
size="lg"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
variant={variant}
|
||||
boxShadow="sm"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLeftIcon as={Icon} boxSize="16px" />
|
||||
<TagLabel fontSize="sm" fontWeight="bold">
|
||||
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 精简模式的事件渲染
|
||||
const renderCompactEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="32px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
boxShadow="sm"
|
||||
border="2px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="60px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 精简事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={3}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
|
||||
{/* 左侧:标题和时间 */}
|
||||
<VStack align="start" spacing={2} flex="1" minW="200px">
|
||||
<Heading
|
||||
size="sm"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
noOfLines={1}
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
<HStack spacing={2} fontSize="xs" color={mutedColor}>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
|
||||
<Text>•</Text>
|
||||
<Text>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅指标 */}
|
||||
<HStack spacing={3}>
|
||||
<Tooltip label="平均涨幅" placement="top">
|
||||
<Box
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="1px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getPriceChangeColor(event.related_avg_chg)}
|
||||
>
|
||||
{event.related_avg_chg != null
|
||||
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
|
||||
: '--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
colorScheme="yellow"
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
|
||||
const renderDetailedEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="40px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
boxShadow="md"
|
||||
border="3px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="100px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={4}
|
||||
>
|
||||
<CardBody p={5}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题和重要性标签 */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Tooltip
|
||||
label="点击查看事件详情"
|
||||
placement="top"
|
||||
hasArrow
|
||||
openDelay={500}
|
||||
>
|
||||
<Heading
|
||||
size="md"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
colorScheme={importance.color.split('.')[0]}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{importance.label}优先级
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* 元信息 */}
|
||||
<HStack spacing={4} fontSize="sm">
|
||||
<HStack
|
||||
bg="blue.50"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
color="blue.700"
|
||||
fontWeight="medium"
|
||||
>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
</HStack>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
|
||||
{event.description}
|
||||
</Text>
|
||||
|
||||
{/* 价格变化指标 */}
|
||||
<Box
|
||||
bg={useColorModeValue('gradient.subtle', 'gray.700')}
|
||||
bgGradient="linear(to-r, gray.50, white)"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
平均涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
|
||||
{event.related_avg_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_max_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
最大涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
|
||||
{event.related_max_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_max_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_week_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
周涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
|
||||
{event.related_week_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_week_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 统计信息和操作按钮 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
<HStack spacing={6}>
|
||||
<Tooltip label="浏览量" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ViewIcon />
|
||||
<Text fontSize="sm">{event.view_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="帖子数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ChatIcon />
|
||||
<Text fontSize="sm">{event.post_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="关注数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<StarIcon />
|
||||
<Text fontSize="sm">{followerCount}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
<ButtonGroup size="sm" spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
leftIcon={<ViewIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
}}
|
||||
>
|
||||
快速查看
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详细信息
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={current === pageNum ? 'solid' : 'ghost'}
|
||||
colorScheme={current === pageNum ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{totalPages > 5 && <Text>...</Text>}
|
||||
{totalPages > 5 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={current === totalPages ? 'solid' : 'ghost'}
|
||||
colorScheme={current === totalPages ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
{/* 视图切换控制 */}
|
||||
<Flex justify="flex-end" mb={6}>
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
||||
精简模式
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{events.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
{isCompactMode
|
||||
? renderCompactEvent(event)
|
||||
: renderDetailedEvent(event)
|
||||
}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
98
src/views/Community/components/HotEvents.css
Normal file
98
src/views/Community/components/HotEvents.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* Hot Events Section */
|
||||
.hot-events-section {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.hot-event-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.hot-event-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Cover image */
|
||||
.event-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.importance-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Card content */
|
||||
.event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.event-header .ant-tag {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.event-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.creator {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
114
src/views/Community/components/HotEvents.js
Normal file
114
src/views/Community/components/HotEvents.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// src/views/Community/components/HotEvents.js
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Badge, Tag, Empty } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, FireOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg'
|
||||
const HotEvents = ({ events }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const renderPriceChange = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return <Tag color="default">--</Tag>;
|
||||
}
|
||||
|
||||
const isPositive = value > 0;
|
||||
const icon = isPositive ? <ArrowUpOutlined /> : <ArrowDownOutlined />;
|
||||
const color = isPositive ? '#ff4d4f' : '#52c41a';
|
||||
|
||||
return (
|
||||
<Tag color={color}>
|
||||
{icon} {Math.abs(value).toFixed(2)}%
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const getImportanceColor = (importance) => {
|
||||
const colors = {
|
||||
S: 'red',
|
||||
A: 'orange',
|
||||
B: 'blue',
|
||||
C: 'green'
|
||||
};
|
||||
return colors[importance] || 'default';
|
||||
};
|
||||
|
||||
const handleCardClick = (eventId) => {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hot-events-section">
|
||||
<h2 className="section-title">
|
||||
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
|
||||
近期热点信息
|
||||
</h2>
|
||||
<p className="section-subtitle">展示最近5天内涨幅最高的事件,助您把握市场热点</p>
|
||||
|
||||
{events && events.length > 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{events.map((event, index) => (
|
||||
<Col lg={6} md={12} sm={24} key={event.id}>
|
||||
<Card
|
||||
hoverable
|
||||
className="hot-event-card"
|
||||
onClick={() => handleCardClick(event.id)}
|
||||
cover={
|
||||
<div className="event-cover">
|
||||
<img
|
||||
alt={event.title}
|
||||
src={`/images/events/${['first', 'second', 'third', 'fourth'][index] || 'first'}.jpg`}
|
||||
onError={e => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = defaultEventImage;
|
||||
}}
|
||||
/>
|
||||
{event.importance && (
|
||||
<Badge
|
||||
className="importance-badge"
|
||||
color={getImportanceColor(event.importance)}
|
||||
text={`${event.importance}级`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div className="event-header">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p className="event-description">
|
||||
{event.description && event.description.length > 80
|
||||
? `${event.description.substring(0, 80)}...`
|
||||
: event.description}
|
||||
</p>
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">{moment(event.created_at).format('MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : (
|
||||
<Card>
|
||||
<Empty description="暂无热点信息" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotEvents;
|
||||
34
src/views/Community/components/ImportanceLegend.js
Normal file
34
src/views/Community/components/ImportanceLegend.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// src/views/Community/components/ImportanceLegend.js
|
||||
import React from 'react';
|
||||
import { Card, Space, Badge } from 'antd';
|
||||
|
||||
const ImportanceLegend = () => {
|
||||
const levels = [
|
||||
{ level: 'S', color: '#ff4d4f', description: '重大事件,市场影响深远' },
|
||||
{ level: 'A', color: '#faad14', description: '重要事件,影响较大' },
|
||||
{ level: 'B', color: '#1890ff', description: '普通事件,有一定影响' },
|
||||
{ level: 'C', color: '#52c41a', description: '参考事件,影响有限' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="重要性等级说明" className="importance-legend">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{levels.map(item => (
|
||||
<div key={item.level} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Badge
|
||||
color={item.color}
|
||||
text={
|
||||
<span>
|
||||
<strong style={{ marginRight: 8 }}>{item.level}级</strong>
|
||||
{item.description}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportanceLegend;
|
||||
277
src/views/Community/components/InvestmentCalendar.css
Normal file
277
src/views/Community/components/InvestmentCalendar.css
Normal file
@@ -0,0 +1,277 @@
|
||||
/* --- 全局与通用增强 --- */
|
||||
|
||||
/* 为交互元素增加平滑的过渡效果,提升用户体验 */
|
||||
.ant-btn, .ant-tag, .ant-picker-cell {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* --- 投资日历卡片与日历本身 --- */
|
||||
|
||||
.investment-calendar .ant-card-body {
|
||||
/* 为紧凑型日历减少内边距,使其不那么空旷 */
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-calendar-date {
|
||||
position: relative; /* 保持相对定位,为角标提供定位锚点 */
|
||||
}
|
||||
|
||||
/* 日历单元格上的事件指示器 */
|
||||
.investment-calendar .calendar-events {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
pointer-events: none; /* 避免阻止点击事件 */
|
||||
}
|
||||
|
||||
.investment-calendar .event-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.investment-calendar .event-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.investment-calendar .event-count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8);
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 当事件数量很多时,调整样式 */
|
||||
.investment-calendar .event-count.many-events {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 8px;
|
||||
padding: 1px 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* 响应式设计 - 在小屏幕上调整指示器大小 */
|
||||
@media (max-width: 768px) {
|
||||
.investment-calendar .event-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.investment-calendar .event-count {
|
||||
font-size: 9px;
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell-in-view .ant-picker-calendar-date {
|
||||
min-height: 28px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 增强可访问性 - 为有事件的日期添加更明显的视觉提示 */
|
||||
.investment-calendar .ant-picker-cell-in-view:has(.calendar-events) .ant-picker-calendar-date {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell-in-view:has(.calendar-events) .ant-picker-calendar-date::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 1px solid rgba(24, 144, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 优化日历单元格的布局和显示 */
|
||||
.investment-calendar .ant-picker-cell-in-view .ant-picker-calendar-date {
|
||||
transition: background-color 0.3s;
|
||||
min-height: 32px; /* 确保有足够空间显示日期和事件指示器 */
|
||||
display: flex;
|
||||
align-items: flex-start; /* 日期数字置顶显示 */
|
||||
justify-content: flex-start;
|
||||
padding: 4px 6px; /* 适当的内边距 */
|
||||
}
|
||||
|
||||
/* 日期数字样式优化 */
|
||||
.investment-calendar .ant-picker-calendar-date-value {
|
||||
font-weight: 500;
|
||||
z-index: 1; /* 确保日期数字在最上层 */
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell-selected .ant-picker-calendar-date {
|
||||
background-color: #e6f7ff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1890ff;
|
||||
}
|
||||
|
||||
.investment-calendar .ant-picker-cell:hover .ant-picker-calendar-date {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 有事件的日期单元格特殊样式 */
|
||||
.investment-calendar .ant-picker-cell-in-view .ant-picker-calendar-date:has(.calendar-events) {
|
||||
background-color: rgba(24, 144, 255, 0.05); /* 淡蓝色背景提示有事件 */
|
||||
}
|
||||
|
||||
/* 突出显示“今天”的单元格 */
|
||||
.investment-calendar .ant-picker-calendar-date-today {
|
||||
border: 1px solid #1890ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
/* --- 事件与股票弹窗 (Modal) --- */
|
||||
|
||||
/* 为弹窗内的Tabs内容区增加一些上边距 */
|
||||
.ant-modal-body .ant-tabs-content-holder {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* --- 表格 (Table) 统一样式 --- */
|
||||
|
||||
/* 为表格行增加悬停背景色,提供清晰的视觉反馈 */
|
||||
.ant-table-tbody > tr.ant-table-row:hover > td {
|
||||
background: #fafafa !important;
|
||||
}
|
||||
|
||||
/* 默认将所有单元格内容垂直居中,使表格看起来更整洁 */
|
||||
.ant-table-cell {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
/* --- 事件表格列的特定样式 --- */
|
||||
|
||||
/* 将“重要度”列的表头和内容都居中对齐 */
|
||||
.ant-table-thead th:nth-child(2).ant-table-cell,
|
||||
.ant-table-tbody td:nth-child(2).ant-table-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- 股票表格列的特定样式 --- */
|
||||
|
||||
/* 美化“相关度”进度条 */
|
||||
.ant-table-tbody td:nth-child(6) > .ant-tooltip > div { /* 进度条外层容器 */
|
||||
height: 16px !important;
|
||||
background: #f0f2f5 !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden; /* 确保内层进度条圆角不溢出 */
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.ant-table-tbody td:nth-child(6) .ant-tooltip > div > div { /* 进度条内层填充 */
|
||||
border-radius: 8px !important;
|
||||
/* 增加渐变和细微阴影,使其更具质感 */
|
||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-size: 40px 40px;
|
||||
box-shadow: inset 0 -1px 1px rgba(0, 0, 0, 0.1);
|
||||
/* 让宽度变化的动画更平滑 */
|
||||
transition: width 0.5s cubic-bezier(0.23, 1, 0.32, 1) !important;
|
||||
}
|
||||
|
||||
/* 将“K线图”操作列居中 */
|
||||
.ant-table-thead th:nth-child(7).ant-table-cell,
|
||||
.ant-table-tbody td:nth-child(7).ant-table-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/* --- 内容详情抽屉 (Drawer) --- */
|
||||
/* (您提供的Markdown样式已经很完善,这里保留并整合) */
|
||||
|
||||
.markdown-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.markdown-content h1 { font-size: 24px; }
|
||||
.markdown-content h2 { font-size: 20px; border-bottom: 1px solid #eee; padding-bottom: 8px; }
|
||||
.markdown-content h3 { font-size: 18px; }
|
||||
.markdown-content h4 { font-size: 16px; }
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
padding-left: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
padding: 12px 20px;
|
||||
margin: 16px 0;
|
||||
border-left: 4px solid #1890ff;
|
||||
background-color: #f6f8fa;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 3px 6px;
|
||||
margin: 0 2px;
|
||||
font-size: 13px;
|
||||
background-color: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
background-color: #2d2d2d; /* 暗色背景代码块 */
|
||||
color: #f8f8f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f6f8fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
695
src/views/Community/components/InvestmentCalendar.js
Normal file
695
src/views/Community/components/InvestmentCalendar.js
Normal file
@@ -0,0 +1,695 @@
|
||||
// src/views/Community/components/InvestmentCalendar.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
|
||||
Drawer, Typography, Divider, Space, Tooltip, message, Alert
|
||||
} from 'antd';
|
||||
import {
|
||||
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
|
||||
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined
|
||||
} from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
|
||||
import { useSubscription } from '../../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
const InvestmentCalendar = () => {
|
||||
// 权限控制
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
|
||||
const [eventCounts, setEventCounts] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(moment());
|
||||
|
||||
// 新增状态
|
||||
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
|
||||
const [selectedDetail, setSelectedDetail] = useState(null);
|
||||
const [stockModalVisible, setStockModalVisible] = useState(false);
|
||||
const [selectedStocks, setSelectedStocks] = useState([]);
|
||||
const [stockQuotes, setStockQuotes] = useState({});
|
||||
const [klineModalVisible, setKlineModalVisible] = useState(false);
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
const [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表
|
||||
const [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码
|
||||
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
|
||||
|
||||
// 加载月度事件统计
|
||||
const loadEventCounts = async (date) => {
|
||||
try {
|
||||
const year = date.year();
|
||||
const month = date.month() + 1;
|
||||
const response = await eventService.calendar.getEventCounts(year, month);
|
||||
|
||||
if (response.success) {
|
||||
setEventCounts(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load calendar event counts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载指定日期的事件
|
||||
const loadDateEvents = async (date) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const response = await eventService.calendar.getEventsForDate(dateStr);
|
||||
|
||||
if (response.success) {
|
||||
setSelectedDateEvents(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load date events:', error);
|
||||
setSelectedDateEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取六位股票代码(去掉后缀)
|
||||
const getSixDigitCode = (code) => {
|
||||
if (!code) return code;
|
||||
// 如果有.SH或.SZ后缀,去掉
|
||||
return code.split('.')[0];
|
||||
};
|
||||
|
||||
// 加载股票行情
|
||||
const loadStockQuotes = async (stocks, eventTime) => {
|
||||
try {
|
||||
const codes = stocks.map(stock => getSixDigitCode(stock[0])); // 确保使用六位代码
|
||||
const quotes = {};
|
||||
|
||||
// 使用市场API获取最新行情数据
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const code = codes[i];
|
||||
const originalCode = stocks[i][0]; // 保持原始代码作为key
|
||||
try {
|
||||
const response = await fetch(`/api/market/trade/${code}?days=1`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
const latest = data.data[data.data.length - 1]; // 最新数据
|
||||
quotes[originalCode] = {
|
||||
price: latest.close,
|
||||
change: latest.change_amount,
|
||||
changePercent: latest.change_percent
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load quote for ${code}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
setStockQuotes(quotes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load stock quotes:', error);
|
||||
message.error('加载股票行情失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEventCounts(currentMonth);
|
||||
}, [currentMonth]);
|
||||
|
||||
// 自定义日期单元格渲染
|
||||
const dateCellRender = (value) => {
|
||||
const dateStr = value.format('YYYY-MM-DD');
|
||||
const dayEvents = eventCounts.find(item => item.date === dateStr);
|
||||
|
||||
if (dayEvents && dayEvents.count > 0) {
|
||||
return (
|
||||
<div className="calendar-events">
|
||||
{/* 使用小圆点指示器,不遮挡日期数字 */}
|
||||
<div className="event-indicators">
|
||||
<div
|
||||
className="event-dot"
|
||||
style={{ backgroundColor: getEventCountColor(dayEvents.count) }}
|
||||
title={`${dayEvents.count}个事件`}
|
||||
/>
|
||||
<span
|
||||
className={`event-count ${dayEvents.count >= 10 ? 'many-events' : ''}`}
|
||||
style={{ color: getEventCountColor(dayEvents.count) }}
|
||||
>
|
||||
{dayEvents.count > 99 ? '99+' : dayEvents.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 根据事件数量获取颜色 - 更丰富的渐进色彩
|
||||
const getEventCountColor = (count) => {
|
||||
if (count >= 15) return '#f5222d'; // 深红色 - 非常多
|
||||
if (count >= 10) return '#fa541c'; // 橙红色 - 很多
|
||||
if (count >= 8) return '#fa8c16'; // 橙色 - 较多
|
||||
if (count >= 5) return '#faad14'; // 金黄色 - 中等
|
||||
if (count >= 3) return '#52c41a'; // 绿色 - 少量
|
||||
return '#1890ff'; // 蓝色 - 很少
|
||||
};
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateSelect = (value) => {
|
||||
setSelectedDate(value);
|
||||
loadDateEvents(value);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 渲染重要性星级
|
||||
const renderStars = (star) => {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(
|
||||
<StarFilled
|
||||
key={i}
|
||||
style={{
|
||||
color: i <= star ? '#faad14' : '#d9d9d9',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>{stars}</span>;
|
||||
};
|
||||
|
||||
// 显示内容详情
|
||||
const showContentDetail = (content, title) => {
|
||||
setSelectedDetail({ content, title });
|
||||
setDetailDrawerVisible(true);
|
||||
};
|
||||
|
||||
// 显示相关股票
|
||||
const showRelatedStocks = (stocks, eventTime) => {
|
||||
// 检查权限
|
||||
if (!hasFeatureAccess('related_stocks')) {
|
||||
setUpgradeModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stocks || stocks.length === 0) {
|
||||
message.info('暂无相关股票');
|
||||
return;
|
||||
}
|
||||
// 按相关度排序(限降序)
|
||||
const sortedStocks = [...stocks].sort((a, b) => (b[3] || 0) - (a[3] || 0));
|
||||
setSelectedStocks(sortedStocks);
|
||||
setStockModalVisible(true);
|
||||
loadStockQuotes(sortedStocks, eventTime);
|
||||
};
|
||||
|
||||
// 显示K线图
|
||||
const showKline = (stock) => {
|
||||
setSelectedStock({
|
||||
code: getSixDigitCode(stock[0]), // 确保使用六位代码
|
||||
name: stock[1]
|
||||
});
|
||||
setKlineModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理关注切换
|
||||
const handleFollowToggle = async (eventId) => {
|
||||
setFollowingIds(prev => [...prev, eventId]);
|
||||
try {
|
||||
const response = await eventService.calendar.toggleFollow(eventId);
|
||||
if (response.success) {
|
||||
// 更新本地事件列表的关注状态
|
||||
setSelectedDateEvents(prev =>
|
||||
prev.map(event =>
|
||||
event.id === eventId
|
||||
? { ...event, is_following: response.data.is_following }
|
||||
: event
|
||||
)
|
||||
);
|
||||
message.success(response.data.is_following ? '关注成功' : '取消关注成功');
|
||||
} else {
|
||||
message.error(response.error || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('关注操作失败:', error);
|
||||
message.error('操作失败,请重试');
|
||||
} finally {
|
||||
setFollowingIds(prev => prev.filter(id => id !== eventId));
|
||||
}
|
||||
};
|
||||
|
||||
// 添加单只股票到自选
|
||||
const addSingleToWatchlist = async (stock) => {
|
||||
const stockCode = getSixDigitCode(stock[0]);
|
||||
|
||||
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/watchlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
stock_code: stockCode, // 使用六位代码
|
||||
stock_name: stock[1] // 股票名称
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
message.success(`已将 ${stock[1]}(${stockCode}) 添加到自选`);
|
||||
} else {
|
||||
message.error(data.error || '添加失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`添加${stock[1]}(${stockCode})到自选失败:`, error);
|
||||
message.error('添加失败,请重试');
|
||||
} finally {
|
||||
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 事件表格列定义
|
||||
const eventColumns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'calendar_time',
|
||||
key: 'time',
|
||||
width: 80,
|
||||
render: (time) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text>{moment(time).format('HH:mm')}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '重要度',
|
||||
dataIndex: 'star',
|
||||
key: 'star',
|
||||
width: 120,
|
||||
render: renderStars
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<Tooltip title={text}>
|
||||
<Text strong>{text}</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '背景',
|
||||
dataIndex: 'former',
|
||||
key: 'former',
|
||||
width: 80,
|
||||
render: (text) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => showContentDetail(text + (text ? '\n\n(AI合成)' : ''), '事件背景')}
|
||||
disabled={!text}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
title: (
|
||||
<span>
|
||||
相关股票
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6, color: '#faad14' }} />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'related_stocks',
|
||||
key: 'stocks',
|
||||
width: 100,
|
||||
render: (stocks, record) => {
|
||||
const hasStocks = stocks && stocks.length > 0;
|
||||
const hasAccess = hasFeatureAccess('related_stocks');
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={hasAccess ? <StockOutlined /> : <LockOutlined />}
|
||||
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
|
||||
disabled={!hasStocks}
|
||||
style={!hasAccess ? { color: '#faad14' } : {}}
|
||||
>
|
||||
{hasStocks ? (hasAccess ? `${stocks.length}只` : '🔒需Pro') : '无'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '相关概念',
|
||||
dataIndex: 'concepts',
|
||||
key: 'concepts',
|
||||
width: 200,
|
||||
render: (concepts) => (
|
||||
<Space wrap>
|
||||
{concepts && concepts.length > 0 ? (
|
||||
concepts.slice(0, 3).map((concept, index) => (
|
||||
<Tag key={index} icon={<TagsOutlined />}>
|
||||
{Array.isArray(concept) ? concept[0] : concept}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">无</Text>
|
||||
)}
|
||||
{concepts && concepts.length > 3 && (
|
||||
<Tag>+{concepts.length - 3}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '关注',
|
||||
key: 'follow',
|
||||
width: 60,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type={record.is_following ? "primary" : "default"}
|
||||
icon={record.is_following ? <StarFilled /> : <StarOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleFollowToggle(record.id)}
|
||||
loading={followingIds.includes(record.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 股票表格列定义
|
||||
const stockColumns = [
|
||||
{
|
||||
title: '代码',
|
||||
dataIndex: '0',
|
||||
key: 'code',
|
||||
width: 100,
|
||||
render: (code) => {
|
||||
const sixDigitCode = getSixDigitCode(code);
|
||||
return (
|
||||
<a
|
||||
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Text code>{sixDigitCode}</Text>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: '1',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
render: (name, record) => {
|
||||
const sixDigitCode = getSixDigitCode(record[0]);
|
||||
return (
|
||||
<a
|
||||
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Text strong>{name}</Text>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '现价',
|
||||
key: 'price',
|
||||
width: 80,
|
||||
render: (_, record) => {
|
||||
const quote = stockQuotes[record[0]];
|
||||
if (quote && quote.price !== undefined) {
|
||||
return (
|
||||
<Text type={quote.change > 0 ? 'danger' : 'success'}>
|
||||
{quote.price?.toFixed(2)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <Text>-</Text>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '涨跌幅',
|
||||
key: 'change',
|
||||
width: 100,
|
||||
render: (_, record) => {
|
||||
const quote = stockQuotes[record[0]];
|
||||
if (quote && quote.changePercent !== undefined) {
|
||||
const changePercent = quote.changePercent || 0;
|
||||
return (
|
||||
<Tag color={changePercent > 0 ? 'red' : 'green'}>
|
||||
{changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}%
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return <Text>-</Text>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '关联理由',
|
||||
dataIndex: '2',
|
||||
key: 'reason',
|
||||
render: (reason, record) => {
|
||||
const stockCode = record[0];
|
||||
const isExpanded = expandedReasons[stockCode] || false;
|
||||
const shouldTruncate = reason && reason.length > 100;
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedReasons(prev => ({
|
||||
...prev,
|
||||
[stockCode]: !prev[stockCode]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text>
|
||||
{isExpanded || !shouldTruncate
|
||||
? reason
|
||||
: `${reason?.slice(0, 100)}...`
|
||||
}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={toggleExpanded}
|
||||
style={{ padding: 0, marginLeft: 4 }}
|
||||
>
|
||||
({isExpanded ? '收起' : '展开'})
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>(AI合成)</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
title: 'K线图',
|
||||
key: 'kline',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => showKline(record)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => {
|
||||
const stockCode = getSixDigitCode(record[0]);
|
||||
const isAdding = addingToWatchlist[stockCode] || false;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
loading={isAdding}
|
||||
onClick={() => addSingleToWatchlist(record)}
|
||||
>
|
||||
加自选
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
投资日历
|
||||
</span>
|
||||
}
|
||||
className="investment-calendar"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Calendar
|
||||
fullscreen={false}
|
||||
dateCellRender={dateCellRender}
|
||||
onSelect={handleDateSelect}
|
||||
onPanelChange={(date) => setCurrentMonth(date)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 事件列表模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<CalendarOutlined />
|
||||
<span>{selectedDate?.format('YYYY年MM月DD日')} 投资事件</span>
|
||||
</Space>
|
||||
}
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={1200}
|
||||
footer={null}
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Tabs defaultActiveKey="event">
|
||||
<TabPane tab={`事件 (${selectedDateEvents.filter(e => e.type === 'event').length})`} key="event">
|
||||
<Table
|
||||
dataSource={selectedDateEvents.filter(e => e.type === 'event')}
|
||||
columns={eventColumns}
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane tab={`数据 (${selectedDateEvents.filter(e => e.type === 'data').length})`} key="data">
|
||||
<Table
|
||||
dataSource={selectedDateEvents.filter(e => e.type === 'data')}
|
||||
columns={eventColumns}
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
||||
{/* 内容详情抽屉 */}
|
||||
<Drawer
|
||||
title={selectedDetail?.title}
|
||||
placement="right"
|
||||
width={600}
|
||||
onClose={() => setDetailDrawerVisible(false)}
|
||||
visible={detailDrawerVisible}
|
||||
>
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown>{selectedDetail?.content || '暂无内容'}</ReactMarkdown>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
{/* 相关股票模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<StockOutlined />
|
||||
<span>相关股票</span>
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<LockOutlined style={{ color: '#faad14' }} />
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
visible={stockModalVisible}
|
||||
onCancel={() => {
|
||||
setStockModalVisible(false);
|
||||
setExpandedReasons({}); // 清理展开状态
|
||||
setAddingToWatchlist({}); // 清理加自选状态
|
||||
}}
|
||||
width={1000}
|
||||
footer={
|
||||
<Button onClick={() => setStockModalVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{hasFeatureAccess('related_stocks') ? (
|
||||
<Table
|
||||
dataSource={selectedStocks}
|
||||
columns={stockColumns}
|
||||
rowKey={(record) => record[0]}
|
||||
size="middle"
|
||||
pagination={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
|
||||
<LockOutlined />
|
||||
</div>
|
||||
<Alert
|
||||
message="相关股票功能已锁定"
|
||||
description="此功能需要Pro版订阅才能使用"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => setUpgradeModalOpen(true)}
|
||||
>
|
||||
升级到 Pro版
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* K线图模态框 */}
|
||||
{klineModalVisible && selectedStock && (
|
||||
<StockChartAntdModal
|
||||
open={klineModalVisible}
|
||||
stock={selectedStock}
|
||||
onCancel={() => setKlineModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 订阅升级模态框 */}
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModalOpen}
|
||||
onClose={() => setUpgradeModalOpen(false)}
|
||||
requiredLevel="pro"
|
||||
featureName="相关股票分析"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvestmentCalendar;
|
||||
813
src/views/Community/components/MidjourneyHeroSection.js
Normal file
813
src/views/Community/components/MidjourneyHeroSection.js
Normal file
@@ -0,0 +1,813 @@
|
||||
import React, { useRef, useMemo, useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Particles from 'react-tsparticles';
|
||||
import { loadSlim } from 'tsparticles-slim';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Button,
|
||||
HStack,
|
||||
VStack,
|
||||
Badge,
|
||||
Grid,
|
||||
GridItem,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
Flex,
|
||||
Tag,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ComposedChart,
|
||||
ReferenceLine,
|
||||
ReferenceDot,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { indexService } from '../../../services/eventService';
|
||||
|
||||
// 将后端分钟/分时数据转换为 Recharts 数据
|
||||
const toLineSeries = (resp) => {
|
||||
const arr = resp?.data || [];
|
||||
return arr.map((d, i) => ({ time: d.time || i, value: d.price ?? d.close, volume: d.volume }));
|
||||
};
|
||||
|
||||
// 提取昨日收盘价:优先使用最后一条记录的 prev_close;否则回退到倒数第二条的 close
|
||||
const getPrevClose = (resp) => {
|
||||
const arr = resp?.data || [];
|
||||
if (!arr.length) return null;
|
||||
const last = arr[arr.length - 1] || {};
|
||||
if (last.prev_close !== undefined && last.prev_close !== null && isFinite(Number(last.prev_close))) {
|
||||
return Number(last.prev_close);
|
||||
}
|
||||
const idx = arr.length >= 2 ? arr.length - 2 : arr.length - 1;
|
||||
const k = arr[idx] || {};
|
||||
const candidate = k.close ?? k.c ?? k.price ?? null;
|
||||
return candidate != null ? Number(candidate) : null;
|
||||
};
|
||||
|
||||
// 组合图表组件(折线图 + 成交量柱状图)
|
||||
const CombinedChart = ({ series, title, color = "#FFD700", basePrice = null }) => {
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const cursorRef = useRef(0);
|
||||
|
||||
// 直接将光标设置到最后一个数据点,不再使用动画
|
||||
useEffect(() => {
|
||||
if (!series || series.length === 0) return;
|
||||
// 直接设置到最后一个点
|
||||
const lastIndex = series.length - 1;
|
||||
cursorRef.current = lastIndex;
|
||||
setCursorIndex(lastIndex);
|
||||
}, [series && series.length]);
|
||||
|
||||
|
||||
const yDomain = useMemo(() => {
|
||||
if (!series || series.length === 0) return ['auto', 'auto'];
|
||||
const values = series
|
||||
.map((d) => d?.value)
|
||||
.filter((v) => typeof v === 'number' && isFinite(v));
|
||||
if (values.length === 0) return ['auto', 'auto'];
|
||||
const minVal = Math.min(...values);
|
||||
const maxVal = Math.max(...values);
|
||||
const maxAbs = Math.max(Math.abs(minVal), Math.abs(maxVal));
|
||||
const padding = Math.max(maxAbs * 0.1, 0.2);
|
||||
return [-maxAbs - padding, maxAbs + padding];
|
||||
}, [series]);
|
||||
|
||||
// 当前高亮点
|
||||
const activePoint = useMemo(() => {
|
||||
if (!series || series.length === 0) return null;
|
||||
if (cursorIndex < 0 || cursorIndex >= series.length) return null;
|
||||
return series[cursorIndex];
|
||||
}, [series, cursorIndex]);
|
||||
|
||||
// 稳定的X轴ticks,避免随渲染跳动而闪烁
|
||||
const xTicks = useMemo(() => {
|
||||
if (!series || series.length === 0) return [];
|
||||
const desiredLabels = ['09:30', '10:30', '11:30', '14:00', '15:00'];
|
||||
const set = new Set(series.map(d => d?.time));
|
||||
let ticks = desiredLabels.filter(t => set.has(t));
|
||||
if (ticks.length === 0) {
|
||||
// 回退到首/中/尾的稳定采样,避免空白
|
||||
const len = series.length;
|
||||
const idxs = [0, Math.round(len * 0.25), Math.round(len * 0.5), Math.round(len * 0.75), len - 1];
|
||||
ticks = idxs.map(i => series[i]?.time).filter(Boolean);
|
||||
}
|
||||
return ticks;
|
||||
}, [series && series.length]);
|
||||
|
||||
return (
|
||||
<Box h="full" position="relative">
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={color}
|
||||
fontFamily="monospace"
|
||||
mb={1}
|
||||
px={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<ComposedChart data={series} margin={{ top: 10, right: 10, left: 0, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8}/>
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.2}/>
|
||||
</linearGradient>
|
||||
<linearGradient id={`barGradient-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3}/>
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.05}/>
|
||||
</linearGradient>
|
||||
<linearGradient id={`barGradientActive-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8}/>
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.3}/>
|
||||
</linearGradient>
|
||||
{/* 发光效果 */}
|
||||
<filter id={`glow-${title.replace(/[.\s]/g, '')}`}>
|
||||
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255, 215, 0, 0.1)" />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={color}
|
||||
tick={{ fill: color, fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: `${color}33` }}
|
||||
ticks={xTicks}
|
||||
interval={0}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
|
||||
{/* 左Y轴 - 价格 */}
|
||||
<YAxis
|
||||
yAxisId="price"
|
||||
stroke={color}
|
||||
domain={yDomain}
|
||||
tickFormatter={(v) => `${v.toFixed(2)}%`}
|
||||
orientation="left"
|
||||
/>
|
||||
|
||||
{/* 右Y轴 - 成交量(隐藏) */}
|
||||
<YAxis
|
||||
yAxisId="volume"
|
||||
orientation="right"
|
||||
hide
|
||||
domain={[0, 'dataMax + 1000']}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0,0,0,0.9)',
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
labelFormatter={(label) => `时间: ${label}`}
|
||||
formatter={(value, name) => {
|
||||
if (name === 'value') {
|
||||
const pct = Number(value);
|
||||
if (typeof basePrice === 'number' && isFinite(basePrice)) {
|
||||
const price = basePrice * (1 + pct / 100);
|
||||
return [price.toFixed(2), '价格'];
|
||||
}
|
||||
return [`${pct.toFixed(2)}%`, '涨跌幅'];
|
||||
}
|
||||
if (name === 'volume') return [`${(Number(value) / 100000000).toFixed(2)}亿`, '成交量'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 零轴参考线 */}
|
||||
<ReferenceLine yAxisId="price" y={0} stroke="#666" strokeDasharray="4 4" />
|
||||
|
||||
{/* 成交量柱状图 */}
|
||||
<Bar
|
||||
yAxisId="volume"
|
||||
dataKey="volume"
|
||||
fill={`url(#barGradient-${title.replace(/[.\s]/g, '')})`}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
barSize={20}
|
||||
>
|
||||
{series.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={index <= cursorIndex ? `url(#barGradientActive-${title.replace(/[.\s]/g, '')})` : `url(#barGradient-${title.replace(/[.\s]/g, '')})`}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
|
||||
{/* 价格折线 */}
|
||||
<Line
|
||||
yAxisId="price"
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
|
||||
{/* 移动的亮点 - 使用 ReferenceDot 贴合主数据坐标系 */}
|
||||
{activePoint && (
|
||||
<ReferenceDot
|
||||
xAxisId={0}
|
||||
yAxisId="price"
|
||||
x={activePoint.time}
|
||||
y={activePoint.value}
|
||||
r={6}
|
||||
isFront
|
||||
ifOverflow="hidden"
|
||||
shape={(props) => (
|
||||
<g>
|
||||
<circle
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
r={8}
|
||||
fill={color}
|
||||
stroke="#fff"
|
||||
strokeWidth={2}
|
||||
filter={`url(#glow-${title.replace(/[.\s]/g, '')})`}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 数据流动线条组件
|
||||
function DataStreams() {
|
||||
const lines = useMemo(() => {
|
||||
return [...Array(15)].map((_, i) => ({
|
||||
id: i,
|
||||
startX: Math.random() * 100,
|
||||
delay: Math.random() * 5,
|
||||
duration: 3 + Math.random() * 2,
|
||||
height: 30 + Math.random() * 70
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box position="absolute" inset={0} overflow="hidden" pointerEvents="none">
|
||||
{lines.map((line) => (
|
||||
<motion.div
|
||||
key={line.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
background: 'linear-gradient(to bottom, transparent, rgba(255, 215, 0, 0.3), transparent)',
|
||||
left: `${line.startX}%`,
|
||||
height: `${line.height}%`,
|
||||
}}
|
||||
initial={{ y: '-100%', opacity: 0 }}
|
||||
animate={{
|
||||
y: '200%',
|
||||
opacity: [0, 0.5, 0.5, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: line.duration,
|
||||
delay: line.delay,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 主组件
|
||||
export default function MidjourneyHeroSection() {
|
||||
const [sse, setSse] = useState({
|
||||
sh: { data: [], base: null },
|
||||
sz: { data: [], base: null },
|
||||
cyb: { data: [], base: null }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [shTL, szTL, cybTL, shDaily, szDaily, cybDaily] = await Promise.all([
|
||||
// 指数不传 event_time,后端自动返回"最新可用"交易日
|
||||
indexService.getKlineData('000001.SH', 'timeline'),
|
||||
indexService.getKlineData('399001.SZ', 'timeline'),
|
||||
indexService.getKlineData('399006.SZ', 'timeline'), // 创业板指
|
||||
indexService.getKlineData('000001.SH', 'daily'),
|
||||
indexService.getKlineData('399001.SZ', 'daily'),
|
||||
indexService.getKlineData('399006.SZ', 'daily'),
|
||||
]);
|
||||
|
||||
const shPrevClose = getPrevClose(shDaily);
|
||||
const szPrevClose = getPrevClose(szDaily);
|
||||
const cybPrevClose = getPrevClose(cybDaily);
|
||||
|
||||
const shSeries = toLineSeries(shTL);
|
||||
const szSeries = toLineSeries(szTL);
|
||||
const cybSeries = toLineSeries(cybTL);
|
||||
|
||||
const baseSh = (typeof shPrevClose === 'number' && isFinite(shPrevClose))
|
||||
? shPrevClose
|
||||
: (shSeries.length ? shSeries[0].value : 1);
|
||||
const baseSz = (typeof szPrevClose === 'number' && isFinite(szPrevClose))
|
||||
? szPrevClose
|
||||
: (szSeries.length ? szSeries[0].value : 1);
|
||||
const baseCyb = (typeof cybPrevClose === 'number' && isFinite(cybPrevClose))
|
||||
? cybPrevClose
|
||||
: (cybSeries.length ? cybSeries[0].value : 1);
|
||||
|
||||
const shPct = shSeries.map(p => ({
|
||||
time: p.time,
|
||||
value: ((p.value / baseSh) - 1) * 100,
|
||||
volume: p.volume || 0
|
||||
}));
|
||||
const szPct = szSeries.map(p => ({
|
||||
time: p.time,
|
||||
value: ((p.value / baseSz) - 1) * 100,
|
||||
volume: p.volume || 0
|
||||
}));
|
||||
const cybPct = cybSeries.map(p => ({
|
||||
time: p.time,
|
||||
value: ((p.value / baseCyb) - 1) * 100,
|
||||
volume: p.volume || 0
|
||||
}));
|
||||
|
||||
setSse({
|
||||
sh: { data: shPct, base: baseSh },
|
||||
sz: { data: szPct, base: baseSz },
|
||||
cyb: { data: cybPct, base: baseCyb }
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const particlesInit = async (engine) => {
|
||||
await loadSlim(engine);
|
||||
};
|
||||
|
||||
const particlesOptions = {
|
||||
particles: {
|
||||
number: {
|
||||
value: 80,
|
||||
density: {
|
||||
enable: true,
|
||||
value_area: 800
|
||||
}
|
||||
},
|
||||
color: {
|
||||
value: ["#FFD700", "#FF9800", "#FFC107", "#FFEB3B"]
|
||||
},
|
||||
shape: {
|
||||
type: "circle"
|
||||
},
|
||||
opacity: {
|
||||
value: 0.3,
|
||||
random: true,
|
||||
anim: {
|
||||
enable: true,
|
||||
speed: 1,
|
||||
opacity_min: 0.1,
|
||||
sync: false
|
||||
}
|
||||
},
|
||||
size: {
|
||||
value: 2,
|
||||
random: true,
|
||||
anim: {
|
||||
enable: true,
|
||||
speed: 2,
|
||||
size_min: 0.1,
|
||||
sync: false
|
||||
}
|
||||
},
|
||||
line_linked: {
|
||||
enable: true,
|
||||
distance: 150,
|
||||
color: "#FFD700",
|
||||
opacity: 0.2,
|
||||
width: 1
|
||||
},
|
||||
move: {
|
||||
enable: true,
|
||||
speed: 0.5,
|
||||
direction: "none",
|
||||
random: false,
|
||||
straight: false,
|
||||
out_mode: "out",
|
||||
bounce: false,
|
||||
}
|
||||
},
|
||||
interactivity: {
|
||||
detect_on: "canvas",
|
||||
events: {
|
||||
onhover: {
|
||||
enable: true,
|
||||
mode: "grab"
|
||||
},
|
||||
onclick: {
|
||||
enable: true,
|
||||
mode: "push"
|
||||
},
|
||||
resize: true
|
||||
},
|
||||
modes: {
|
||||
grab: {
|
||||
distance: 140,
|
||||
line_linked: {
|
||||
opacity: 0.5
|
||||
}
|
||||
},
|
||||
push: {
|
||||
particles_nb: 4
|
||||
}
|
||||
}
|
||||
},
|
||||
retina_detect: true
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
minH="100vh"
|
||||
bg="linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #000000 100%)"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 粒子背景 */}
|
||||
<Box position="absolute" inset={0} zIndex={0}>
|
||||
<Particles
|
||||
id="tsparticles"
|
||||
init={particlesInit}
|
||||
options={particlesOptions}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 数据流动效果 */}
|
||||
<DataStreams />
|
||||
|
||||
{/* 内容容器 */}
|
||||
<Container maxW="7xl" position="relative" zIndex={20} pt={20} pb={20}>
|
||||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }} gap={12} alignItems="center" minH="70vh">
|
||||
|
||||
{/* 左侧文本内容 */}
|
||||
<GridItem>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<VStack align="start" spacing={6}>
|
||||
{/* 标签 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Badge
|
||||
colorScheme="yellow"
|
||||
variant="subtle"
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontFamily="monospace"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
>
|
||||
<Box
|
||||
as="span"
|
||||
w={2}
|
||||
h={2}
|
||||
bg="yellow.400"
|
||||
borderRadius="full"
|
||||
mr={2}
|
||||
animation="pulse 2s ease-in-out infinite"
|
||||
/>
|
||||
AI-Assisted Curation
|
||||
</Badge>
|
||||
</motion.div>
|
||||
|
||||
{/* 主标题 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Heading
|
||||
fontSize={{ base: '4xl', md: '5xl', lg: '6xl' }}
|
||||
fontWeight="bold"
|
||||
lineHeight="shorter"
|
||||
>
|
||||
<Text
|
||||
as="span"
|
||||
bgGradient="linear(to-r, yellow.400, orange.400, yellow.500)"
|
||||
bgClip="text"
|
||||
>
|
||||
ME-Agent
|
||||
</Text>
|
||||
<br />
|
||||
<Text as="span" color="white">
|
||||
实时分析系统
|
||||
</Text>
|
||||
</Heading>
|
||||
</motion.div>
|
||||
|
||||
{/* 副标题 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Heading
|
||||
as="h3"
|
||||
fontSize="xl"
|
||||
color="gray.300"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
基于微调版{' '}
|
||||
<Text as="span" color="yellow.400" fontFamily="monospace">
|
||||
deepseek-r1
|
||||
</Text>{' '}
|
||||
进行深度研究
|
||||
</Heading>
|
||||
</motion.div>
|
||||
|
||||
{/* 描述文本 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Text
|
||||
color="gray.400"
|
||||
fontSize="md"
|
||||
lineHeight="tall"
|
||||
maxW="xl"
|
||||
>
|
||||
ME (Money Edge) 是一款以大模型为底座、由资深分析师参与校准的信息辅助系统,
|
||||
专为金融研究与企业决策等场景设计。系统侧重于多源信息的汇聚、清洗与结构化整理,
|
||||
结合自主训练的领域知识图谱,并配合专家人工复核与整合,帮助用户高效获取相关线索与参考资料。
|
||||
</Text>
|
||||
</motion.div>
|
||||
|
||||
{/* 特性标签 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
{['海量信息整理', '领域知识图谱', '分析师复核', '结构化呈现'].map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
colorScheme="gray"
|
||||
borderRadius="lg"
|
||||
px={3}
|
||||
py={1}
|
||||
bg="gray.800"
|
||||
color="gray.300"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.600"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</motion.div>
|
||||
|
||||
{/* 按钮组 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<HStack spacing={4} pt={4}>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
color="gray.300"
|
||||
borderColor="gray.600"
|
||||
borderRadius="full"
|
||||
px={8}
|
||||
_hover={{
|
||||
bg: "gray.800",
|
||||
borderColor: "gray.500",
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
了解更多
|
||||
</Button>
|
||||
</HStack>
|
||||
</motion.div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Grid
|
||||
templateColumns="repeat(3, 1fr)"
|
||||
gap={6}
|
||||
pt={8}
|
||||
borderTop="1px"
|
||||
borderTopColor="gray.800"
|
||||
w="full"
|
||||
>
|
||||
{[
|
||||
{ label: '数据源', value: '10K+' },
|
||||
{ label: '日处理', value: '1M+' },
|
||||
{ label: '准确率', value: '98%' }
|
||||
].map((stat) => (
|
||||
<Stat key={stat.label}>
|
||||
<StatNumber
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color="yellow.400"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
{stat.value}
|
||||
</StatNumber>
|
||||
<StatLabel fontSize="sm" color="gray.500">
|
||||
{stat.label}
|
||||
</StatLabel>
|
||||
</Stat>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
</VStack>
|
||||
</motion.div>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧金融图表可视化 */}
|
||||
<GridItem>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1, delay: 0.5 }}
|
||||
>
|
||||
<Box position="relative" h={{ base: '400px', md: '500px', lg: '600px' }}>
|
||||
{/* 图表网格布局 */}
|
||||
<Grid
|
||||
templateColumns="repeat(2, 1fr)"
|
||||
templateRows="repeat(2, 1fr)"
|
||||
gap={4}
|
||||
h="full"
|
||||
p={4}
|
||||
bg="rgba(0, 0, 0, 0.3)"
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 215, 0, 0.2)"
|
||||
backdropFilter="blur(10px)"
|
||||
>
|
||||
{/* 上证指数 */}
|
||||
<GridItem colSpan={2}>
|
||||
<Box
|
||||
h="full"
|
||||
bg="rgba(0, 0, 0, 0.4)"
|
||||
borderRadius="lg"
|
||||
p={2}
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 215, 0, 0.1)"
|
||||
>
|
||||
<CombinedChart
|
||||
series={sse.sh?.data || []}
|
||||
basePrice={sse.sh?.base}
|
||||
title="000001.SH 上证指数"
|
||||
color="#FFD700"
|
||||
/>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
{/* 深证成指 */}
|
||||
<GridItem>
|
||||
<Box
|
||||
h="full"
|
||||
bg="rgba(0, 0, 0, 0.4)"
|
||||
borderRadius="lg"
|
||||
p={2}
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 215, 0, 0.1)"
|
||||
>
|
||||
<CombinedChart
|
||||
series={sse.sz?.data || []}
|
||||
basePrice={sse.sz?.base}
|
||||
title="399001.SZ 深证成指"
|
||||
color="#00E0FF"
|
||||
/>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
{/* 创业板指 */}
|
||||
<GridItem>
|
||||
<Box
|
||||
h="full"
|
||||
bg="rgba(0, 0, 0, 0.4)"
|
||||
borderRadius="lg"
|
||||
p={2}
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 215, 0, 0.1)"
|
||||
>
|
||||
<CombinedChart
|
||||
series={sse.cyb?.data || []}
|
||||
basePrice={sse.cyb?.base}
|
||||
title="399006.SZ 创业板指"
|
||||
color="#FF69B4"
|
||||
/>
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
{/* 装饰性光效 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
w="150%"
|
||||
h="150%"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="20%"
|
||||
left="20%"
|
||||
w="200px"
|
||||
h="200px"
|
||||
bg="radial-gradient(circle, rgba(255, 215, 0, 0.15), transparent)"
|
||||
borderRadius="full"
|
||||
filter="blur(40px)"
|
||||
animation="pulse 4s ease-in-out infinite"
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="20%"
|
||||
right="20%"
|
||||
w="150px"
|
||||
h="150px"
|
||||
bg="radial-gradient(circle, rgba(255, 152, 0, 0.15), transparent)"
|
||||
borderRadius="full"
|
||||
filter="blur(40px)"
|
||||
animation="pulse 4s ease-in-out infinite"
|
||||
animationDelay="2s"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
{/* 底部渐变遮罩 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
h="128px"
|
||||
bgGradient="linear(to-t, black, transparent)"
|
||||
zIndex={10}
|
||||
/>
|
||||
|
||||
{/* 全局样式 */}
|
||||
<style jsx global>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(1.1); }
|
||||
}
|
||||
`}</style>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
186
src/views/Community/components/PopularKeywords.js
Normal file
186
src/views/Community/components/PopularKeywords.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// src/views/Community/components/PopularKeywords.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Tag, Space, Spin, Empty, Button } from 'antd';
|
||||
import { FireOutlined, RightOutlined } from '@ant-design/icons';
|
||||
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? '/concept-api'
|
||||
: 'http://192.168.1.58:6801';
|
||||
|
||||
// 获取域名前缀
|
||||
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
|
||||
? ''
|
||||
: 'https://valuefrontier.cn';
|
||||
|
||||
const PopularKeywords = ({ onKeywordClick }) => {
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 加载热门概念(涨幅前20)
|
||||
const loadPopularConcepts = async () => {
|
||||
setLoading(true);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load popular concepts:', error);
|
||||
setKeywords([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadPopularConcepts();
|
||||
}, []);
|
||||
|
||||
// 根据涨跌幅获取标签颜色
|
||||
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) => {
|
||||
// 如果原有的 onKeywordClick 存在,可以选择是否还要调用
|
||||
// onKeywordClick && onKeywordClick(concept.keyword);
|
||||
|
||||
// 跳转到对应概念的页面
|
||||
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// 查看更多概念
|
||||
const handleViewMore = () => {
|
||||
const url = `${DOMAIN_PREFIX}/concepts`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
|
||||
热门概念
|
||||
</span>
|
||||
}
|
||||
className="popular-keywords"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
涨幅TOP20
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{keywords && keywords.length > 0 ? (
|
||||
<>
|
||||
<Space size={[8, 8]} wrap style={{ marginBottom: 16 }}>
|
||||
{keywords.map((item) => (
|
||||
<Tag
|
||||
key={item.concept_id}
|
||||
color={getTagColor(item.change_pct)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginBottom: 8,
|
||||
padding: '2px 8px',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
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: 6,
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{formatChangePct(item.change_pct)}
|
||||
</span>
|
||||
<span style={{
|
||||
marginLeft: 4,
|
||||
fontSize: 11,
|
||||
opacity: 0.8
|
||||
}}>
|
||||
({item.count}股)
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{/* 查看更多按钮 */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: 12,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={handleViewMore}
|
||||
style={{
|
||||
color: '#1890ff',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
查看更多概念
|
||||
<RightOutlined style={{ fontSize: 12, marginLeft: 4 }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
description="暂无热门概念"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularKeywords;
|
||||
49
src/views/Community/components/SearchBox.js
Normal file
49
src/views/Community/components/SearchBox.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/views/Community/components/SearchBox.js
|
||||
import React from 'react';
|
||||
import { Card, Input, Radio, Form, Button } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
const SearchBox = ({ onSearch }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
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;
|
||||
235
src/views/Community/components/StockDetailPanel.css
Normal file
235
src/views/Community/components/StockDetailPanel.css
Normal file
@@ -0,0 +1,235 @@
|
||||
/* Drawer root */
|
||||
.stock-detail-panel .ant-drawer-body {
|
||||
padding: 24px 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Card common style */
|
||||
.stock-detail-panel .ant-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stock-detail-panel .ant-card-head-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Stock list items */
|
||||
.stock-item {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-left 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 4px; /* compensate for border shift */
|
||||
}
|
||||
|
||||
.stock-item:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.stock-item.selected {
|
||||
background-color: #e6f7ff;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
.stock-item .ant-list-item-meta-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stock-item .ant-list-item-meta-description {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.stock-item .ant-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ReactECharts */
|
||||
.stock-detail-panel .echarts-for-react {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Card spacing */
|
||||
.stock-detail-panel .ant-card:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Close icon */
|
||||
.stock-detail-panel .anticon-close:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.row-hover {
|
||||
background: #f5faff !important;
|
||||
box-shadow: 0 2px 8px rgba(24,144,255,0.10);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 新增样式 - 相关标的Tab */
|
||||
.stock-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stock-header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stock-search-bar {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stock-search-input {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
transition: all 0.3s;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.stock-search-input:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.monitoring-button {
|
||||
background: linear-gradient(135deg, #4caf50 0%, #45a049 100%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
padding: 4px 12px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.monitoring-button:hover {
|
||||
background: linear-gradient(135deg, #45a049 0%, #3d8b40 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.monitoring-button.monitoring {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||
}
|
||||
|
||||
.monitoring-button.monitoring:hover {
|
||||
background: linear-gradient(135deg, #ee5a52 0%, #d63031 100%);
|
||||
}
|
||||
|
||||
.add-stock-button {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
padding: 4px 12px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-stock-button:hover {
|
||||
background: linear-gradient(135deg, #096dd9 0%, #0050b3 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
transform: rotate(180deg);
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.monitoring-status {
|
||||
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
|
||||
border: 1px solid #4caf50;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.stock-count {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stock-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 表格hover效果增强 */
|
||||
.ant-table-tbody > tr.row-hover > td {
|
||||
background: #f5faff !important;
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr.row-hover:hover > td {
|
||||
background: #e6f7ff !important;
|
||||
}
|
||||
|
||||
/* 搜索图标样式 */
|
||||
.search-icon {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 按钮组样式 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.stock-search-bar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
997
src/views/Community/components/StockDetailPanel.js
Normal file
997
src/views/Community/components/StockDetailPanel.js
Normal file
@@ -0,0 +1,997 @@
|
||||
// src/views/Community/components/StockDetailPanel.js
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Drawer, List, Card, Tag, Spin, Empty, Typography, Row, Col, Statistic, Tabs, Descriptions, Badge, message, Table, Modal, Button, Input, Alert } from 'antd';
|
||||
import { CloseOutlined, RiseOutlined, FallOutlined, CloseCircleOutlined, PushpinOutlined, ReloadOutlined, StarOutlined, StarFilled, LockOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import './StockDetailPanel.css';
|
||||
import { Tabs as AntdTabs } from 'antd';
|
||||
import ReactDOM from 'react-dom';
|
||||
import RelatedConcepts from '../../EventDetail/components/RelatedConcepts';
|
||||
import HistoricalEvents from '../../EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '../../EventDetail/components/TransmissionChainAnalysis';
|
||||
import EventDiscussionModal from './EventDiscussionModal';
|
||||
import { useSubscription } from '../../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
|
||||
import moment from 'moment';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
// ================= 全局缓存和请求管理 =================
|
||||
const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data
|
||||
const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise
|
||||
const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp
|
||||
|
||||
// 请求间隔限制(毫秒)
|
||||
const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据
|
||||
|
||||
// 获取缓存key
|
||||
const getCacheKey = (stockCode, eventTime) => {
|
||||
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
|
||||
return `${stockCode}|${date}`;
|
||||
};
|
||||
|
||||
// 检查是否需要刷新数据
|
||||
const shouldRefreshData = (cacheKey) => {
|
||||
const lastTime = lastRequestTime.get(cacheKey);
|
||||
if (!lastTime) return true;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastTime;
|
||||
|
||||
// 如果是今天的数据且交易时间内,允许更频繁的更新
|
||||
const today = moment().format('YYYY-MM-DD');
|
||||
const isToday = cacheKey.includes(today);
|
||||
const currentHour = new Date().getHours();
|
||||
const isTradingHours = currentHour >= 9 && currentHour < 16;
|
||||
|
||||
if (isToday && isTradingHours) {
|
||||
return elapsed > REQUEST_INTERVAL;
|
||||
}
|
||||
|
||||
// 历史数据不需要频繁更新
|
||||
return elapsed > 3600000; // 1小时
|
||||
};
|
||||
|
||||
// 获取K线数据(带缓存和防重复请求)
|
||||
const fetchKlineData = async (stockCode, eventTime) => {
|
||||
const cacheKey = getCacheKey(stockCode, eventTime);
|
||||
|
||||
// 1. 检查缓存
|
||||
if (klineDataCache.has(cacheKey)) {
|
||||
// 检查是否需要刷新
|
||||
if (!shouldRefreshData(cacheKey)) {
|
||||
console.log(`使用缓存数据: ${cacheKey}`);
|
||||
return klineDataCache.get(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否有正在进行的请求
|
||||
if (pendingRequests.has(cacheKey)) {
|
||||
console.log(`等待进行中的请求: ${cacheKey}`);
|
||||
return pendingRequests.get(cacheKey);
|
||||
}
|
||||
|
||||
// 3. 发起新请求
|
||||
console.log(`发起新请求: ${cacheKey}`);
|
||||
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const requestPromise = stockService
|
||||
.getKlineData(stockCode, 'minute', normalizedEventTime)
|
||||
.then((res) => {
|
||||
const data = Array.isArray(res?.data) ? res.data : [];
|
||||
// 更新缓存
|
||||
klineDataCache.set(cacheKey, data);
|
||||
lastRequestTime.set(cacheKey, Date.now());
|
||||
// 清除pending状态
|
||||
pendingRequests.delete(cacheKey);
|
||||
console.log(`请求完成并缓存: ${cacheKey}`);
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`获取${stockCode}的K线数据失败:`, error);
|
||||
// 清除pending状态
|
||||
pendingRequests.delete(cacheKey);
|
||||
// 如果有旧缓存,返回旧数据
|
||||
if (klineDataCache.has(cacheKey)) {
|
||||
return klineDataCache.get(cacheKey);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 保存pending请求
|
||||
pendingRequests.set(cacheKey, requestPromise);
|
||||
|
||||
return requestPromise;
|
||||
};
|
||||
|
||||
// ================= 优化后的迷你分时图组件 =================
|
||||
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime }) {
|
||||
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 ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData([]);
|
||||
loadedRef.current = false;
|
||||
dataFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经请求过数据,不再重复请求
|
||||
if (dataFetchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
// 如果有缓存数据,直接使用
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
setData(cachedData);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记正在请求
|
||||
dataFetchedRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// 使用全局的fetchKlineData函数
|
||||
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;
|
||||
}
|
||||
});
|
||||
}, [stockCode, stableEventTime]); // 注意这里使用 stableEventTime
|
||||
|
||||
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 = moment(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: 140, height: 40 }}>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数,只有当stockCode或eventTime变化时才重新渲染
|
||||
return prevProps.stockCode === nextProps.stockCode &&
|
||||
prevProps.eventTime === nextProps.eventTime;
|
||||
});
|
||||
|
||||
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
|
||||
|
||||
// 使用统一的股票详情组件
|
||||
const StockDetailModal = ({ stock, onClose, fixed, eventTime }) => {
|
||||
return (
|
||||
<StockChartAntdModal
|
||||
open={true}
|
||||
onCancel={onClose}
|
||||
stock={stock}
|
||||
eventTime={eventTime}
|
||||
fixed={fixed}
|
||||
width={800}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function StockDetailPanel({ visible, event, onClose }) {
|
||||
console.log('StockDetailPanel 组件已加载,visible:', visible, 'event:', event?.id);
|
||||
|
||||
// 权限控制
|
||||
const { hasFeatureAccess, getRequiredLevel, getUpgradeRecommendation } = useSubscription();
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
const [upgradeFeature, setUpgradeFeature] = useState('');
|
||||
|
||||
// 1. hooks
|
||||
const [activeTab, setActiveTab] = useState('stocks');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [relatedStocks, setRelatedStocks] = useState([]);
|
||||
const [stockQuotes, setStockQuotes] = useState({});
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [eventDetail, setEventDetail] = useState(null);
|
||||
const [historicalEvents, setHistoricalEvents] = useState([]);
|
||||
const [chainAnalysis, setChainAnalysis] = useState(null);
|
||||
const [posts, setPosts] = useState([]);
|
||||
// 移除悬浮相关的state
|
||||
// const [hoveredStock, setHoveredStock] = useState(null);
|
||||
const [fixedCharts, setFixedCharts] = useState([]); // [{stock, chartType}]
|
||||
// const [hoveredRowIndex, setHoveredRowIndex] = useState(null);
|
||||
// const [tableRect, setTableRect] = useState(null);
|
||||
const tableRef = React.useRef();
|
||||
|
||||
// 讨论模态框相关状态
|
||||
const [discussionModalVisible, setDiscussionModalVisible] = useState(false);
|
||||
const [discussionType, setDiscussionType] = useState('事件讨论');
|
||||
// 移除滚动相关的ref
|
||||
// const isScrollingRef = React.useRef(false);
|
||||
// const scrollStopTimerRef = React.useRef(null);
|
||||
// const hoverTimerRef = React.useRef(null);
|
||||
// const [hoverTab, setHoverTab] = useState('stock');
|
||||
const [searchText, setSearchText] = useState(''); // 搜索文本
|
||||
const [isMonitoring, setIsMonitoring] = useState(false); // 实时监控状态
|
||||
const [filteredStocks, setFilteredStocks] = useState([]); // 过滤后的股票列表
|
||||
const [expectationScore, setExpectationScore] = useState(null); // 超预期得分
|
||||
const monitoringIntervalRef = useRef(null); // 监控定时器引用
|
||||
const [watchlistStocks, setWatchlistStocks] = useState(new Set()); // 自选股列表
|
||||
|
||||
// 清理函数
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 组件卸载时清理定时器
|
||||
if (monitoringIntervalRef.current) {
|
||||
clearInterval(monitoringIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 过滤股票列表
|
||||
useEffect(() => {
|
||||
if (!searchText.trim()) {
|
||||
setFilteredStocks(relatedStocks);
|
||||
} else {
|
||||
const filtered = relatedStocks.filter(stock =>
|
||||
stock.stock_code.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
stock.stock_name.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
setFilteredStocks(filtered);
|
||||
}
|
||||
}, [searchText, relatedStocks]);
|
||||
|
||||
// 实时监控定时器 - 优化版本
|
||||
useEffect(() => {
|
||||
// 清理旧的定时器
|
||||
if (monitoringIntervalRef.current) {
|
||||
clearInterval(monitoringIntervalRef.current);
|
||||
monitoringIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (isMonitoring && relatedStocks.length > 0) {
|
||||
// 立即执行一次
|
||||
const updateQuotes = () => {
|
||||
const codes = relatedStocks.map(s => s.stock_code);
|
||||
stockService.getQuotes(codes, event?.created_at)
|
||||
.then(quotes => setStockQuotes(quotes))
|
||||
.catch(error => console.error('更新行情失败:', error));
|
||||
};
|
||||
|
||||
updateQuotes();
|
||||
|
||||
// 设置定时器
|
||||
monitoringIntervalRef.current = setInterval(updateQuotes, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (monitoringIntervalRef.current) {
|
||||
clearInterval(monitoringIntervalRef.current);
|
||||
monitoringIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isMonitoring, relatedStocks, event]);
|
||||
|
||||
// 加载用户自选股列表
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
try {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const apiBase = isProduction ? '' : process.env.REACT_APP_API_URL || '';
|
||||
const response = await fetch(`${apiBase}/api/account/watchlist`, {
|
||||
credentials: 'include' // 确保发送cookies
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
const watchlistSet = new Set(data.data.map(item => item.stock_code));
|
||||
setWatchlistStocks(watchlistSet);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载自选股列表失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加入/移除自选股
|
||||
const handleWatchlistToggle = async (stockCode, isInWatchlist) => {
|
||||
try {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const apiBase = isProduction ? '' : process.env.REACT_APP_API_URL || '';
|
||||
|
||||
let response;
|
||||
if (isInWatchlist) {
|
||||
// 移除自选股
|
||||
response = await fetch(`${apiBase}/api/account/watchlist/${stockCode}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include' // 确保发送cookies
|
||||
});
|
||||
} else {
|
||||
// 添加自选股
|
||||
const stockInfo = relatedStocks.find(s => s.stock_code === stockCode);
|
||||
response = await fetch(`${apiBase}/api/account/watchlist`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // 确保发送cookies
|
||||
body: JSON.stringify({
|
||||
stock_code: stockCode,
|
||||
stock_name: stockInfo?.stock_name || stockCode
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
message.success(isInWatchlist ? '已从自选股移除' : '已加入自选股');
|
||||
// 更新本地状态
|
||||
setWatchlistStocks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (isInWatchlist) {
|
||||
newSet.delete(stockCode);
|
||||
} else {
|
||||
newSet.add(stockCode);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
message.error(data.error || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化数据加载
|
||||
useEffect(() => {
|
||||
if (visible && event) {
|
||||
setActiveTab('stocks');
|
||||
loadAllData();
|
||||
}
|
||||
}, [visible, event]);
|
||||
|
||||
// 加载所有数据的函数
|
||||
const loadAllData = useCallback(() => {
|
||||
if (!event) return;
|
||||
|
||||
// 加载自选股列表
|
||||
loadWatchlist();
|
||||
|
||||
// 加载相关标的
|
||||
setLoading(true);
|
||||
eventService.getRelatedStocks(event.id)
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
setRelatedStocks(res.data);
|
||||
if (res.data.length > 0) {
|
||||
const codes = res.data.map(s => s.stock_code);
|
||||
stockService.getQuotes(codes, event.created_at)
|
||||
.then(quotes => setStockQuotes(quotes))
|
||||
.catch(error => console.error('加载行情失败:', error));
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// 加载详细信息
|
||||
setDetailLoading(true);
|
||||
eventService.getEventDetail(event.id)
|
||||
.then(res => {
|
||||
if (res.success) setEventDetail(res.data);
|
||||
})
|
||||
.finally(() => setDetailLoading(false));
|
||||
|
||||
// 加载历史事件
|
||||
eventService.getHistoricalEvents(event.id)
|
||||
.then(res => {
|
||||
if (res.success) setHistoricalEvents(res.data);
|
||||
});
|
||||
|
||||
// 加载传导链分析
|
||||
eventService.getTransmissionChainAnalysis(event.id)
|
||||
.then(res => {
|
||||
if (res.success) setChainAnalysis(res.data);
|
||||
});
|
||||
|
||||
// 加载社区讨论
|
||||
if (eventService.getPosts) {
|
||||
eventService.getPosts(event.id)
|
||||
.then(res => {
|
||||
if (res.success) setPosts(res.data);
|
||||
});
|
||||
}
|
||||
|
||||
// 加载超预期得分
|
||||
if (eventService.getExpectationScore) {
|
||||
eventService.getExpectationScore(event.id)
|
||||
.then(res => {
|
||||
if (res.success) setExpectationScore(res.data.score);
|
||||
})
|
||||
.catch(() => setExpectationScore(null));
|
||||
}
|
||||
}, [event, loadWatchlist]);
|
||||
|
||||
// 2. renderCharts函数
|
||||
const renderCharts = useCallback((stock, chartType, onClose, fixed) => {
|
||||
// 保证事件时间格式为 'YYYY-MM-DD HH:mm'
|
||||
const formattedEventTime = event?.start_time ? moment(event.start_time).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
return <StockDetailModal
|
||||
stock={stock}
|
||||
onClose={onClose}
|
||||
fixed={fixed}
|
||||
eventTime={formattedEventTime}
|
||||
/>;
|
||||
}, [event]);
|
||||
|
||||
// 3. 简化handleRowEvents函数 - 只处理点击事件
|
||||
const handleRowEvents = useCallback((record) => ({
|
||||
onClick: () => {
|
||||
// 点击行时显示详情弹窗
|
||||
setFixedCharts((prev) => {
|
||||
if (prev.find(item => item.stock.stock_code === record.stock_code)) return prev;
|
||||
return [...prev, { stock: record, chartType: 'timeline' }];
|
||||
});
|
||||
},
|
||||
style: { cursor: 'pointer' } // 添加手型光标提示可点击
|
||||
}), []);
|
||||
|
||||
// 展开/收缩的行
|
||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
||||
|
||||
// 稳定的事件时间,避免重复渲染
|
||||
const stableEventTime = useMemo(() => {
|
||||
return event?.start_time ? moment(event.start_time).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [event?.start_time]);
|
||||
|
||||
// 切换行展开状态
|
||||
const toggleRowExpand = useCallback((stockCode) => {
|
||||
setExpandedRows(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(stockCode)) {
|
||||
newSet.delete(stockCode);
|
||||
} else {
|
||||
newSet.add(stockCode);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 4. stockColumns数组 - 使用优化后的 MiniTimelineChart
|
||||
const stockColumns = useMemo(() => [
|
||||
{
|
||||
title: '股票代码',
|
||||
dataIndex: 'stock_code',
|
||||
key: 'stock_code',
|
||||
width: 100,
|
||||
render: (code, record) => (
|
||||
<Button type="link">{code}</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '股票名称',
|
||||
dataIndex: 'stock_name',
|
||||
key: 'stock_name',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '关联描述',
|
||||
dataIndex: 'relation_desc',
|
||||
key: 'relation_desc',
|
||||
width: 200,
|
||||
render: (text, record) => {
|
||||
if (!text) return '--';
|
||||
|
||||
const isExpanded = expandedRows.has(record.stock_code);
|
||||
const maxLength = 30; // 收缩时显示的最大字符数
|
||||
const needTruncate = text.length > maxLength;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{
|
||||
whiteSpace: isExpanded ? 'normal' : 'nowrap',
|
||||
overflow: isExpanded ? 'visible' : 'hidden',
|
||||
textOverflow: isExpanded ? 'clip' : 'ellipsis',
|
||||
paddingRight: needTruncate ? '20px' : '0',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: '#666'
|
||||
}}>
|
||||
{isExpanded ? text : (needTruncate ? text.substring(0, maxLength) + '...' : text)}
|
||||
</div>
|
||||
{needTruncate && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 防止触发行点击事件
|
||||
toggleRowExpand(record.stock_code);
|
||||
}}
|
||||
style={{
|
||||
position: isExpanded ? 'static' : 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
padding: '0 4px',
|
||||
fontSize: '12px',
|
||||
marginTop: isExpanded ? '4px' : '0'
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分时图',
|
||||
key: 'timeline',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<MiniTimelineChart
|
||||
stockCode={record.stock_code}
|
||||
eventTime={stableEventTime}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '涨跌幅',
|
||||
key: 'change',
|
||||
width: 100,
|
||||
render: (_, record) => {
|
||||
const quote = stockQuotes[record.stock_code];
|
||||
if (!quote) return '--';
|
||||
const color = quote.change > 0 ? 'red' : quote.change < 0 ? 'green' : 'inherit';
|
||||
return <span style={{ color }}>{quote.change > 0 ? '+' : ''}{quote.change?.toFixed(2)}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const isInWatchlist = watchlistStocks.has(record.stock_code);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const stockCode = record.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
}}
|
||||
>
|
||||
股票详情
|
||||
</Button>
|
||||
<Button
|
||||
type={isInWatchlist ? 'default' : 'primary'}
|
||||
size="small"
|
||||
icon={isInWatchlist ? <StarFilled /> : <StarOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchlistToggle(record.stock_code, isInWatchlist);
|
||||
}}
|
||||
style={{ minWidth: '70px' }}
|
||||
>
|
||||
{isInWatchlist ? '已关注' : '加自选'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [stockQuotes, stableEventTime, expandedRows, toggleRowExpand, watchlistStocks, handleWatchlistToggle, relatedStocks]); // 注意这里依赖改为 stableEventTime
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = (value) => {
|
||||
setSearchText(value);
|
||||
};
|
||||
|
||||
// 处理实时监控切换
|
||||
const handleMonitoringToggle = () => {
|
||||
setIsMonitoring(!isMonitoring);
|
||||
if (!isMonitoring) {
|
||||
message.info('已开启实时监控,每5秒自动更新');
|
||||
} else {
|
||||
message.info('已停止实时监控');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理刷新 - 只清理当天数据的缓存
|
||||
const handleRefresh = useCallback(() => {
|
||||
// 手动刷新分时图缓存
|
||||
const today = moment().format('YYYY-MM-DD');
|
||||
relatedStocks.forEach(stock => {
|
||||
const cacheKey = getCacheKey(stock.stock_code, stableEventTime);
|
||||
// 如果是今天的数据,强制刷新
|
||||
if (cacheKey.includes(today)) {
|
||||
lastRequestTime.delete(cacheKey);
|
||||
klineDataCache.delete(cacheKey); // 清除缓存数据
|
||||
}
|
||||
});
|
||||
|
||||
// 重新加载数据
|
||||
loadAllData();
|
||||
}, [relatedStocks, stableEventTime, loadAllData]);
|
||||
|
||||
|
||||
|
||||
// 固定图表关闭
|
||||
const handleUnfixChart = useCallback((stock) => {
|
||||
setFixedCharts((prev) => prev.filter(item => item.stock.stock_code !== stock.stock_code));
|
||||
}, []);
|
||||
|
||||
// 权限检查函数
|
||||
const handleTabAccess = (featureName, tabKey) => {
|
||||
if (!hasFeatureAccess(featureName)) {
|
||||
const recommendation = getUpgradeRecommendation(featureName);
|
||||
setUpgradeFeature(recommendation?.required || 'pro');
|
||||
setUpgradeModalOpen(true);
|
||||
return false;
|
||||
}
|
||||
setActiveTab(tabKey);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 渲染锁定内容
|
||||
const renderLockedContent = (featureName, description) => {
|
||||
const recommendation = getUpgradeRecommendation(featureName);
|
||||
const isProRequired = recommendation?.required === 'pro';
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
|
||||
{isProRequired ? <LockOutlined /> : <CrownOutlined />}
|
||||
</div>
|
||||
<Alert
|
||||
message={`${description}功能已锁定`}
|
||||
description={recommendation?.message || `此功能需要${isProRequired ? 'Pro版' : 'Max版'}订阅`}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setUpgradeFeature(recommendation?.required || 'pro');
|
||||
setUpgradeModalOpen(true);
|
||||
}}
|
||||
>
|
||||
升级到 {isProRequired ? 'Pro版' : 'Max版'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 5. tabItems数组
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'stocks',
|
||||
label: (
|
||||
<span>
|
||||
相关标的
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: hasFeatureAccess('related_stocks') ? (
|
||||
<Spin spinning={loading}>
|
||||
{/* 头部信息 */}
|
||||
<div className="stock-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div className="stock-header-icon">
|
||||
<span>📊</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="stock-title">
|
||||
相关标的
|
||||
</div>
|
||||
<div className="stock-count">
|
||||
共 {filteredStocks.length} 只股票
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
|
||||
<Button
|
||||
className={`monitoring-button ${isMonitoring ? 'monitoring' : ''}`}
|
||||
onClick={handleMonitoringToggle}
|
||||
>
|
||||
{isMonitoring ? '停止监控' : '实时监控'}
|
||||
</Button>
|
||||
<div style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||
每5秒自动更新行情数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和操作栏 */}
|
||||
<div className="stock-search-bar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
|
||||
<span className="search-icon">🔍</span>
|
||||
<Input
|
||||
placeholder="搜索股票代码或名称..."
|
||||
value={searchText}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="stock-search-input"
|
||||
style={{ flex: 1, maxWidth: '300px' }}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
className="refresh-button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 股票列表 */}
|
||||
<div ref={tableRef} style={{ position: 'relative' }}>
|
||||
<Table
|
||||
columns={stockColumns}
|
||||
dataSource={filteredStocks}
|
||||
rowKey="stock_code"
|
||||
onRow={handleRowEvents}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
scroll={{ x: 920 }} // 设置横向滚动,因为操作列变宽了
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 固定图表 */}
|
||||
{fixedCharts.map(({ stock }, index) =>
|
||||
<div key={`fixed-chart-${stock.stock_code}-${index}`}>
|
||||
{renderCharts(stock, 'timeline', () => handleUnfixChart(stock), true)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 讨论按钮 */}
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Button.Group />}
|
||||
onClick={() => {
|
||||
setDiscussionType('事件讨论');
|
||||
setDiscussionModalVisible(true);
|
||||
}}
|
||||
>
|
||||
查看事件讨论
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
) : renderLockedContent('related_stocks', '相关标的')
|
||||
},
|
||||
{
|
||||
key: 'concepts',
|
||||
label: (
|
||||
<span>
|
||||
相关概念
|
||||
{!hasFeatureAccess('related_concepts') && (
|
||||
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: hasFeatureAccess('related_concepts') ? (
|
||||
<Spin spinning={detailLoading}>
|
||||
<RelatedConcepts
|
||||
eventTitle={event?.title}
|
||||
eventTime={event?.created_at || event?.start_time}
|
||||
eventId={event?.id}
|
||||
loading={detailLoading}
|
||||
error={null}
|
||||
/>
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setDiscussionType('事件讨论');
|
||||
setDiscussionModalVisible(true);
|
||||
}}
|
||||
>
|
||||
查看事件讨论
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
) : renderLockedContent('related_concepts', '相关概念')
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: (
|
||||
<span>
|
||||
历史事件对比
|
||||
{!hasFeatureAccess('historical_events_full') && (
|
||||
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Spin spinning={detailLoading}>
|
||||
<HistoricalEvents
|
||||
events={historicalEvents}
|
||||
loading={detailLoading}
|
||||
error={null}
|
||||
expectationScore={expectationScore}
|
||||
/>
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setDiscussionType('事件讨论');
|
||||
setDiscussionModalVisible(true);
|
||||
}}
|
||||
>
|
||||
查看事件讨论
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'chain',
|
||||
label: (
|
||||
<span>
|
||||
传导链分析
|
||||
{!hasFeatureAccess('transmission_chain') && (
|
||||
<CrownOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: hasFeatureAccess('transmission_chain') ? (
|
||||
<TransmissionChainAnalysis eventId={event?.id} eventService={eventService} />
|
||||
) : renderLockedContent('transmission_chain', '传导链分析')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{event?.title}</span>
|
||||
<CloseOutlined onClick={onClose} style={{ cursor: 'pointer' }} />
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={900}
|
||||
open={visible}
|
||||
onClose={onClose}
|
||||
closable={false}
|
||||
className="stock-detail-panel"
|
||||
>
|
||||
<AntdTabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
</Drawer>
|
||||
|
||||
{/* 事件讨论模态框 */}
|
||||
<EventDiscussionModal
|
||||
isOpen={discussionModalVisible}
|
||||
onClose={() => setDiscussionModalVisible(false)}
|
||||
eventId={event?.id}
|
||||
eventTitle={event?.title}
|
||||
discussionType={discussionType}
|
||||
/>
|
||||
|
||||
{/* 订阅升级模态框 */}
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModalOpen}
|
||||
onClose={() => setUpgradeModalOpen(false)}
|
||||
requiredLevel={upgradeFeature}
|
||||
featureName={upgradeFeature === 'pro' ? '相关分析功能' : '传导链分析'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default StockDetailPanel;
|
||||
340
src/views/Community/index.css
Normal file
340
src/views/Community/index.css
Normal file
@@ -0,0 +1,340 @@
|
||||
/* src/views/Community/index.css - 增强版 */
|
||||
|
||||
.community-layout {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #f8f9fb 0%, #f0f2f5 100%);
|
||||
}
|
||||
|
||||
.community-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 渐变头部样式 */
|
||||
.page-header.gradient-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 32px 32px;
|
||||
margin: -24px -24px 32px;
|
||||
border-radius: 0 0 24px 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
/* 头部图案装饰 */
|
||||
.header-pattern {
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
right: -60px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
animation: float 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.header-pattern::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 150px;
|
||||
left: -100px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
animation: float 20s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(10deg); }
|
||||
}
|
||||
|
||||
/* 主标题样式 */
|
||||
.main-title {
|
||||
margin: 0;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 42px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 12px 0 16px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 头部徽章 */
|
||||
.header-badges {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.header-badges .ant-badge-status {
|
||||
color: rgba(255,255,255,0.95);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-badges .ant-badge-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* 自动更新按钮 */
|
||||
.auto-update-btn,
|
||||
.auto-update-btn-active {
|
||||
border-radius: 24px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.auto-update-btn {
|
||||
background: rgba(255,255,255,0.2) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.auto-update-btn:hover {
|
||||
background: rgba(255,255,255,0.3) !important;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.auto-update-btn-active {
|
||||
background: white !important;
|
||||
color: #667eea !important;
|
||||
border-color: white !important;
|
||||
box-shadow: 0 4px 12px rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.auto-update-btn-active:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* 增强的事件卡片 */
|
||||
.events-card.enhanced-card {
|
||||
min-height: 400px;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.events-card .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
padding: 24px 24px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(to bottom, #fafbfc, #ffffff);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
margin: 0 !important;
|
||||
font-size: 20px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
color: #667eea;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.timeline-subtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-subtitle .anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-subtitle .anticon-global {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.timeline-subtitle .anticon-thunderbolt {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.timeline-subtitle .anticon-bulb {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
/* 事件列表容器 */
|
||||
.events-card .ant-spin-nested-loading > div > .ant-spin-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 分页容器优化 */
|
||||
.pagination-container {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
padding: 20px 24px;
|
||||
background: #fafbfc;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 热点事件部分样式 */
|
||||
.hot-events-section {
|
||||
margin-top: 48px;
|
||||
padding: 32px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 侧边栏卡片统一样式 */
|
||||
.ant-card {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.ant-card:hover {
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 加载提示优化 */
|
||||
.ant-spin-text {
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 空状态优化 */
|
||||
.ant-empty-description {
|
||||
color: #8c8c8c;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 消息提示样式 */
|
||||
.ant-message-notice-content {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 992px) {
|
||||
.page-header.gradient-header {
|
||||
padding: 32px 24px 24px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header.gradient-header {
|
||||
padding: 24px 16px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.community-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.auto-update-btn,
|
||||
.auto-update-btn-active {
|
||||
font-size: 14px;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式支持(可选) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.community-layout {
|
||||
background: linear-gradient(180deg, #141414 0%, #1f1f1f 100%);
|
||||
}
|
||||
|
||||
.events-card.enhanced-card {
|
||||
background: #1f1f1f;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(to bottom, #2a2a2a, #1f1f1f);
|
||||
border-bottom-color: #303030;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
486
src/views/Community/index.js
Normal file
486
src/views/Community/index.js
Normal file
@@ -0,0 +1,486 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
GridItem,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Button,
|
||||
Text,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
useToast,
|
||||
Flex,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagCloseButton,
|
||||
IconButton,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
useDisclosure,
|
||||
Center,
|
||||
Image,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
RepeatIcon,
|
||||
TimeIcon,
|
||||
InfoIcon,
|
||||
SearchIcon,
|
||||
CalendarIcon,
|
||||
StarIcon,
|
||||
ChevronRightIcon,
|
||||
CloseIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
|
||||
|
||||
// 导入组件
|
||||
import MidjourneyHeroSection from './components/MidjourneyHeroSection';
|
||||
import EventFilters from './components/EventFilters';
|
||||
import EventList from './components/EventList';
|
||||
import EventDetailModal from './components/EventDetailModal';
|
||||
import StockDetailPanel from './components/StockDetailPanel';
|
||||
import SearchBox from './components/SearchBox';
|
||||
import PopularKeywords from './components/PopularKeywords';
|
||||
import HotEvents from './components/HotEvents';
|
||||
import ImportanceLegend from './components/ImportanceLegend';
|
||||
import InvestmentCalendar from './components/InvestmentCalendar';
|
||||
import { eventService } from '../../services/eventService';
|
||||
|
||||
// 导入导航栏组件 (如果需要保留原有的导航栏)
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
|
||||
const filterLabelMap = {
|
||||
date_range: v => v ? `日期: ${v}` : '',
|
||||
sort: v => v ? `排序: ${v === 'new' ? '最新' : v === 'hot' ? '热门' : v === 'returns' ? '收益率' : v}` : '',
|
||||
importance: v => v && v !== 'all' ? `重要性: ${v}` : '',
|
||||
industry_classification: v => v ? `行业: ${v}` : '',
|
||||
industry_code: v => v ? `行业代码: ${v}` : '',
|
||||
q: v => v ? `关键词: ${v}` : '',
|
||||
};
|
||||
|
||||
const Community = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// Chakra UI hooks
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// Modal/Drawer控制
|
||||
const { isOpen: isEventModalOpen, onOpen: onEventModalOpen, onClose: onEventModalClose } = useDisclosure();
|
||||
const { isOpen: isStockDrawerOpen, onOpen: onStockDrawerOpen, onClose: onStockDrawerClose } = useDisclosure();
|
||||
|
||||
// 状态管理
|
||||
const [events, setEvents] = useState([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
|
||||
const [popularKeywords, setPopularKeywords] = useState([]);
|
||||
const [hotEvents, setHotEvents] = useState([]);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
|
||||
|
||||
// 从URL获取筛选参数
|
||||
const getFiltersFromUrl = useCallback(() => {
|
||||
return {
|
||||
sort: searchParams.get('sort') || 'new',
|
||||
importance: searchParams.get('importance') || 'all',
|
||||
date_range: searchParams.get('date_range') || '',
|
||||
q: searchParams.get('q') || '',
|
||||
search_type: searchParams.get('search_type') || 'topic',
|
||||
industry_classification: searchParams.get('industry_classification') || '',
|
||||
industry_code: searchParams.get('industry_code') || '',
|
||||
page: parseInt(searchParams.get('page') || '1', 10)
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateUrlParams = useCallback((params) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
newParams.set(key, value);
|
||||
} else {
|
||||
newParams.delete(key);
|
||||
}
|
||||
});
|
||||
setSearchParams(newParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// 加载事件列表
|
||||
const loadEvents = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = getFiltersFromUrl();
|
||||
const response = await eventService.getEvents({
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.pageSize
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setEvents(response.data.events);
|
||||
setPagination({
|
||||
current: response.data.pagination.page,
|
||||
pageSize: response.data.pagination.per_page,
|
||||
total: response.data.pagination.total
|
||||
});
|
||||
setLastUpdateTime(new Date());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load events:', error);
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载事件列表',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getFiltersFromUrl, pagination.pageSize, toast]);
|
||||
|
||||
// 加载热门关键词
|
||||
const loadPopularKeywords = useCallback(async () => {
|
||||
try {
|
||||
const response = await eventService.getPopularKeywords(20);
|
||||
if (response.success) {
|
||||
setPopularKeywords(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load popular keywords:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载热点事件
|
||||
const loadHotEvents = useCallback(async () => {
|
||||
try {
|
||||
const response = await eventService.getHotEvents({ days: 5, limit: 4 });
|
||||
if (response.success) {
|
||||
setHotEvents(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load hot events:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = useCallback((filterType, value) => {
|
||||
updateUrlParams({ [filterType]: value, page: 1 });
|
||||
}, [updateUrlParams]);
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = useCallback((page) => {
|
||||
updateUrlParams({ page });
|
||||
loadEvents(page);
|
||||
window.scrollTo(0, 0);
|
||||
}, [updateUrlParams, loadEvents]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
setSelectedEventForStock(event);
|
||||
onStockDrawerOpen();
|
||||
}, [onStockDrawerOpen]);
|
||||
|
||||
// 处理查看详情
|
||||
const handleViewDetail = useCallback((eventId) => {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
}, [navigate]);
|
||||
|
||||
// 处理关键词点击
|
||||
const handleKeywordClick = useCallback((keyword) => {
|
||||
updateUrlParams({ q: keyword, page: 1 });
|
||||
}, [updateUrlParams]);
|
||||
|
||||
|
||||
|
||||
// 处理标签删除
|
||||
const handleRemoveFilterTag = (key) => {
|
||||
let reset = '';
|
||||
if (key === 'sort') reset = 'new';
|
||||
if (key === 'importance') reset = 'all';
|
||||
updateUrlParams({ [key]: reset, page: 1 });
|
||||
loadEvents(1);
|
||||
};
|
||||
|
||||
// 获取筛选标签
|
||||
const filters = getFiltersFromUrl();
|
||||
const filterTags = Object.entries(filters)
|
||||
.filter(([key, value]) => {
|
||||
if (key === 'industry_code') return !!value;
|
||||
if (key === 'importance') return value && value !== 'all';
|
||||
if (key === 'sort') return value && value !== 'new';
|
||||
if (key === 'date_range') return !!value;
|
||||
if (key === 'q') return !!value;
|
||||
return false;
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
if (key === 'industry_code') return { key, label: `行业代码: ${value}` };
|
||||
return { key, label: filterLabelMap[key] ? filterLabelMap[key](value) : `${key}: ${value}` };
|
||||
});
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
loadEvents(page);
|
||||
loadPopularKeywords();
|
||||
loadHotEvents();
|
||||
}, [searchParams, loadEvents, loadPopularKeywords, loadHotEvents]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏 - 可以保留原有的或改用Chakra UI版本 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* Midjourney风格英雄区域 */}
|
||||
<MidjourneyHeroSection />
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
|
||||
|
||||
{/* 左侧主要内容 */}
|
||||
<GridItem>
|
||||
{/* 筛选器 - 需要改造为Chakra UI版本 */}
|
||||
<Card mb={4} bg={cardBg} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<EventFilters
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
loading={loading}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 筛选标签 */}
|
||||
{filterTags.length > 0 && (
|
||||
<Wrap spacing={2} mb={4}>
|
||||
{filterTags.map(tag => (
|
||||
<WrapItem key={tag.key}>
|
||||
<Tag size="md" variant="solid" colorScheme="blue">
|
||||
<TagLabel>{tag.label}</TagLabel>
|
||||
<TagCloseButton onClick={() => handleRemoveFilterTag(tag.key)} />
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)}
|
||||
|
||||
{/* 事件列表卡片 */}
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时事件时间轴</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="green">全网监控</Badge>
|
||||
<Badge colorScheme="orange">智能捕获</Badge>
|
||||
<Badge colorScheme="purple">深度分析</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime.toLocaleTimeString()}
|
||||
</Text>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : events.length > 0 ? (
|
||||
<EventList
|
||||
events={events}
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
) : (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧侧边栏 */}
|
||||
<GridItem>
|
||||
<VStack spacing={4}>
|
||||
{/* 搜索框 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardBody>
|
||||
<SearchBox
|
||||
onSearch={(values) => {
|
||||
updateUrlParams({ ...values, page: 1 });
|
||||
}}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 投资日历 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="sm">
|
||||
<HStack>
|
||||
<CalendarIcon />
|
||||
<Text>投资日历</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<InvestmentCalendar />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 热门关键词 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="sm">
|
||||
<HStack>
|
||||
<StarIcon />
|
||||
<Text>热门关键词</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<PopularKeywords
|
||||
keywords={popularKeywords}
|
||||
onKeywordClick={handleKeywordClick}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 重要性说明 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="sm">
|
||||
<HStack>
|
||||
<InfoIcon />
|
||||
<Text>重要性说明</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ImportanceLegend />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
{/* 热点事件 - 需要改造为Chakra UI版本 */}
|
||||
{hotEvents.length > 0 && (
|
||||
<Card mt={8} bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔥 热点事件</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<HotEvents events={hotEvents} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{/* Footer区域 */}
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 事件详情模态框 - 使用Chakra UI Modal */}
|
||||
<Modal isOpen={isEventModalOpen && selectedEvent} onClose={onEventModalClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>事件详情</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<EventDetailModal
|
||||
event={selectedEvent}
|
||||
onClose={() => {
|
||||
setSelectedEvent(null);
|
||||
onEventModalClose();
|
||||
}}
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer,避免与 Chakra Drawer 重叠导致空白 */}
|
||||
<StockDetailPanel
|
||||
visible={!!selectedEventForStock}
|
||||
event={selectedEventForStock}
|
||||
onClose={() => {
|
||||
setSelectedEventForStock(null);
|
||||
onStockDrawerClose();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
Reference in New Issue
Block a user