feat: 添加相关股票模块

This commit is contained in:
zdl
2025-11-01 12:19:47 +08:00
parent 571d5e68bc
commit 3b8b749eb1
6 changed files with 663 additions and 15 deletions

View File

@@ -747,6 +747,33 @@ export function generateMockEvents(params = {}) {
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15% const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30% 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个相关股票 // 为每个事件随机选择2-5个相关股票
const relatedStockCount = 2 + (i % 4); // 2-5个股票 const relatedStockCount = 2 + (i % 4); // 2-5个股票
const relatedStocks = []; const relatedStocks = [];
@@ -758,10 +785,16 @@ export function generateMockEvents(params = {}) {
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) { for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
const stock = industryStocks[j % industryStocks.length]; const stock = industryStocks[j % industryStocks.length];
if (!addedStockCodes.has(stock.stock_code)) { 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({ relatedStocks.push({
stock_name: stock.stock_name, stock_name: stock.stock_name,
stock_code: stock.stock_code, 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); addedStockCodes.add(stock.stock_code);
} }
@@ -773,10 +806,16 @@ export function generateMockEvents(params = {}) {
while (relatedStocks.length < relatedStockCount && poolIndex < stockPool.length) { while (relatedStocks.length < relatedStockCount && poolIndex < stockPool.length) {
const randomStock = stockPool[poolIndex % stockPool.length]; const randomStock = stockPool[poolIndex % stockPool.length];
if (!addedStockCodes.has(randomStock.stock_code)) { 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({ relatedStocks.push({
stock_name: randomStock.stock_name, stock_name: randomStock.stock_name,
stock_code: randomStock.stock_code, 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); addedStockCodes.add(randomStock.stock_code);
} }

View File

@@ -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 (
<div
style={{
width: 140,
height: 40,
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}
>
<ReactECharts
option={chartOption}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
</div>
);
}, (prevProps, nextProps) => {
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick;
});
export default MiniKLineChart;

View File

@@ -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<number>} 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 (
<Box width={`${width}px`} height={`${height}px`}>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<defs>
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
</linearGradient>
</defs>
{/* 填充区域 */}
<path
d={fillPathD}
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
/>
{/* 折线 */}
<path
d={pathD}
fill="none"
stroke={strokeColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 垂直分隔线(标记三个时间段) */}
{/* 前一天和当天之间 */}
<line
x1={width / 3}
y1={0}
x2={width / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
{/* 当天和后一天之间 */}
<line
x1={(width * 2) / 3}
y1={0}
x2={(width * 2) / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
</svg>
</Box>
);
};
export default MiniLineChart;

View File

@@ -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<Object>} 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 (
<Box>
<CollapsibleHeader
title="相关股票"
isOpen={isOpen}
onToggle={onToggle}
count={stocks.length}
/>
<Collapse in={isOpen} animateOpacity>
<Box mt={3}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</SimpleGrid>
</Box>
</Collapse>
</Box>
);
};
export default RelatedStocksSection;

View File

@@ -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';
};
// 获取涨跌幅数据(优先使用 quotefallback 到 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 (
<>
<Box
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
_hover={{
boxShadow: 'md',
borderColor: 'blue.300',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 顶部:股票代码 + 名称 + 操作按钮 */}
<Flex justify="space-between" align="center">
{/* 左侧:代码 + 名称 */}
<Flex align="baseline" gap={2}>
<Text
fontSize="md"
fontWeight="bold"
color={codeColor}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code}
</Text>
<Text fontSize="sm" color={nameColor}>
{stock.stock_name}
</Text>
<Text
fontSize="sm"
fontWeight="semibold"
color={getChangeColor(change)}
>
{formatChange(change)}
</Text>
</Flex>
{/* 右侧:操作按钮 */}
<Flex gap={2}>
{onWatchlistToggle && (
<IconButton
size="sm"
variant={isInWatchlist ? 'solid' : 'outline'}
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
icon={<StarIcon />}
onClick={handleWatchlistClick}
aria-label={isInWatchlist ? '已关注' : '加自选'}
title={isInWatchlist ? '已关注' : '加自选'}
/>
)}
<Button
size="sm"
colorScheme="blue"
onClick={handleViewDetail}
>
查看
</Button>
</Flex>
</Flex>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 分时图 & K线图 - 左右布局 */}
<Box>
<SimpleGrid columns={2} spacing={3}>
{/* 左侧:分时图 */}
<Box>
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
分时图
</Text>
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
{/* 右侧K线图 */}
<Box>
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
日K线
</Text>
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
</SimpleGrid>
</Box>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 关联描述 */}
{relationText && relationText !== '--' && (
<Box>
<Text fontSize="xs" color={descColor} mb={1}>
关联描述
</Text>
<Collapse in={isDescExpanded} startingHeight={40}>
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
{relationText}
</Text>
</Collapse>
{needTruncate && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={() => setIsDescExpanded(!isDescExpanded)}
mt={1}
>
{isDescExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
</VStack>
</Box>
{/* 股票详情弹窗 */}
<StockChartModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
stock={stock}
eventTime={eventTime}
size="6xl"
/>
</>
);
};
export default StockListItem;

View File

@@ -99,6 +99,30 @@ const Community = () => {
const fetchDynamicNews = async () => { const fetchDynamicNews = async () => {
setDynamicNewsLoading(true); setDynamicNewsLoading(true);
try { try {
// 检查是否使用 mock 模式
// 开发阶段默认使用 mock 数据
const useMock = true; // TODO: 生产环境改为环境变量控制
// const useMock = process.env.REACT_APP_USE_MOCK === 'true' ||
// localStorage.getItem('use_mock_data') === 'true';
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 {
// 使用真实 API
const timeRange = getCurrentTradingTimeRange(); const timeRange = getCurrentTradingTimeRange();
const response = await fetch( const response = await fetch(
`/api/events/dynamic-news?start_time=${timeRange.startTime.toISOString()}&end_time=${timeRange.endTime.toISOString()}&count=30`, `/api/events/dynamic-news?start_time=${timeRange.startTime.toISOString()}&end_time=${timeRange.endTime.toISOString()}&count=30`,
@@ -110,12 +134,14 @@ const Community = () => {
setDynamicNewsEvents(data.data); setDynamicNewsEvents(data.data);
logger.info('Community', '动态新闻加载成功', { logger.info('Community', '动态新闻加载成功', {
count: data.data.length, count: data.data.length,
timeRange: timeRange.description timeRange: timeRange.description,
mode: 'api'
}); });
} else { } else {
logger.warn('Community', '动态新闻加载失败', data); logger.warn('Community', '动态新闻加载失败', data);
setDynamicNewsEvents([]); setDynamicNewsEvents([]);
} }
}
} catch (error) { } catch (error) {
logger.error('Community', '动态新闻加载异常', error); logger.error('Community', '动态新闻加载异常', error);
setDynamicNewsEvents([]); setDynamicNewsEvents([]);