diff --git a/src/components/Navbars/components/Navigation/DesktopNav.js b/src/components/Navbars/components/Navigation/DesktopNav.js index c9512352..642dd4ba 100644 --- a/src/components/Navbars/components/Navigation/DesktopNav.js +++ b/src/components/Navbars/components/Navigation/DesktopNav.js @@ -72,10 +72,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => { { - onHighFreqClose(); // 先关闭菜单 // 🎯 追踪菜单项点击 navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community'); navigate('/community'); + onHighFreqClose(); // 跳转后关闭菜单 }} borderRadius="md" bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'} @@ -93,10 +93,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => { { - onHighFreqClose(); // 先关闭菜单 // 🎯 追踪菜单项点击 navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts'); navigate('/concepts'); + onHighFreqClose(); // 跳转后关闭菜单 }} borderRadius="md" bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'} @@ -132,8 +132,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => { { - onMarketReviewClose(); // 先关闭菜单 navigate('/limit-analyse'); + onMarketReviewClose(); // 跳转后关闭菜单 }} borderRadius="md" bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'} @@ -148,8 +148,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => { { - onMarketReviewClose(); // 先关闭菜单 navigate('/stocks'); + onMarketReviewClose(); // 跳转后关闭菜单 }} borderRadius="md" bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'} @@ -164,8 +164,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => { { - onMarketReviewClose(); // 先关闭菜单 navigate('/trading-simulation'); + onMarketReviewClose(); // 跳转后关闭菜单 }} borderRadius="md" bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'} diff --git a/src/components/StockChangeIndicators.js b/src/components/StockChangeIndicators.js new file mode 100644 index 00000000..7dce7b20 --- /dev/null +++ b/src/components/StockChangeIndicators.js @@ -0,0 +1,92 @@ +// src/components/StockChangeIndicators.js +// 股票涨跌幅指标组件(通用) + +import React from 'react'; +import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react'; + +/** + * 股票涨跌幅指标组件(3分天下布局) + * @param {Object} props + * @param {number} props.avgChange - 平均涨跌幅 + * @param {number} props.maxChange - 最大涨跌幅 + * @param {number} props.weekChange - 周涨跌幅 + */ +const StockChangeIndicators = ({ + avgChange, + maxChange, + weekChange, +}) => { + // 根据涨跌幅获取数字颜色 + const getNumberColor = (value) => { + if (value == null) { + return useColorModeValue('gray.600', 'gray.400'); + } + + const absValue = Math.abs(value); + const isPositive = value > 0; + + // 5%以上:深色 + if (absValue >= 5) { + return isPositive ? 'red.600' : 'green.600'; + } + // 1-5%:正常色 + if (absValue >= 1) { + return isPositive ? 'red.500' : 'green.500'; + } + // 0-1%:淡色 + return isPositive ? 'red.400' : 'green.400'; + }; + + // 渲染单个指标 + const renderIndicator = (label, value) => { + if (value == null) return null; + + const sign = value > 0 ? '+' : ''; + const numStr = Math.abs(value).toFixed(1); + const numberColor = getNumberColor(value); + const labelColor = useColorModeValue('gray.600', 'gray.400'); + + return ( + + + + {label} + + + {sign} + + + {value < 0 ? '-' : ''}{numStr} + + + % + + + + ); + }; + + // 如果没有任何数据,不渲染 + if (avgChange == null && maxChange == null && weekChange == null) { + return null; + } + + return ( + + {renderIndicator('均 ', avgChange)} + {renderIndicator('最 ', maxChange)} + {renderIndicator('周 ', weekChange)} + + ); +}; + +export default StockChangeIndicators; diff --git a/src/index.js b/src/index.js index 79c2e646..33162ea0 100755 --- a/src/index.js +++ b/src/index.js @@ -27,7 +27,7 @@ async function startApp() { diff --git a/src/mocks/data/events.js b/src/mocks/data/events.js index 8ace595f..565d3cc4 100644 --- a/src/mocks/data/events.js +++ b/src/mocks/data/events.js @@ -650,7 +650,7 @@ export function generateMockEvents(params = {}) { const allEvents = []; const importanceLevels = ['S', 'A', 'B', 'C']; - const baseDate = new Date('2025-01-15'); + const baseDate = new Date(); // 使用当前日期作为基准 for (let i = 0; i < totalEvents; i++) { const industry = industries[i % industries.length]; @@ -816,3 +816,95 @@ export function generatePopularKeywords(limit = 20) { trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable', })); } + +/** + * 生成动态新闻事件(实时要闻·动态追踪专用) + * @param {Object} timeRange - 时间范围 { startTime, endTime } + * @param {number} count - 生成事件数量,默认30条 + * @returns {Array} - 事件列表 + */ +export function generateDynamicNewsEvents(timeRange = null, count = 30) { + const events = []; + const importanceLevels = ['S', 'A', 'B', 'C']; + + // 如果没有提供时间范围,默认生成最近24小时的事件 + let startTime, endTime; + if (timeRange) { + startTime = new Date(timeRange.startTime); + endTime = new Date(timeRange.endTime); + } else { + endTime = new Date(); + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); // 24小时前 + } + + // 计算时间跨度(毫秒) + const timeSpan = endTime.getTime() - startTime.getTime(); + + for (let i = 0; i < count; i++) { + const industry = industries[i % industries.length]; + const imp = importanceLevels[i % importanceLevels.length]; + const eventType = eventTypes[i % eventTypes.length]; + + // 在时间范围内随机生成事件时间 + const randomOffset = Math.random() * timeSpan; + const createdAt = new Date(startTime.getTime() + randomOffset); + + // 生成随机热度和收益率 + const hotScore = Math.max(60, 100 - i * 1.2); // 动态新闻热度更高 + const relatedAvgChg = (Math.random() * 15 - 3).toFixed(2); // -3% 到 12% + const relatedMaxChg = (Math.random() * 25).toFixed(2); // 0% 到 25% + const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20% + + // 为每个事件随机选择2-5个相关股票 + const relatedStockCount = 2 + (i % 4); + const relatedStocks = []; + const industryStocks = stockPool.filter(s => s.industry === industry); + + // 优先选择同行业股票 + if (industryStocks.length > 0) { + for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) { + relatedStocks.push(industryStocks[j % industryStocks.length].stock_code); + } + } + + // 如果同行业股票不够,从整个 stockPool 中补充 + while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) { + const randomStock = stockPool[relatedStocks.length % stockPool.length]; + if (!relatedStocks.includes(randomStock.stock_code)) { + relatedStocks.push(randomStock.stock_code); + } + } + + events.push({ + id: `dynamic_${i + 1}`, + title: generateEventTitle(industry, i), + description: generateEventDescription(industry, imp, i), + content: generateEventDescription(industry, imp, i), + event_type: eventType, + importance: imp, + status: 'published', + created_at: createdAt.toISOString(), + updated_at: createdAt.toISOString(), + hot_score: hotScore, + view_count: Math.floor(Math.random() * 5000) + 1000, // 1000-6000 浏览量 + follower_count: Math.floor(Math.random() * 500) + 50, // 50-550 关注数 + post_count: Math.floor(Math.random() * 100) + 10, // 10-110 帖子数 + related_avg_chg: parseFloat(relatedAvgChg), + related_max_chg: parseFloat(relatedMaxChg), + related_week_chg: parseFloat(relatedWeekChg), + keywords: generateKeywords(industry, i), + is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成 + industry: industry, + related_stocks: relatedStocks, + creator: { + username: authorPool[i % authorPool.length], + avatar_url: null + } + }); + } + + // 按时间倒序排序(最新的在前) + events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + + return events; +} diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index 81247fa0..8cd7a11b 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -2,7 +2,7 @@ // 事件相关的 Mock API Handlers import { http, HttpResponse } from 'msw'; -import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events'; +import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events'; import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account'; // 模拟网络延迟 @@ -111,6 +111,47 @@ export const eventHandlers = [ } }), + // 获取动态新闻(实时要闻·动态追踪专用) + http.get('/api/events/dynamic-news', async ({ request }) => { + await delay(400); + + const url = new URL(request.url); + const count = parseInt(url.searchParams.get('count') || '30'); + const startTime = url.searchParams.get('start_time'); + const endTime = url.searchParams.get('end_time'); + + console.log('[Mock] 获取动态新闻, count:', count, 'startTime:', startTime, 'endTime:', endTime); + + try { + let timeRange = null; + if (startTime && endTime) { + timeRange = { + startTime: new Date(startTime), + endTime: new Date(endTime) + }; + } + + const events = generateDynamicNewsEvents(timeRange, count); + + return HttpResponse.json({ + success: true, + data: events, + total: events.length, + message: '获取成功' + }); + } catch (error) { + console.error('[Mock] 获取动态新闻失败:', error); + return HttpResponse.json( + { + success: false, + error: '获取动态新闻失败', + data: [] + }, + { status: 500 } + ); + } + }), + // ==================== 事件详情相关 ==================== // 获取事件相关股票 diff --git a/src/utils/tradingTimeUtils.js b/src/utils/tradingTimeUtils.js new file mode 100644 index 00000000..eeb09e22 --- /dev/null +++ b/src/utils/tradingTimeUtils.js @@ -0,0 +1,175 @@ +// src/utils/tradingTimeUtils.js +// 交易时间相关工具函数 + +import moment from 'moment'; + +/** + * 获取当前时间应该显示的实时要闻时间范围 + * 规则: + * - 15:00 之前:显示昨日 15:00 - 今日 15:00 + * - 15:30 之后:显示今日 15:00 - 当前时间 + * + * @returns {{ startTime: Date, endTime: Date, description: string }} + */ +export const getCurrentTradingTimeRange = () => { + const now = moment(); + const currentHour = now.hour(); + const currentMinute = now.minute(); + + // 计算当前是第几分钟(方便比较) + const currentTimeInMinutes = currentHour * 60 + currentMinute; + const cutoffTime1500 = 15 * 60; // 15:00 = 900分钟 + const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟 + + let startTime, endTime, description; + + if (currentTimeInMinutes < cutoffTime1500) { + // 15:00 之前:显示昨日 15:00 - 今日 15:00 + startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); + endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); + description = '昨日15:00 - 今日15:00'; + } else if (currentTimeInMinutes >= cutoffTime1530) { + // 15:30 之后:显示今日 15:00 - 当前时间 + startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); + endTime = now.toDate(); + description = '今日15:00 - 当前时间'; + } else { + // 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00 + startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); + endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); + description = '昨日15:00 - 今日15:00'; + } + + return { + startTime, + endTime, + description, + rangeType: currentTimeInMinutes >= cutoffTime1530 ? 'current_day' : 'full_day' + }; +}; + +/** + * 获取市场复盘的时间范围 + * 规则:显示最近一个完整的交易日(昨日 15:00 - 今日 15:00) + * + * @returns {{ startTime: Date, endTime: Date, description: string }} + */ +export const getMarketReviewTimeRange = () => { + const now = moment(); + const currentHour = now.hour(); + const currentMinute = now.minute(); + + // 计算当前是第几分钟 + const currentTimeInMinutes = currentHour * 60 + currentMinute; + const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟 + + let startTime, endTime, description; + + if (currentTimeInMinutes >= cutoffTime1530) { + // 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日) + startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); + endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); + description = '昨日15:00 - 今日15:00'; + } else { + // 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日) + startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate(); + endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); + description = '前日15:00 - 昨日15:00'; + } + + return { + startTime, + endTime, + description, + rangeType: 'market_review' + }; +}; + +/** + * 根据时间范围过滤事件列表 + * + * @param {Array} events - 事件列表 + * @param {Date} startTime - 开始时间 + * @param {Date} endTime - 结束时间 + * @returns {Array} 过滤后的事件列表 + */ +export const filterEventsByTimeRange = (events, startTime, endTime) => { + if (!events || !Array.isArray(events)) { + return []; + } + + if (!startTime || !endTime) { + return events; + } + + const startMoment = moment(startTime); + const endMoment = moment(endTime); + + return events.filter(event => { + if (!event.created_at) { + return false; + } + + const eventTime = moment(event.created_at); + return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment); + }); +}; + +/** + * 判断当前是否应该显示市场复盘模块 + * 根据需求:市场复盘模块一直显示 + * + * @returns {boolean} + */ +export const shouldShowMarketReview = () => { + // 市场复盘模块始终显示 + return true; +}; + +/** + * 获取时间范围的描述文本 + * + * @param {Date} startTime - 开始时间 + * @param {Date} endTime - 结束时间 + * @returns {string} + */ +export const getTimeRangeDescription = (startTime, endTime) => { + if (!startTime || !endTime) { + return ''; + } + + const startStr = moment(startTime).format('MM-DD HH:mm'); + const endStr = moment(endTime).format('MM-DD HH:mm'); + + return `${startStr} - ${endStr}`; +}; + +/** + * 判断是否为交易日(简化版本,只判断周末) + * 注意:这里没有考虑节假日,如需精确判断需要接入交易日历API + * + * @param {Date} date - 日期 + * @returns {boolean} + */ +export const isTradingDay = (date) => { + const day = moment(date).day(); + // 0 = 周日, 6 = 周六 + return day !== 0 && day !== 6; +}; + +/** + * 获取上一个交易日(简化版本) + * + * @param {Date} date - 日期 + * @returns {Date} + */ +export const getPreviousTradingDay = (date) => { + let prevDay = moment(date).subtract(1, 'day'); + + // 如果是周末,继续往前找 + while (!isTradingDay(prevDay.toDate())) { + prevDay = prevDay.subtract(1, 'day'); + } + + return prevDay.toDate(); +}; diff --git a/src/views/Community/components/DynamicNewsCard.js b/src/views/Community/components/DynamicNewsCard.js new file mode 100644 index 00000000..02768e41 --- /dev/null +++ b/src/views/Community/components/DynamicNewsCard.js @@ -0,0 +1,239 @@ +// src/views/Community/components/DynamicNewsCard.js +// 横向滚动事件卡片组件(实时要闻·动态追踪) + +import React, { forwardRef, useRef, useState } from 'react'; +import { + Card, + CardHeader, + CardBody, + Box, + Flex, + VStack, + HStack, + Heading, + Text, + Badge, + IconButton, + Center, + Spinner, + useColorModeValue +} from '@chakra-ui/react'; +import { ChevronLeftIcon, ChevronRightIcon, TimeIcon } from '@chakra-ui/icons'; +import DynamicNewsEventCard from './EventCard/DynamicNewsEventCard'; + +/** + * 实时要闻·动态追踪 - 横向滚动卡片组件 + * @param {Array} events - 事件列表 + * @param {boolean} loading - 加载状态 + * @param {Date} lastUpdateTime - 最后更新时间 + * @param {Function} onEventClick - 事件点击回调 + * @param {Function} onViewDetail - 查看详情回调 + * @param {Object} ref - 用于滚动的ref + */ +const DynamicNewsCard = forwardRef(({ + events, + loading, + lastUpdateTime, + onEventClick, + onViewDetail, + ...rest +}, ref) => { + const cardBg = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + const scrollContainerRef = useRef(null); + const [showLeftArrow, setShowLeftArrow] = useState(false); + const [showRightArrow, setShowRightArrow] = useState(true); + + // 滚动到左侧 + const scrollLeft = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollBy({ + left: -400, + behavior: 'smooth' + }); + } + }; + + // 滚动到右侧 + const scrollRight = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollBy({ + left: 400, + behavior: 'smooth' + }); + } + }; + + // 监听滚动位置,更新箭头显示状态 + const handleScroll = (e) => { + const container = e.target; + const scrollLeft = container.scrollLeft; + const scrollWidth = container.scrollWidth; + const clientWidth = container.clientWidth; + + setShowLeftArrow(scrollLeft > 0); + setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10); + }; + + // 时间轴样式配置 + const getTimelineBoxStyle = () => { + return { + bg: useColorModeValue('gray.50', 'gray.700'), + borderColor: useColorModeValue('gray.400', 'gray.500'), + borderWidth: '2px', + textColor: useColorModeValue('blue.600', 'blue.400'), + boxShadow: 'sm', + }; + }; + + return ( + + {/* 标题部分 */} + + + + + + + 实时要闻·动态追踪 + + + + 实时 + 盘中 + 快讯 + + + + 最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'} + + + + + {/* 主体内容 */} + + {/* Loading 状态 */} + {loading && ( +
+ + + 正在加载最新事件... + +
+ )} + + {/* Empty 状态 */} + {!loading && (!events || events.length === 0) && ( +
+ + 暂无事件数据 + +
+ )} + + {/* 横向滚动事件列表 */} + {!loading && events && events.length > 0 && ( + + {/* 左侧滚动按钮 */} + {showLeftArrow && ( + } + position="absolute" + left="-4" + top="50%" + transform="translateY(-50%)" + zIndex={2} + onClick={scrollLeft} + colorScheme="blue" + variant="solid" + size="md" + borderRadius="full" + shadow="md" + aria-label="向左滚动" + /> + )} + + {/* 右侧滚动按钮 */} + {showRightArrow && ( + } + position="absolute" + right="-4" + top="50%" + transform="translateY(-50%)" + zIndex={2} + onClick={scrollRight} + colorScheme="blue" + variant="solid" + size="md" + borderRadius="full" + shadow="md" + aria-label="向右滚动" + /> + )} + + {/* 横向滚动容器 */} + + {events.map((event, index) => ( + + { + e.preventDefault(); + e.stopPropagation(); + onEventClick(event); + }} + onToggleFollow={() => {}} + timelineStyle={getTimelineBoxStyle()} + borderColor={borderColor} + /> + + ))} + + + )} +
+
+ ); +}); + +DynamicNewsCard.displayName = 'DynamicNewsCard'; + +export default DynamicNewsCard; diff --git a/src/views/Community/components/EventCard/DynamicNewsEventCard.js b/src/views/Community/components/EventCard/DynamicNewsEventCard.js new file mode 100644 index 00000000..a398c284 --- /dev/null +++ b/src/views/Community/components/EventCard/DynamicNewsEventCard.js @@ -0,0 +1,141 @@ +// src/views/Community/components/EventCard/DynamicNewsEventCard.js +// 动态新闻事件卡片组件(纵向布局,时间在上) + +import React from 'react'; +import { + VStack, + Card, + CardBody, + Box, + Text, + useColorModeValue, +} from '@chakra-ui/react'; +import moment from 'moment'; +import { getImportanceConfig } from '../../../../constants/importanceLevels'; + +// 导入子组件 +import EventFollowButton from './EventFollowButton'; +import StockChangeIndicators from '../../../../components/StockChangeIndicators'; + +/** + * 动态新闻事件卡片组件(极简版) + * @param {Object} props + * @param {Object} props.event - 事件对象 + * @param {number} props.index - 事件索引 + * @param {boolean} props.isFollowing - 是否已关注 + * @param {number} props.followerCount - 关注数 + * @param {Function} props.onEventClick - 卡片点击事件 + * @param {Function} props.onTitleClick - 标题点击事件 + * @param {Function} props.onToggleFollow - 切换关注事件 + * @param {Object} props.timelineStyle - 时间轴样式配置 + * @param {string} props.borderColor - 边框颜色 + */ +const DynamicNewsEventCard = ({ + event, + index, + isFollowing, + followerCount, + onEventClick, + onTitleClick, + onToggleFollow, + timelineStyle, + borderColor, +}) => { + const importance = getImportanceConfig(event.importance); + const cardBg = useColorModeValue('white', 'gray.800'); + const linkColor = useColorModeValue('blue.600', 'blue.400'); + + return ( + + {/* 时间标签 - 在卡片上方 */} + + + {moment(event.created_at).format('YYYY-MM-DD HH:mm')} + + + + {/* 事件卡片 */} + onEventClick?.(event)} + > + + {/* 关注按钮 - 绝对定位在右上角 */} + + onToggleFollow?.(event.id)} + size="xs" + showCount={false} + /> + + + + {/* 第一行:标题 + 重要性(行内文字) */} + onTitleClick?.(e, event)} + paddingRight="10px" + > + + {event.title} + + [{importance.level}] + + + + + {/* 第二行:涨跌幅数据 */} + + + + + + ); +}; + +export default DynamicNewsEventCard; diff --git a/src/views/Community/components/EventCard/EventFollowButton.js b/src/views/Community/components/EventCard/EventFollowButton.js index 9b7a3e3c..58447836 100644 --- a/src/views/Community/components/EventCard/EventFollowButton.js +++ b/src/views/Community/components/EventCard/EventFollowButton.js @@ -1,7 +1,7 @@ // src/views/Community/components/EventCard/EventFollowButton.js import React from 'react'; -import { Button } from '@chakra-ui/react'; -import { StarIcon } from '@chakra-ui/icons'; +import { IconButton, Box } from '@chakra-ui/react'; +import { AiFillStar, AiOutlineStar } from 'react-icons/ai'; /** * 事件关注按钮组件 @@ -19,7 +19,7 @@ const EventFollowButton = ({ size = 'sm', showCount = true }) => { - const iconSize = size === 'xs' ? '10px' : '12px'; + const iconSize = size === 'xs' ? '16px' : size === 'sm' ? '18px' : '22px'; const handleClick = (e) => { e.stopPropagation(); @@ -27,16 +27,38 @@ const EventFollowButton = ({ }; return ( - + + + ) : ( + + ) + } + onClick={handleClick} + aria-label={isFollowing ? '取消关注' : '关注'} + /> + {/* + {followerCount || 0} + */} + ); }; diff --git a/src/views/Community/components/MarketReviewCard.js b/src/views/Community/components/MarketReviewCard.js new file mode 100644 index 00000000..78d771d2 --- /dev/null +++ b/src/views/Community/components/MarketReviewCard.js @@ -0,0 +1,300 @@ +// src/views/Community/components/MarketReviewCard.js +// 市场复盘组件(左右布局:事件列表 | 事件详情) + +import React, { forwardRef, useState } from 'react'; +import { + Card, + CardHeader, + CardBody, + Box, + Flex, + VStack, + HStack, + Heading, + Text, + Badge, + Center, + Spinner, + useColorModeValue, + Grid, + GridItem, +} from '@chakra-ui/react'; +import { TimeIcon, InfoIcon } from '@chakra-ui/icons'; +import moment from 'moment'; +import CompactEventCard from './EventCard/CompactEventCard'; +import EventHeader from './EventCard/EventHeader'; +import EventStats from './EventCard/EventStats'; +import EventFollowButton from './EventCard/EventFollowButton'; +import EventPriceDisplay from './EventCard/EventPriceDisplay'; +import EventDescription from './EventCard/EventDescription'; +import { getImportanceConfig } from '../../../constants/importanceLevels'; + +/** + * 市场复盘 - 左右布局卡片组件 + * @param {Array} events - 事件列表 + * @param {boolean} loading - 加载状态 + * @param {Date} lastUpdateTime - 最后更新时间 + * @param {Function} onEventClick - 事件点击回调 + * @param {Function} onViewDetail - 查看详情回调 + * @param {Function} onToggleFollow - 切换关注回调 + * @param {Object} ref - 用于滚动的ref + */ +const MarketReviewCard = forwardRef(({ + events, + loading, + lastUpdateTime, + onEventClick, + onViewDetail, + onToggleFollow, + ...rest +}, ref) => { + const cardBg = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + const linkColor = useColorModeValue('blue.600', 'blue.400'); + const mutedColor = useColorModeValue('gray.500', 'gray.400'); + const textColor = useColorModeValue('gray.700', 'gray.200'); + const selectedBg = useColorModeValue('blue.50', 'blue.900'); + + // 选中的事件 + const [selectedEvent, setSelectedEvent] = useState(null); + + // 时间轴样式配置 + const getTimelineBoxStyle = () => { + return { + bg: useColorModeValue('gray.50', 'gray.700'), + borderColor: useColorModeValue('gray.400', 'gray.500'), + borderWidth: '2px', + textColor: useColorModeValue('blue.600', 'blue.400'), + boxShadow: 'sm', + }; + }; + + // 处理事件点击 + const handleEventClick = (event) => { + setSelectedEvent(event); + if (onEventClick) { + onEventClick(event); + } + }; + + // 渲染右侧事件详情 + const renderEventDetail = () => { + if (!selectedEvent) { + return ( +
+ + + + 请从左侧选择事件查看详情 + + +
+ ); + } + + const importance = getImportanceConfig(selectedEvent.importance); + + return ( + + + + {/* 第一行:标题+优先级 | 统计+关注 */} + + {/* 左侧:标题 + 优先级标签 */} + { + e.preventDefault(); + e.stopPropagation(); + if (onViewDetail) { + onViewDetail(e, selectedEvent.id); + } + }} + linkColor={linkColor} + compact={false} + size="lg" + /> + + {/* 右侧:统计数据 + 关注按钮 */} + + {/* 统计数据 */} + + + {/* 关注按钮 */} + onToggleFollow && onToggleFollow(selectedEvent.id)} + size="sm" + showCount={false} + /> + + + + {/* 第二行:价格标签 | 时间+作者 */} + + {/* 左侧:价格标签 */} + + + {/* 右侧:时间 + 作者 */} + + + {moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')} + + + @{selectedEvent.creator?.username || 'Anonymous'} + + + + {/* 第三行:描述文字 */} + + + + + ); + }; + + return ( + + {/* 标题部分 */} + + + + + + + 市场复盘 + + + + 复盘 + 总结 + 完整 + + + + 最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'} + + + + + {/* 主体内容 */} + + {/* Loading 状态 */} + {loading && ( +
+ + + 正在加载复盘数据... + +
+ )} + + {/* Empty 状态 */} + {!loading && (!events || events.length === 0) && ( +
+ + 暂无复盘数据 + +
+ )} + + {/* 左右布局:事件列表 | 事件详情 */} + {!loading && events && events.length > 0 && ( + + {/* 左侧:事件列表 (33.3%) */} + + + + {events.map((event, index) => ( + handleEventClick(event)} + cursor="pointer" + bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'} + borderRadius="md" + transition="all 0.2s" + _hover={{ bg: selectedBg }} + > + handleEventClick(event)} + onTitleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleEventClick(event); + }} + onViewDetail={onViewDetail} + onToggleFollow={() => {}} + timelineStyle={getTimelineBoxStyle()} + borderColor={borderColor} + /> + + ))} + + + + + {/* 右侧:事件详情 (66.7%) */} + + {renderEventDetail()} + + + )} +
+
+ ); +}); + +MarketReviewCard.displayName = 'MarketReviewCard'; + +export default MarketReviewCard; diff --git a/src/views/Community/index.js b/src/views/Community/index.js index 0739f7d5..7a16acfd 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -1,5 +1,5 @@ // src/views/Community/index.js -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice'; @@ -11,6 +11,8 @@ import { // 导入组件 import EventTimelineCard from './components/EventTimelineCard'; +import DynamicNewsCard from './components/DynamicNewsCard'; +import MarketReviewCard from './components/MarketReviewCard'; import HotEventsSection from './components/HotEventsSection'; import EventModals from './components/EventModals'; @@ -19,6 +21,13 @@ import { useEventData } from './hooks/useEventData'; import { useEventFilters } from './hooks/useEventFilters'; import { useCommunityEvents } from './hooks/useCommunityEvents'; +// 导入时间工具函数 +import { + getCurrentTradingTimeRange, + getMarketReviewTimeRange, + filterEventsByTimeRange +} from '../../utils/tradingTimeUtils'; + import { logger } from '../../utils/logger'; import { useNotification } from '../../contexts/NotificationContext'; import { usePostHogTrack } from '../../hooks/usePostHogRedux'; @@ -48,6 +57,10 @@ const Community = () => { const [selectedEvent, setSelectedEvent] = useState(null); const [selectedEventForStock, setSelectedEventForStock] = useState(null); + // 动态新闻数据状态 + const [dynamicNewsEvents, setDynamicNewsEvents] = useState([]); + const [dynamicNewsLoading, setDynamicNewsLoading] = useState(true); + // 🎯 初始化Community埋点Hook const communityEvents = useCommunityEvents({ navigate }); @@ -60,12 +73,64 @@ const Community = () => { const { events, pagination, loading, lastUpdateTime } = useEventData(filters); + // 计算市场复盘的时间范围和过滤后的事件 + const marketReviewData = useMemo(() => { + const timeRange = getMarketReviewTimeRange(); + const filteredEvents = filterEventsByTimeRange(events, timeRange.startTime, timeRange.endTime); + logger.debug('Community', '市场复盘时间范围', { + description: timeRange.description, + rangeType: timeRange.rangeType, + eventCount: filteredEvents.length + }); + return { + events: filteredEvents, + timeRange + }; + }, [events]); + // 加载热门关键词和热点事件(使用Redux,内部有缓存判断) useEffect(() => { dispatch(fetchPopularKeywords()); dispatch(fetchHotEvents()); }, [dispatch]); + // 加载动态新闻数据 + useEffect(() => { + const fetchDynamicNews = async () => { + setDynamicNewsLoading(true); + try { + const timeRange = getCurrentTradingTimeRange(); + const response = await fetch( + `/api/events/dynamic-news?start_time=${timeRange.startTime.toISOString()}&end_time=${timeRange.endTime.toISOString()}&count=30`, + { credentials: 'include' } + ); + const data = await response.json(); + + if (data.success && data.data) { + setDynamicNewsEvents(data.data); + logger.info('Community', '动态新闻加载成功', { + count: data.data.length, + timeRange: timeRange.description + }); + } else { + logger.warn('Community', '动态新闻加载失败', data); + setDynamicNewsEvents([]); + } + } catch (error) { + logger.error('Community', '动态新闻加载异常', error); + setDynamicNewsEvents([]); + } finally { + setDynamicNewsLoading(false); + } + }; + + fetchDynamicNews(); + + // 每5分钟刷新一次动态新闻 + const interval = setInterval(fetchDynamicNews, 5 * 60 * 1000); + return () => clearInterval(interval); + }, []); + // 🎯 PostHog 追踪:页面浏览 // useEffect(() => { // track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, { @@ -120,7 +185,28 @@ const Community = () => { {/* 热点事件区域 */} - {/* 实时事件 */} + {/* 实时要闻·动态追踪 - 横向滚动 */} + + + {/* 市场复盘 - 左右布局 */} + {}} + /> + + {/* 实时事件 - 原纵向列表 */}