diff --git a/app.py b/app.py index f023aab3..a4e83821 100755 --- a/app.py +++ b/app.py @@ -6285,6 +6285,164 @@ def get_stock_kline(stock_code): return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400 +@app.route('/api/stock/batch-kline', methods=['POST']) +def get_batch_kline_data(): + """批量获取多只股票的K线/分时数据 + 请求体:{ codes: string[], type: 'timeline'|'daily', event_time?: string } + 返回:{ success: true, data: { [code]: { data: [], trade_date: '', ... } } } + """ + try: + data = request.json + codes = data.get('codes', []) + chart_type = data.get('type', 'timeline') + event_time = data.get('event_time') + + if not codes: + return jsonify({'success': False, 'error': '请提供股票代码列表'}), 400 + + if len(codes) > 50: + return jsonify({'success': False, 'error': '单次最多查询50只股票'}), 400 + + try: + event_datetime = datetime.fromisoformat(event_time) if event_time else datetime.now() + except ValueError: + return jsonify({'success': False, 'error': 'Invalid event_time format'}), 400 + + client = get_clickhouse_client() + + # 批量获取股票名称 + stock_names = {} + with engine.connect() as conn: + base_codes = list(set([code.split('.')[0] for code in codes])) + if base_codes: + placeholders = ','.join([f':code{i}' for i in range(len(base_codes))]) + params = {f'code{i}': code for i, code in enumerate(base_codes)} + result = conn.execute(text( + f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})" + ), params).fetchall() + for row in result: + stock_names[row[0]] = row[1] + + # 确定目标交易日 + target_date = get_trading_day_near_date(event_datetime.date()) + is_after_market = event_datetime.time() > dt_time(15, 0) + + if target_date and is_after_market: + next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1)) + if next_trade_date: + target_date = next_trade_date + + if not target_date: + # 返回空数据 + return jsonify({ + 'success': True, + 'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in codes} + }) + + start_time = datetime.combine(target_date, dt_time(9, 30)) + end_time = datetime.combine(target_date, dt_time(15, 0)) + + results = {} + + if chart_type == 'timeline': + # 批量查询分时数据 + batch_data = client.execute(""" + SELECT code, timestamp, close, volume + FROM stock_minute + WHERE code IN %(codes)s + AND timestamp BETWEEN %(start)s AND %(end)s + ORDER BY code, timestamp + """, { + 'codes': codes, + 'start': start_time, + 'end': end_time + }) + + # 按股票代码分组 + stock_data = {} + for row in batch_data: + code = row[0] + if code not in stock_data: + stock_data[code] = [] + stock_data[code].append({ + 'time': row[1].strftime('%H:%M'), + 'price': float(row[2]), + 'volume': float(row[3]) + }) + + # 组装结果 + for code in codes: + base_code = code.split('.')[0] + stock_name = stock_names.get(base_code, f'股票{base_code}') + data_list = stock_data.get(code, []) + + results[code] = { + 'code': code, + 'name': stock_name, + 'data': data_list, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'timeline' + } + + elif chart_type == 'daily': + # 批量查询日线数据(从MySQL ea_trade表) + with engine.connect() as conn: + base_codes = list(set([code.split('.')[0] for code in codes])) + if base_codes: + placeholders = ','.join([f':code{i}' for i in range(len(base_codes))]) + params = {f'code{i}': code for i, code in enumerate(base_codes)} + params['start_date'] = target_date - timedelta(days=60) + params['end_date'] = target_date + + daily_result = conn.execute(text(f""" + SELECT SECCODE, TRADEDATE, F003N as open, F005N as high, F006N as low, F007N as close, F004N as volume + FROM ea_trade + WHERE SECCODE IN ({placeholders}) + AND TRADEDATE BETWEEN :start_date AND :end_date + ORDER BY SECCODE, TRADEDATE + """), params).fetchall() + + # 按股票代码分组 + stock_data = {} + for row in daily_result: + code_base = row[0] + if code_base not in stock_data: + stock_data[code_base] = [] + stock_data[code_base].append({ + 'date': row[1].strftime('%Y-%m-%d') if hasattr(row[1], 'strftime') else str(row[1]), + 'open': float(row[2]) if row[2] else 0, + 'high': float(row[3]) if row[3] else 0, + 'low': float(row[4]) if row[4] else 0, + 'close': float(row[5]) if row[5] else 0, + 'volume': float(row[6]) if row[6] else 0 + }) + + # 组装结果 + for code in codes: + base_code = code.split('.')[0] + stock_name = stock_names.get(base_code, f'股票{base_code}') + data_list = stock_data.get(base_code, []) + + results[code] = { + 'code': code, + 'name': stock_name, + 'data': data_list, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'daily' + } + + print(f"批量K线查询完成: {len(codes)} 只股票, 类型: {chart_type}, 交易日: {target_date}") + + return jsonify({ + 'success': True, + 'data': results + }) + + except Exception as e: + print(f"批量K线查询错误: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/stock//latest-minute', methods=['GET']) def get_latest_minute_data(stock_code): """获取最新交易日的分钟频数据""" diff --git a/src/services/eventService.js b/src/services/eventService.js index 6f577cea..7c9b8fc4 100755 --- a/src/services/eventService.js +++ b/src/services/eventService.js @@ -358,6 +358,37 @@ export const stockService = { throw error; } }, + + /** + * 批量获取多只股票的K线数据 + * @param {string[]} stockCodes - 股票代码数组 + * @param {string} chartType - 图表类型 (timeline/daily) + * @param {string} eventTime - 事件时间 + * @returns {Promise} { [stockCode]: data[] } + */ + getBatchKlineData: async (stockCodes, chartType = 'timeline', eventTime = null) => { + try { + const requestBody = { + codes: stockCodes, + type: chartType + }; + if (eventTime) { + requestBody.event_time = eventTime; + } + + logger.debug('stockService', '批量获取K线数据', { stockCount: stockCodes.length, chartType, eventTime }); + + const response = await apiRequest('/api/stock/batch-kline', { + method: 'POST', + body: JSON.stringify(requestBody) + }); + + return response; + } catch (error) { + logger.error('stockService', 'getBatchKlineData', error, { stockCodes, chartType }); + throw error; + } + }, getTransmissionChainAnalysis: async (eventId) => { return await apiRequest(`/api/events/${eventId}/transmission`); }, diff --git a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js index 7724a5c4..686b7e1e 100644 --- a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js +++ b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js @@ -122,8 +122,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { const canAccessTransmission = hasAccess('max'); // 子区块折叠状态管理 + 加载追踪 - // 相关股票默认折叠,只显示数量吸引点击 - const [isStocksOpen, setIsStocksOpen] = useState(false); + // 相关股票默认展开 + const [isStocksOpen, setIsStocksOpen] = useState(true); const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量) const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载 diff --git a/src/views/Community/components/EventCard/EventPriceDisplay.js b/src/views/Community/components/EventCard/EventPriceDisplay.js index 54371cec..85ea0a48 100644 --- a/src/views/Community/components/EventCard/EventPriceDisplay.js +++ b/src/views/Community/components/EventCard/EventPriceDisplay.js @@ -1,6 +1,6 @@ // src/views/Community/components/EventCard/EventPriceDisplay.js -import React from 'react'; -import { HStack, Badge, Text, Tooltip } from '@chakra-ui/react'; +import React, { useState } from 'react'; +import { HStack, Box, Text, Tooltip, Progress } from '@chakra-ui/react'; import { PriceArrow } from '../../../../utils/priceFormatters'; /** @@ -8,17 +8,20 @@ import { PriceArrow } from '../../../../utils/priceFormatters'; * @param {Object} props * @param {number|null} props.avgChange - 平均涨跌幅 * @param {number|null} props.maxChange - 最大涨跌幅 - * @param {number|null} props.weekChange - 周涨跌幅 + * @param {number|null} props.expectationScore - 超预期得分(满分100) * @param {boolean} props.compact - 是否为紧凑模式(只显示平均值,默认 false) * @param {boolean} props.inline - 是否内联显示(默认 false) */ const EventPriceDisplay = ({ avgChange, maxChange, - weekChange, + expectationScore, compact = false, inline = false }) => { + // 点击切换显示最大超额/平均超额 + const [showAvg, setShowAvg] = useState(false); + // 获取颜色方案 const getColorScheme = (value) => { if (value == null) return 'gray'; @@ -31,12 +34,23 @@ const EventPriceDisplay = ({ return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`; }; + // 获取超预期得分的颜色(渐变色系) + const getScoreColor = (score) => { + if (score == null) return { bg: 'gray.100', color: 'gray.500', progressColor: 'gray' }; + if (score >= 80) return { bg: 'red.50', color: 'red.600', progressColor: 'red' }; + if (score >= 60) return { bg: 'orange.50', color: 'orange.600', progressColor: 'orange' }; + if (score >= 40) return { bg: 'yellow.50', color: 'yellow.700', progressColor: 'yellow' }; + if (score >= 20) return { bg: 'blue.50', color: 'blue.600', progressColor: 'blue' }; + return { bg: 'gray.50', color: 'gray.600', progressColor: 'gray' }; + }; + // 紧凑模式:只显示平均值,内联在标题后 if (compact && avgChange != null) { return ( - - + 0 ? 'red.50' : avgChange < 0 ? 'green.50' : 'gray.100'} + color={avgChange > 0 ? 'red.600' : avgChange < 0 ? 'green.600' : 'gray.500'} fontSize="xs" px={2} py={1} @@ -49,71 +63,91 @@ const EventPriceDisplay = ({ > {formatPercent(avgChange)} - + ); } - // 详细模式:显示所有价格变动 + const displayValue = showAvg ? avgChange : maxChange; + const displayLabel = showAvg ? '平均超额' : '最大超额'; + const scoreColors = getScoreColor(expectationScore); + + // 详细模式:显示最大超额(可点击切换)+ 超预期得分 return ( - - {/* 平均涨幅 - 始终显示,无数据时显示 -- */} - + {/* 最大超额/平均超额 - 点击切换 */} + - - 平均 - - {formatPercent(avgChange)} - - - + 0 ? 'red.50' : displayValue < 0 ? 'green.50' : 'gray.100'} + color={displayValue > 0 ? 'red.600' : displayValue < 0 ? 'green.600' : 'gray.500'} + fontSize="xs" + px={2.5} + py={1} + borderRadius="md" + cursor="pointer" + onClick={(e) => { + e.stopPropagation(); + setShowAvg(!showAvg); + }} + _hover={{ + transform: 'scale(1.02)', + boxShadow: 'sm', + opacity: 0.9 + }} + transition="all 0.2s" + border="1px solid" + borderColor={displayValue > 0 ? 'red.200' : displayValue < 0 ? 'green.200' : 'gray.200'} + > + + {displayLabel} + + {formatPercent(displayValue)} + + + + - {/* 最大涨幅 - 始终显示,无数据时显示 -- */} - - - 最大 - - {formatPercent(maxChange)} - - - - - {/* 周涨幅 - 始终显示,无数据时显示 -- */} - - - - {weekChange != null && } - - {formatPercent(weekChange)} - - - + {/* 超预期得分 - 精致的进度条样式 */} + {expectationScore != null && ( + + + + + 超预期 + + + + + + {expectationScore.toFixed(0)} + + + + + )} ); }; diff --git a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js index b2b55d04..862ba8d8 100644 --- a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js +++ b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js @@ -1,12 +1,13 @@ // src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import ReactECharts from 'echarts-for-react'; import * as echarts from 'echarts'; import dayjs from 'dayjs'; import { fetchKlineData, getCacheKey, - klineDataCache + klineDataCache, + batchPendingRequests } from '../utils/klineDataCache'; /** @@ -37,6 +38,25 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve }; }, []); + // 从缓存或API获取数据的函数 + const loadData = useCallback(() => { + if (!stockCode || !mountedRef.current) return; + + // 检查缓存 + const cacheKey = getCacheKey(stockCode, stableEventTime); + const cachedData = klineDataCache.get(cacheKey); + + // 如果有缓存数据,直接使用 + if (cachedData && cachedData.length > 0) { + setData(cachedData); + setLoading(false); + loadedRef.current = true; + dataFetchedRef.current = true; + return true; // 表示数据已加载 + } + return false; // 表示需要请求 + }, [stockCode, stableEventTime]); + useEffect(() => { if (!stockCode) { setData([]); @@ -50,19 +70,34 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve return; } - // 检查缓存 - const cacheKey = getCacheKey(stockCode, stableEventTime); - const cachedData = klineDataCache.get(cacheKey); - - // 如果有缓存数据,直接使用 - if (cachedData && cachedData.length > 0) { - setData(cachedData); - loadedRef.current = true; - dataFetchedRef.current = true; + // 尝试从缓存加载 + if (loadData()) { return; } - // 标记正在请求 + // 检查是否有正在进行的批量请求 + const batchKey = `${stableEventTime || 'today'}|timeline`; + const pendingBatch = batchPendingRequests.get(batchKey); + + if (pendingBatch) { + // 等待批量请求完成后再从缓存读取 + setLoading(true); + dataFetchedRef.current = true; + pendingBatch.then(() => { + if (mountedRef.current) { + loadData(); + setLoading(false); + } + }).catch(() => { + if (mountedRef.current) { + setData([]); + setLoading(false); + } + }); + return; + } + + // 没有批量请求,发起单独请求 dataFetchedRef.current = true; setLoading(true); @@ -82,7 +117,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve loadedRef.current = true; } }); - }, [stockCode, stableEventTime]); // 注意这里使用 stableEventTime + }, [stockCode, stableEventTime, loadData]); // 注意这里使用 stableEventTime const chartOption = useMemo(() => { const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number'); diff --git a/src/views/Community/components/StockDetailPanel/components/StockTable.js b/src/views/Community/components/StockDetailPanel/components/StockTable.js index 3edd9ca9..c5319160 100644 --- a/src/views/Community/components/StockDetailPanel/components/StockTable.js +++ b/src/views/Community/components/StockDetailPanel/components/StockTable.js @@ -1,9 +1,10 @@ // src/views/Community/components/StockDetailPanel/components/StockTable.js -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { Table, Button } from 'antd'; import { StarFilled, StarOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import MiniTimelineChart from './MiniTimelineChart'; +import { preloadBatchKlineData } from '../utils/klineDataCache'; import { logger } from '../../../../../utils/logger'; /** @@ -28,12 +29,31 @@ const StockTable = ({ }) => { // 展开/收缩的行 const [expandedRows, setExpandedRows] = useState(new Set()); + const preloadedRef = useRef(false); // 标记是否已预加载 // 稳定的事件时间,避免重复渲染 const stableEventTime = useMemo(() => { return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : ''; }, [eventTime]); + // 批量预加载K线数据 + useEffect(() => { + if (stocks.length > 0 && !preloadedRef.current) { + const stockCodes = stocks.map(s => s.stock_code); + logger.debug('StockTable', '批量预加载K线数据', { + stockCount: stockCodes.length, + eventTime: stableEventTime + }); + preloadBatchKlineData(stockCodes, stableEventTime, 'timeline'); + preloadedRef.current = true; + } + }, [stocks, stableEventTime]); + + // 当股票列表变化时重置预加载标记 + useEffect(() => { + preloadedRef.current = false; + }, [stocks.length]); + // 切换行展开状态 const toggleRowExpand = useCallback((stockCode) => { setExpandedRows(prev => { diff --git a/src/views/Community/components/StockDetailPanel/utils/klineDataCache.js b/src/views/Community/components/StockDetailPanel/utils/klineDataCache.js index 55e42c5c..efa023ab 100644 --- a/src/views/Community/components/StockDetailPanel/utils/klineDataCache.js +++ b/src/views/Community/components/StockDetailPanel/utils/klineDataCache.js @@ -4,9 +4,10 @@ import { stockService } from '../../../../../services/eventService'; import { logger } from '../../../../../utils/logger'; // ================= 全局缓存和请求管理 ================= -export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data -export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise -export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp +export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}|${chartType}` -> data +export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}|${chartType}` -> Promise +export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}|${chartType}` -> timestamp +export const batchPendingRequests = new Map(); // 批量请求的 Promise: key = `${eventTime}|${chartType}` -> Promise // 请求间隔限制(毫秒) const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据 @@ -157,3 +158,131 @@ export const getCacheStats = () => { cacheKeys: Array.from(klineDataCache.keys()) }; }; + +/** + * 批量获取多只股票的K线数据(一次API请求) + * @param {string[]} stockCodes - 股票代码数组 + * @param {string} eventTime - 事件时间 + * @param {string} chartType - 图表类型(timeline/daily) + * @returns {Promise} 股票代码到K线数据的映射 { [stockCode]: data[] } + */ +export const fetchBatchKlineData = async (stockCodes, eventTime, chartType = 'timeline') => { + if (!stockCodes || stockCodes.length === 0) { + return {}; + } + + const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined; + const batchKey = `${normalizedEventTime || 'today'}|${chartType}`; + + // 过滤出未缓存的股票 + const uncachedCodes = stockCodes.filter(code => { + const cacheKey = getCacheKey(code, eventTime, chartType); + return !klineDataCache.has(cacheKey) || shouldRefreshData(cacheKey); + }); + + logger.debug('klineDataCache', '批量请求分析', { + totalCodes: stockCodes.length, + uncachedCodes: uncachedCodes.length, + cachedCodes: stockCodes.length - uncachedCodes.length + }); + + // 如果所有股票都有缓存,直接返回缓存数据 + if (uncachedCodes.length === 0) { + const result = {}; + stockCodes.forEach(code => { + const cacheKey = getCacheKey(code, eventTime, chartType); + result[code] = klineDataCache.get(cacheKey) || []; + }); + logger.debug('klineDataCache', '所有股票数据来自缓存', { stockCount: stockCodes.length }); + return result; + } + + // 检查是否有正在进行的批量请求 + if (batchPendingRequests.has(batchKey)) { + logger.debug('klineDataCache', '等待进行中的批量请求', { batchKey }); + return batchPendingRequests.get(batchKey); + } + + // 发起批量请求 + logger.debug('klineDataCache', '发起批量K线数据请求', { + batchKey, + stockCount: uncachedCodes.length, + chartType + }); + + const requestPromise = stockService + .getBatchKlineData(uncachedCodes, chartType, normalizedEventTime) + .then((response) => { + const batchData = response?.data || {}; + const now = Date.now(); + + // 将批量数据存入缓存 + Object.entries(batchData).forEach(([code, stockData]) => { + const data = Array.isArray(stockData?.data) ? stockData.data : []; + const cacheKey = getCacheKey(code, eventTime, chartType); + klineDataCache.set(cacheKey, data); + lastRequestTime.set(cacheKey, now); + }); + + // 对于请求中没有返回数据的股票,设置空数组 + uncachedCodes.forEach(code => { + if (!batchData[code]) { + const cacheKey = getCacheKey(code, eventTime, chartType); + if (!klineDataCache.has(cacheKey)) { + klineDataCache.set(cacheKey, []); + lastRequestTime.set(cacheKey, now); + } + } + }); + + // 清除批量请求状态 + batchPendingRequests.delete(batchKey); + + logger.debug('klineDataCache', '批量K线数据请求完成', { + batchKey, + stockCount: Object.keys(batchData).length + }); + + // 返回所有请求股票的数据(包括之前缓存的) + const result = {}; + stockCodes.forEach(code => { + const cacheKey = getCacheKey(code, eventTime, chartType); + result[code] = klineDataCache.get(cacheKey) || []; + }); + return result; + }) + .catch((error) => { + logger.error('klineDataCache', 'fetchBatchKlineData', error, { + stockCount: uncachedCodes.length, + chartType + }); + // 清除批量请求状态 + batchPendingRequests.delete(batchKey); + + // 返回已缓存的数据 + const result = {}; + stockCodes.forEach(code => { + const cacheKey = getCacheKey(code, eventTime, chartType); + result[code] = klineDataCache.get(cacheKey) || []; + }); + return result; + }); + + // 保存批量请求 + batchPendingRequests.set(batchKey, requestPromise); + + return requestPromise; +}; + +/** + * 预加载多只股票的K线数据(后台执行,不阻塞UI) + * @param {string[]} stockCodes - 股票代码数组 + * @param {string} eventTime - 事件时间 + * @param {string} chartType - 图表类型(timeline/daily) + */ +export const preloadBatchKlineData = (stockCodes, eventTime, chartType = 'timeline') => { + // 异步执行,不返回Promise,不阻塞调用方 + fetchBatchKlineData(stockCodes, eventTime, chartType).catch(() => { + // 静默处理错误,预加载失败不影响用户体验 + }); +};