feat: 添加相关股票模块
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<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;
|
||||||
@@ -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([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user