diff --git a/src/mocks/data/events.js b/src/mocks/data/events.js index 959f3d0d..652de263 100644 --- a/src/mocks/data/events.js +++ b/src/mocks/data/events.js @@ -747,6 +747,33 @@ export function generateMockEvents(params = {}) { const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15% const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30% + // 生成价格走势数据(前一天、当天、后一天) + const generatePriceTrend = (seed) => { + const basePrice = 10 + (seed % 90); // 基础价格 10-100 + const trend = []; + + // 前一天(5个数据点) + let price = basePrice; + for (let i = 0; i < 5; i++) { + price = price + (Math.random() - 0.5) * 0.5; + trend.push(parseFloat(price.toFixed(2))); + } + + // 当天(5个数据点) + for (let i = 0; i < 5; i++) { + price = price + (Math.random() - 0.4) * 0.8; // 轻微上涨趋势 + trend.push(parseFloat(price.toFixed(2))); + } + + // 后一天(5个数据点) + for (let i = 0; i < 5; i++) { + price = price + (Math.random() - 0.45) * 1.0; + trend.push(parseFloat(price.toFixed(2))); + } + + return trend; + }; + // 为每个事件随机选择2-5个相关股票 const relatedStockCount = 2 + (i % 4); // 2-5个股票 const relatedStocks = []; @@ -758,10 +785,16 @@ export function generateMockEvents(params = {}) { for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) { const stock = industryStocks[j % industryStocks.length]; if (!addedStockCodes.has(stock.stock_code)) { + const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4% + const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7% + relatedStocks.push({ stock_name: stock.stock_name, stock_code: stock.stock_code, - relation_desc: relationDescTemplates[(i + j) % relationDescTemplates.length] + relation_desc: relationDescTemplates[(i + j) % relationDescTemplates.length], + daily_change: dailyChange, + week_change: weekChange, + price_trend: generatePriceTrend(i * 100 + j) }); addedStockCodes.add(stock.stock_code); } @@ -773,10 +806,16 @@ export function generateMockEvents(params = {}) { while (relatedStocks.length < relatedStockCount && poolIndex < stockPool.length) { const randomStock = stockPool[poolIndex % stockPool.length]; if (!addedStockCodes.has(randomStock.stock_code)) { + const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4% + const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7% + relatedStocks.push({ stock_name: randomStock.stock_name, stock_code: randomStock.stock_code, - relation_desc: relationDescTemplates[(i + poolIndex) % relationDescTemplates.length] + relation_desc: relationDescTemplates[(i + poolIndex) % relationDescTemplates.length], + daily_change: dailyChange, + week_change: weekChange, + price_trend: generatePriceTrend(i * 100 + poolIndex) }); addedStockCodes.add(randomStock.stock_code); } diff --git a/src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js b/src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js new file mode 100644 index 00000000..a12324ea --- /dev/null +++ b/src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js @@ -0,0 +1,184 @@ +// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import ReactECharts from 'echarts-for-react'; +import moment from 'moment'; +import { + fetchKlineData, + getCacheKey, + klineDataCache +} from '../StockDetailPanel/utils/klineDataCache'; + +/** + * 迷你K线图组件 + * 显示股票的K线走势(蜡烛图),支持事件时间标记 + * + * @param {string} stockCode - 股票代码 + * @param {string} eventTime - 事件时间(可选) + * @param {Function} onClick - 点击回调(可选) + * @returns {JSX.Element} + */ +const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick }) { + 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; + } + + // 检查缓存(K线图使用 'daily' 类型) + const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily'); + const cachedData = klineDataCache.get(cacheKey); + + if (cachedData && cachedData.length > 0) { + setData(cachedData); + loadedRef.current = true; + dataFetchedRef.current = true; + return; + } + + dataFetchedRef.current = true; + setLoading(true); + + // 获取日K线数据 + fetchKlineData(stockCode, stableEventTime, 'daily') + .then((result) => { + if (mountedRef.current) { + setData(result); + setLoading(false); + loadedRef.current = true; + } + }) + .catch(() => { + if (mountedRef.current) { + setData([]); + setLoading(false); + loadedRef.current = true; + } + }); + }, [stockCode, stableEventTime]); + + const chartOption = useMemo(() => { + // 提取K线数据 [open, close, low, high] + const klineData = data + .filter(item => item.open && item.close && item.low && item.high) + .map(item => [item.open, item.close, item.low, item.high]); + + // 日K线使用 date 字段 + const dates = data.map(item => item.date || item.time); + const hasData = klineData.length > 0; + + if (!hasData) { + return { + title: { + text: loading ? '加载中...' : '无数据', + left: 'center', + top: 'middle', + textStyle: { color: '#999', fontSize: 10 } + } + }; + } + + // 计算事件时间标记 + let eventMarkLineData = []; + if (stableEventTime && Array.isArray(dates) && dates.length > 0) { + try { + const eventDate = moment(stableEventTime).format('YYYY-MM-DD'); + const eventIdx = dates.findIndex(d => { + const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d); + return dateStr.includes(eventDate); + }); + + if (eventIdx >= 0) { + eventMarkLineData.push({ + xAxis: eventIdx, + 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: dates, + show: false, + boundaryGap: true + }, + yAxis: { + type: 'value', + show: false, + scale: true + }, + series: [{ + type: 'candlestick', + data: klineData, + itemStyle: { + color: '#ef5350', // 涨(阳线) + color0: '#26a69a', // 跌(阴线) + borderColor: '#ef5350', // 涨(边框) + borderColor0: '#26a69a' // 跌(边框) + }, + barWidth: '60%', + markLine: { + silent: true, + symbol: 'none', + label: { show: false }, + data: eventMarkLineData + } + }], + tooltip: { show: false }, + animation: false + }; + }, [data, loading, stableEventTime]); + + return ( +
+ +
+ ); +}, (prevProps, nextProps) => { + return prevProps.stockCode === nextProps.stockCode && + prevProps.eventTime === nextProps.eventTime && + prevProps.onClick === nextProps.onClick; +}); + +export default MiniKLineChart; diff --git a/src/views/Community/components/DynamicNewsDetail/MiniLineChart.js b/src/views/Community/components/DynamicNewsDetail/MiniLineChart.js new file mode 100644 index 00000000..1d869273 --- /dev/null +++ b/src/views/Community/components/DynamicNewsDetail/MiniLineChart.js @@ -0,0 +1,94 @@ +// src/views/Community/components/DynamicNewsDetail/MiniLineChart.js +// Mini 折线图组件(用于股票卡片) + +import React from 'react'; +import { Box } from '@chakra-ui/react'; + +/** + * Mini 折线图组件 + * @param {Object} props + * @param {Array} props.data - 价格走势数据数组(15个数据点:前5+中5+后5) + * @param {number} props.width - 图表宽度(默认180) + * @param {number} props.height - 图表高度(默认60) + */ +const MiniLineChart = ({ data = [], width = 180, height = 60 }) => { + if (!data || data.length === 0) { + return null; + } + + // 计算最大值和最小值,用于归一化 + const max = Math.max(...data); + const min = Math.min(...data); + const range = max - min || 1; // 防止除以0 + + // 将数据点转换为 SVG 路径坐标 + const points = data.map((value, index) => { + const x = (index / (data.length - 1)) * width; + const y = height - ((value - min) / range) * height; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }); + + // 构建 SVG 路径字符串 + const pathD = `M ${points.join(' L ')}`; + + // 判断整体趋势(比较第一个和最后一个值) + const isPositive = data[data.length - 1] >= data[0]; + const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌 + + // 创建渐变填充区域路径 + const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`; + + return ( + + + + + + + + + + {/* 填充区域 */} + + + {/* 折线 */} + + + {/* 垂直分隔线(标记三个时间段) */} + {/* 前一天和当天之间 */} + + + {/* 当天和后一天之间 */} + + + + ); +}; + +export default MiniLineChart; diff --git a/src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js b/src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js new file mode 100644 index 00000000..fc1bd53a --- /dev/null +++ b/src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js @@ -0,0 +1,66 @@ +// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js +// 相关股票列表区组件(可折叠,网格布局) + +import React from 'react'; +import { + Box, + SimpleGrid, + Collapse, +} from '@chakra-ui/react'; +import CollapsibleHeader from './CollapsibleHeader'; +import StockListItem from './StockListItem'; + +/** + * 相关股票列表区组件 + * @param {Object} props + * @param {Array} props.stocks - 股票数组 + * @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } } + * @param {string} props.eventTime - 事件时间 + * @param {Set} props.watchlistSet - 自选股代码集合 + * @param {boolean} props.isOpen - 是否展开 + * @param {Function} props.onToggle - 切换展开/收起的回调 + * @param {Function} props.onWatchlistToggle - 切换自选股回调 + */ +const RelatedStocksSection = ({ + stocks, + quotes = {}, + eventTime = null, + watchlistSet = new Set(), + isOpen, + onToggle, + onWatchlistToggle +}) => { + // 如果没有股票数据,不渲染 + if (!stocks || stocks.length === 0) { + return null; + } + + return ( + + + + + + {stocks.map((stock, index) => ( + + ))} + + + + + ); +}; + +export default RelatedStocksSection; diff --git a/src/views/Community/components/DynamicNewsDetail/StockListItem.js b/src/views/Community/components/DynamicNewsDetail/StockListItem.js new file mode 100644 index 00000000..0eeb2d6d --- /dev/null +++ b/src/views/Community/components/DynamicNewsDetail/StockListItem.js @@ -0,0 +1,239 @@ +// src/views/Community/components/DynamicNewsDetail/StockListItem.js +// 股票卡片组件(融合表格功能的卡片样式) + +import React, { useState } from 'react'; +import { + Box, + Flex, + VStack, + SimpleGrid, + Text, + Button, + IconButton, + Collapse, + useColorModeValue, +} from '@chakra-ui/react'; +import { StarIcon } from '@chakra-ui/icons'; +import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart'; +import MiniKLineChart from './MiniKLineChart'; +import StockChartModal from '../../../../components/StockChart/StockChartModal'; + +/** + * 股票卡片组件 + * @param {Object} props + * @param {Object} props.stock - 股票对象 + * @param {string} props.stock.stock_name - 股票名称 + * @param {string} props.stock.stock_code - 股票代码 + * @param {string} props.stock.relation_desc - 关联描述 + * @param {Object} props.quote - 股票行情数据(可选) + * @param {number} props.quote.change - 涨跌幅 + * @param {string} props.eventTime - 事件时间(可选) + * @param {boolean} props.isInWatchlist - 是否在自选股中 + * @param {Function} props.onWatchlistToggle - 切换自选股回调 + */ +const StockListItem = ({ + stock, + quote = null, + eventTime = null, + isInWatchlist = false, + onWatchlistToggle +}) => { + const cardBg = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + const codeColor = useColorModeValue('blue.600', 'blue.300'); + const nameColor = useColorModeValue('gray.700', 'gray.300'); + const descColor = useColorModeValue('gray.600', 'gray.400'); + const dividerColor = useColorModeValue('gray.200', 'gray.600'); + + const [isDescExpanded, setIsDescExpanded] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleViewDetail = () => { + const stockCode = stock.stock_code.split('.')[0]; + window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank'); + }; + + const handleWatchlistClick = (e) => { + e.stopPropagation(); + onWatchlistToggle?.(stock.stock_code, isInWatchlist); + }; + + // 格式化涨跌幅显示 + const formatChange = (value) => { + if (value === null || value === undefined || isNaN(value)) return '--'; + const prefix = value > 0 ? '+' : ''; + return `${prefix}${parseFloat(value).toFixed(2)}%`; + }; + + // 获取涨跌幅颜色 + const getChangeColor = (value) => { + const num = parseFloat(value); + if (isNaN(num) || num === 0) return 'gray.500'; + return num > 0 ? 'red.500' : 'green.500'; + }; + + // 获取涨跌幅数据(优先使用 quote,fallback 到 stock) + const change = quote?.change ?? stock.daily_change ?? null; + + // 处理关联描述 + const getRelationDesc = () => { + const relationDesc = stock.relation_desc; + + if (!relationDesc) return '--'; + + if (typeof relationDesc === 'string') { + return relationDesc; + } else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) { + // 新格式:{data: [{query_part: "...", sentences: "..."}]} + return relationDesc.data + .map(item => item.query_part || item.sentences || '') + .filter(s => s) + .join(';') || '--'; + } + + return '--'; + }; + + const relationText = getRelationDesc(); + const maxLength = 50; // 收缩时显示的最大字符数 + const needTruncate = relationText && relationText !== '--' && relationText.length > maxLength; + + return ( + <> + + + {/* 顶部:股票代码 + 名称 + 操作按钮 */} + + {/* 左侧:代码 + 名称 */} + + + {stock.stock_code} + + + {stock.stock_name} + + + {formatChange(change)} + + + + {/* 右侧:操作按钮 */} + + {onWatchlistToggle && ( + } + onClick={handleWatchlistClick} + aria-label={isInWatchlist ? '已关注' : '加自选'} + title={isInWatchlist ? '已关注' : '加自选'} + /> + )} + + + + + {/* 分隔线 */} + + + {/* 分时图 & K线图 - 左右布局 */} + + + {/* 左侧:分时图 */} + + + 分时图 + + setIsModalOpen(true)} + /> + + + {/* 右侧:K线图 */} + + + 日K线 + + setIsModalOpen(true)} + /> + + + + + {/* 分隔线 */} + + + {/* 关联描述 */} + {relationText && relationText !== '--' && ( + + + 关联描述: + + + + {relationText} + + + {needTruncate && ( + + )} + + )} + + + + {/* 股票详情弹窗 */} + setIsModalOpen(false)} + stock={stock} + eventTime={eventTime} + size="6xl" + /> + + ); +}; + +export default StockListItem; diff --git a/src/views/Community/index.js b/src/views/Community/index.js index 09bbd417..41c6cd82 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -99,22 +99,48 @@ const Community = () => { 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(); + // 检查是否使用 mock 模式 + // 开发阶段默认使用 mock 数据 + const useMock = true; // TODO: 生产环境改为环境变量控制 + // const useMock = process.env.REACT_APP_USE_MOCK === 'true' || + // localStorage.getItem('use_mock_data') === 'true'; - if (data.success && data.data) { - setDynamicNewsEvents(data.data); - logger.info('Community', '动态新闻加载成功', { - count: data.data.length, - timeRange: timeRange.description + if (useMock) { + // 使用 mock 数据 + const { generateMockEvents } = await import('../../mocks/data/events'); + const mockData = generateMockEvents({ page: 1, per_page: 30 }); + + // 调试:检查第一个事件的 related_stocks 数据 + if (mockData.events[0]?.related_stocks) { + console.log('Mock 数据第一个事件的股票:', mockData.events[0].related_stocks); + } + + setDynamicNewsEvents(mockData.events); + logger.info('Community', '动态新闻(Mock)加载成功', { + count: mockData.events.length, + mode: 'mock', + firstEventStocks: mockData.events[0]?.related_stocks?.length || 0 }); } else { - logger.warn('Community', '动态新闻加载失败', data); - setDynamicNewsEvents([]); + // 使用真实 API + 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, + mode: 'api' + }); + } else { + logger.warn('Community', '动态新闻加载失败', data); + setDynamicNewsEvents([]); + } } } catch (error) { logger.error('Community', '动态新闻加载异常', error);