Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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;

View 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;
}

View 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;

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

View 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;

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;