diff --git a/app.py b/app.py
index a4e83821..b3718e0f 100755
--- a/app.py
+++ b/app.py
@@ -5801,6 +5801,23 @@ def get_stock_quotes():
if not codes:
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
+ # 标准化股票代码(确保带后缀,用于 ClickHouse 查询)
+ def normalize_stock_code(code):
+ """将股票代码标准化为带后缀格式(如 300274.SZ)"""
+ if '.' in code:
+ return code # 已经带后缀
+ # 根据代码规则添加后缀:6/0/3开头为深圳,其他为上海
+ if code.startswith(('6',)):
+ return f"{code}.SH"
+ else:
+ return f"{code}.SZ"
+
+ # 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询
+ original_codes = codes
+ normalized_codes = [normalize_stock_code(code) for code in codes]
+ # 创建原始代码到标准化代码的映射
+ code_mapping = dict(zip(original_codes, normalized_codes))
+
# 处理事件时间
if event_time_str:
try:
@@ -5829,13 +5846,12 @@ def get_stock_quotes():
# 构建代码到名称的映射
base_name_map = {row[0]: row[1] for row in result}
- # 为每个完整代码(带后缀)分配名称
- for code in codes:
- base_code = code.split('.')[0]
- if base_code in base_name_map:
- stock_names[code] = base_name_map[base_code]
- else:
- stock_names[code] = f"股票{base_code}"
+ # 为原始代码和标准化代码都分配名称
+ for orig_code, norm_code in code_mapping.items():
+ base_code = orig_code.split('.')[0]
+ name = base_name_map.get(base_code, f"股票{base_code}")
+ stock_names[orig_code] = name
+ stock_names[norm_code] = name
def get_trading_day_and_times(event_datetime):
event_date = event_datetime.date()
@@ -5948,11 +5964,11 @@ def get_stock_quotes():
# 构建代码到收盘价的映射(需要匹配完整代码格式)
base_close_map = {row[0]: float(row[1]) if row[1] else None for row in prev_close_result}
- # 为每个完整代码(带后缀)分配收盘价
- for code in codes:
- base_code = code.split('.')[0]
+ # 为每个标准化代码(带后缀)分配收盘价,用于 ClickHouse 查询结果匹配
+ for norm_code in normalized_codes:
+ base_code = norm_code.split('.')[0]
if base_code in base_close_map:
- prev_close_map[code] = base_close_map[base_code]
+ prev_close_map[norm_code] = base_close_map[base_code]
print(f"前一交易日({prev_trading_day})收盘价查询返回 {len(prev_close_result)} 条数据")
@@ -5974,7 +5990,7 @@ def get_stock_quotes():
"""
batch_data = client.execute(batch_price_query, {
- 'codes': codes,
+ 'codes': normalized_codes, # 使用标准化后的代码查询 ClickHouse
'start': start_datetime,
'end': end_datetime
})
@@ -5998,41 +6014,43 @@ def get_stock_quotes():
'change': change_pct
}
- # 组装结果(所有股票)
- for code in codes:
- price_info = price_data_map.get(code)
+ # 组装结果(所有股票)- 使用原始代码作为 key 返回
+ for orig_code in original_codes:
+ norm_code = code_mapping[orig_code]
+ price_info = price_data_map.get(norm_code)
if price_info:
- results[code] = {
+ results[orig_code] = {
'price': price_info['price'],
'change': price_info['change'],
- 'name': stock_names.get(code, f'股票{code.split(".")[0]}')
+ 'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}'))
}
else:
# 批量查询没有返回的股票
- results[code] = {
+ results[orig_code] = {
'price': None,
'change': None,
- 'name': stock_names.get(code, f'股票{code.split(".")[0]}')
+ 'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}'))
}
except Exception as e:
print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询")
# 降级方案:逐只股票查询(使用前一交易日收盘价计算涨跌幅)
- for code in codes:
+ for orig_code in original_codes:
+ norm_code = code_mapping[orig_code]
try:
- # 查询当前价格
+ # 查询当前价格(使用标准化代码查询 ClickHouse)
current_data = client.execute("""
SELECT close FROM stock_minute
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
ORDER BY timestamp DESC LIMIT 1
- """, {'code': code, 'start': start_datetime, 'end': end_datetime})
+ """, {'code': norm_code, 'start': start_datetime, 'end': end_datetime})
last_price = float(current_data[0][0]) if current_data and current_data[0] and current_data[0][0] else None
# 从 MySQL ea_trade 表查询前一交易日收盘价
prev_close = None
if prev_trading_day and last_price is not None:
- base_code = code.split('.')[0]
+ base_code = orig_code.split('.')[0]
with engine.connect() as conn:
prev_result = conn.execute(text("""
SELECT F007N as close_price
@@ -6046,14 +6064,15 @@ def get_stock_quotes():
if last_price is not None and prev_close is not None and prev_close > 0:
change_pct = (last_price - prev_close) / prev_close * 100
- results[code] = {
+ # 使用原始代码作为 key 返回
+ results[orig_code] = {
'price': last_price,
'change': change_pct,
- 'name': stock_names.get(code, f'股票{code.split(".")[0]}')
+ 'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')
}
except Exception as inner_e:
- print(f"Error processing stock {code}: {inner_e}")
- results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
+ print(f"Error processing stock {orig_code}: {inner_e}")
+ results[orig_code] = {'price': None, 'change': None, 'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')}
# 返回标准格式
return jsonify({'success': True, 'data': results})
@@ -6303,6 +6322,23 @@ def get_batch_kline_data():
if len(codes) > 50:
return jsonify({'success': False, 'error': '单次最多查询50只股票'}), 400
+ # 标准化股票代码(确保带后缀,用于 ClickHouse 查询)
+ def normalize_stock_code(code):
+ """将股票代码标准化为带后缀格式(如 300274.SZ)"""
+ if '.' in code:
+ return code # 已经带后缀
+ # 根据代码规则添加后缀:6开头为上海,其他为深圳
+ if code.startswith(('6',)):
+ return f"{code}.SH"
+ else:
+ return f"{code}.SZ"
+
+ # 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询
+ original_codes = codes
+ normalized_codes = [normalize_stock_code(code) for code in codes]
+ code_mapping = dict(zip(original_codes, normalized_codes))
+ reverse_mapping = dict(zip(normalized_codes, original_codes))
+
try:
event_datetime = datetime.fromisoformat(event_time) if event_time else datetime.now()
except ValueError:
@@ -6333,10 +6369,10 @@ def get_batch_kline_data():
target_date = next_trade_date
if not target_date:
- # 返回空数据
+ # 返回空数据(使用原始代码作为 key)
return jsonify({
'success': True,
- 'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in codes}
+ 'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in original_codes}
})
start_time = datetime.combine(target_date, dt_time(9, 30))
@@ -6345,7 +6381,7 @@ def get_batch_kline_data():
results = {}
if chart_type == 'timeline':
- # 批量查询分时数据
+ # 批量查询分时数据(使用标准化代码查询 ClickHouse)
batch_data = client.execute("""
SELECT code, timestamp, close, volume
FROM stock_minute
@@ -6353,31 +6389,32 @@ def get_batch_kline_data():
AND timestamp BETWEEN %(start)s AND %(end)s
ORDER BY code, timestamp
""", {
- 'codes': codes,
+ 'codes': normalized_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({
+ norm_code = row[0]
+ if norm_code not in stock_data:
+ stock_data[norm_code] = []
+ stock_data[norm_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]
+ # 组装结果(使用原始代码作为 key 返回)
+ for orig_code in original_codes:
+ norm_code = code_mapping[orig_code]
+ base_code = orig_code.split('.')[0]
stock_name = stock_names.get(base_code, f'股票{base_code}')
- data_list = stock_data.get(code, [])
+ data_list = stock_data.get(norm_code, [])
- results[code] = {
- 'code': code,
+ results[orig_code] = {
+ 'code': orig_code,
'name': stock_name,
'data': data_list,
'trade_date': target_date.strftime('%Y-%m-%d'),
@@ -6417,14 +6454,14 @@ def get_batch_kline_data():
'volume': float(row[6]) if row[6] else 0
})
- # 组装结果
- for code in codes:
- base_code = code.split('.')[0]
+ # 组装结果(使用原始代码作为 key 返回)
+ for orig_code in original_codes:
+ base_code = orig_code.split('.')[0]
stock_name = stock_names.get(base_code, f'股票{base_code}')
data_list = stock_data.get(base_code, [])
- results[code] = {
- 'code': code,
+ results[orig_code] = {
+ 'code': orig_code,
'name': stock_name,
'data': data_list,
'trade_date': target_date.strftime('%Y-%m-%d'),
diff --git a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
index e517188d..161a9980 100644
--- a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
+++ b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
@@ -17,9 +17,11 @@ import {
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
+ * @param {Array} preloadedData - 预加载的K线数据(可选,由父组件批量加载后传入)
+ * @param {boolean} loading - 外部加载状态(可选)
* @returns {JSX.Element}
*/
-const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick }) {
+const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
@@ -65,6 +67,15 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
return;
}
+ // 优先使用预加载的数据(由父组件批量请求后传入)
+ if (preloadedData !== undefined) {
+ setData(preloadedData || []);
+ setLoading(false);
+ loadedRef.current = true;
+ dataFetchedRef.current = true;
+ return;
+ }
+
// 如果已经请求过数据,不再重复请求
if (dataFetchedRef.current) {
return;
@@ -75,6 +86,12 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
return;
}
+ // 如果外部正在加载,等待外部加载完成
+ if (externalLoading) {
+ setLoading(true);
+ return;
+ }
+
// 检查批量请求的函数
const checkBatchAndLoad = () => {
// 再次检查缓存(批量请求可能已完成)
@@ -119,7 +136,6 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
}
// 延迟一小段时间再检查(等待批量请求启动)
- // 因为 StockTable 的 useEffect 可能还没执行
setLoading(true);
const timeoutId = setTimeout(() => {
if (!mountedRef.current || dataFetchedRef.current) return;
@@ -129,7 +145,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
return;
}
- // 仍然没有批量请求,发起单独请求
+ // 仍然没有批量请求,发起单独请求(备用方案)
dataFetchedRef.current = true;
fetchKlineData(stockCode, stableEventTime)
@@ -147,10 +163,10 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
loadedRef.current = true;
}
});
- }, 50); // 延迟 50ms 等待批量请求启动
+ }, 100); // 延迟 100ms 等待批量请求
return () => clearTimeout(timeoutId);
- }, [stockCode, stableEventTime, loadData]); // 注意这里使用 stableEventTime
+ }, [stockCode, stableEventTime, loadData, preloadedData, externalLoading]); // 添加 preloadedData 和 externalLoading 依赖
const chartOption = useMemo(() => {
const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number');
@@ -249,10 +265,12 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
);
}, (prevProps, nextProps) => {
- // 自定义比较函数,只有当stockCode、eventTime或onClick变化时才重新渲染
+ // 自定义比较函数
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime &&
- prevProps.onClick === nextProps.onClick;
+ prevProps.onClick === nextProps.onClick &&
+ prevProps.preloadedData === nextProps.preloadedData &&
+ prevProps.loading === nextProps.loading;
});
export default MiniTimelineChart;
diff --git a/src/views/Community/components/StockDetailPanel/components/StockTable.js b/src/views/Community/components/StockDetailPanel/components/StockTable.js
index d272ea2f..1669c647 100644
--- a/src/views/Community/components/StockDetailPanel/components/StockTable.js
+++ b/src/views/Community/components/StockDetailPanel/components/StockTable.js
@@ -4,7 +4,7 @@ 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 { fetchBatchKlineData, klineDataCache, getCacheKey } from '../utils/klineDataCache';
import { logger } from '../../../../../utils/logger';
/**
@@ -29,27 +29,71 @@ const StockTable = ({
}) => {
// 展开/收缩的行
const [expandedRows, setExpandedRows] = useState(new Set());
+ // K线数据状态:{ [stockCode]: data[] }
+ const [klineDataMap, setKlineDataMap] = useState({});
+ const [klineLoading, setKlineLoading] = useState(false);
// 稳定的事件时间,避免重复渲染
const stableEventTime = useMemo(() => {
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
- // 批量预加载K线数据
- // 使用 stocks 的 JSON 字符串作为依赖项的 key,避免引用变化导致重复预加载
+ // 批量加载K线数据
+ // 使用 stocks 的 JSON 字符串作为依赖项的 key,避免引用变化导致重复加载
const stocksKey = useMemo(() => {
return stocks.map(s => s.stock_code).sort().join(',');
}, [stocks]);
useEffect(() => {
- if (stocks.length > 0) {
- const stockCodes = stocks.map(s => s.stock_code);
- logger.debug('StockTable', '批量预加载K线数据', {
- stockCount: stockCodes.length,
- eventTime: stableEventTime
- });
- preloadBatchKlineData(stockCodes, stableEventTime, 'timeline');
+ if (stocks.length === 0) {
+ setKlineDataMap({});
+ return;
}
+
+ const stockCodes = stocks.map(s => s.stock_code);
+
+ // 先检查缓存,只请求未缓存的
+ const cachedData = {};
+ const uncachedCodes = [];
+ stockCodes.forEach(code => {
+ const cacheKey = getCacheKey(code, stableEventTime, 'timeline');
+ const cached = klineDataCache.get(cacheKey);
+ if (cached !== undefined) {
+ cachedData[code] = cached;
+ } else {
+ uncachedCodes.push(code);
+ }
+ });
+
+ // 如果全部缓存命中,直接使用
+ if (uncachedCodes.length === 0) {
+ setKlineDataMap(cachedData);
+ logger.debug('StockTable', 'K线数据全部来自缓存', { stockCount: stockCodes.length });
+ return;
+ }
+
+ logger.debug('StockTable', '批量加载K线数据', {
+ totalCount: stockCodes.length,
+ cachedCount: Object.keys(cachedData).length,
+ uncachedCount: uncachedCodes.length,
+ eventTime: stableEventTime
+ });
+
+ setKlineLoading(true);
+
+ // 批量请求未缓存的数据
+ fetchBatchKlineData(stockCodes, stableEventTime, 'timeline')
+ .then((batchData) => {
+ // 合并缓存数据和新数据
+ setKlineDataMap({ ...cachedData, ...batchData });
+ setKlineLoading(false);
+ })
+ .catch((error) => {
+ logger.error('StockTable', '批量加载K线数据失败', error);
+ // 失败时使用已有的缓存数据
+ setKlineDataMap(cachedData);
+ setKlineLoading(false);
+ });
}, [stocksKey, stableEventTime]); // 使用 stocksKey 而非 stocks 对象引用
// 切换行展开状态
@@ -175,6 +219,8 @@ const StockTable = ({