From b2bfcd34826eef963e56ec6d7451b42d0d12badf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=B7=E5=B0=8F=E5=89=8D?= Date: Tue, 20 Jan 2026 17:29:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0ios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MeAgent/src/screens/Concepts/ConceptList.js | 12 +- MeAgent/src/screens/Events/EventDetail.js | 1 + MeAgent/src/screens/Events/MiniChart.js | 279 ++++++++++++++++++ MeAgent/src/screens/Events/RelatedStocks.js | 202 +++++++------ MeAgent/src/screens/Market/EventCalendar.js | 153 +++++----- .../screens/StockDetail/StockDetailScreen.js | 19 ++ .../StockDetail/components/PriceHeader.js | 3 +- .../screens/Watchlist/AddWatchlistModal.js | 84 +++++- MeAgent/src/services/websocketService.js | 47 ++- MeAgent/src/services/ztService.js | 6 +- MeAgent/src/store/slices/eventsSlice.js | 6 +- app.py | 192 ++++++++++-- 12 files changed, 783 insertions(+), 221 deletions(-) create mode 100644 MeAgent/src/screens/Events/MiniChart.js diff --git a/MeAgent/src/screens/Concepts/ConceptList.js b/MeAgent/src/screens/Concepts/ConceptList.js index 8fc22d12..338c6fa0 100644 --- a/MeAgent/src/screens/Concepts/ConceptList.js +++ b/MeAgent/src/screens/Concepts/ConceptList.js @@ -788,12 +788,16 @@ const ConceptList = () => { setError(null); try { - await fetchHierarchy(); - await fetchPriceData(); - // 如果当前是列表模式,加载概念列表 + // 并行请求优化:三个请求相互独立,可以同时发起 + const requests = [ + fetchHierarchy(), + fetchPriceData(), + ]; + // 如果当前是列表模式,同时加载概念列表 if (viewMode === 'list') { - await searchConcepts(searchQuery, 1, false); + requests.push(searchConcepts(searchQuery, 1, false)); } + await Promise.all(requests); } catch (err) { setError(err.message); } finally { diff --git a/MeAgent/src/screens/Events/EventDetail.js b/MeAgent/src/screens/Events/EventDetail.js index 979c272f..91f01463 100644 --- a/MeAgent/src/screens/Events/EventDetail.js +++ b/MeAgent/src/screens/Events/EventDetail.js @@ -356,6 +356,7 @@ const EventDetail = ({ route, navigation }) => { onStockPress={handleStockPress} showAll={showAllStocks} onShowAll={() => setShowAllStocks(true)} + eventTime={currentEvent?.event_time || currentEvent?.created_at} /> {/* 市场影响卡片 */} diff --git a/MeAgent/src/screens/Events/MiniChart.js b/MeAgent/src/screens/Events/MiniChart.js new file mode 100644 index 00000000..055814fe --- /dev/null +++ b/MeAgent/src/screens/Events/MiniChart.js @@ -0,0 +1,279 @@ +/** + * 迷你分时图组件 + * 用于事件详情页面的相关股票列表 + * 显示分时走势线和事件发生时间的金色竖线 + */ + +import React, { memo, useMemo } from 'react'; +import { Box, Text, HStack, Spinner } from 'native-base'; +import Svg, { + Path, + Line, + Rect, + Defs, + LinearGradient, + Stop, +} from 'react-native-svg'; + +// 图表尺寸常量 +const CHART_WIDTH = 90; +const CHART_HEIGHT = 36; +const PADDING = { top: 2, right: 2, bottom: 2, left: 2 }; + +// 将时间字符串转换为分钟数(用于 X 轴计算) +// A股交易时间:9:30-11:30(120分钟)+ 13:00-15:00(120分钟)= 总共240分钟 +const timeToMinutes = (timeStr) => { + if (!timeStr) return 0; + + // 处理不同格式:HH:mm 或 HH:mm:ss + const timePart = timeStr.substring(0, 5); + const [hours, minutes] = timePart.split(':').map(Number); + const totalMinutes = hours * 60 + minutes; + + // 上午时段:9:30-11:30 -> 0-120 + if (totalMinutes >= 570 && totalMinutes <= 690) { + return totalMinutes - 570; + } + // 下午时段:13:00-15:00 -> 120-240 + if (totalMinutes >= 780 && totalMinutes <= 900) { + return 120 + (totalMinutes - 780); + } + // 午休时间,返回上午收盘位置 + if (totalMinutes > 690 && totalMinutes < 780) { + return 120; + } + return 0; +}; + +// 从完整日期时间中提取时间部分 +const extractTime = (dateTimeStr) => { + if (!dateTimeStr) return null; + // 处理格式: "2024-01-15 10:30:00" 或 "2024-01-15T10:30:00" + const match = dateTimeStr.match(/(\d{2}:\d{2})/); + return match ? match[1] : null; +}; + +// 总交易分钟数 +const TOTAL_TRADING_MINUTES = 240; + +/** + * 生成折线路径 + */ +const generateLinePath = (points) => { + if (!points || points.length === 0) return ''; + let path = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + path += ` L ${points[i].x} ${points[i].y}`; + } + return path; +}; + +/** + * 生成填充区域路径 + */ +const generateAreaPath = (points, bottomY) => { + if (!points || points.length === 0) return ''; + let path = `M ${points[0].x} ${bottomY}`; + path += ` L ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + path += ` L ${points[i].x} ${points[i].y}`; + } + path += ` L ${points[points.length - 1].x} ${bottomY}`; + path += ' Z'; + return path; +}; + +/** + * 迷你分时图组件 + * @param {object} props + * @param {Array} props.data - 分时数据 [{time, price, ...}] + * @param {number} props.preClose - 昨收价 + * @param {string} props.eventTime - 事件发生时间(完整日期时间或时间) + * @param {boolean} props.loading - 加载状态 + */ +const MiniChart = memo(({ data = [], preClose, eventTime, loading }) => { + // 处理图表数据 + const chartData = useMemo(() => { + if (!data || data.length === 0) { + return null; + } + + const prices = data.map(d => d.price || d.close || 0).filter(p => p > 0); + if (prices.length === 0) return null; + + // 使用昨收价或第一个价格作为基准 + const effectivePreClose = preClose || data[0]?.prev_close || prices[0]; + + // 计算价格范围(以昨收为中心对称) + const maxDiff = Math.max( + Math.abs(Math.max(...prices) - effectivePreClose), + Math.abs(effectivePreClose - Math.min(...prices)), + effectivePreClose * 0.02 + ) * 1.1; + + const minPrice = effectivePreClose - maxDiff; + const maxPrice = effectivePreClose + maxDiff; + const priceRange = maxPrice - minPrice; + const lastPrice = prices[prices.length - 1]; + const isUp = lastPrice >= effectivePreClose; + + // 图表绘制区域 + const drawWidth = CHART_WIDTH - PADDING.left - PADDING.right; + const drawHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom; + + // 坐标转换函数 + const xScale = (timeStr) => { + const minutes = timeToMinutes(timeStr); + return PADDING.left + (minutes / TOTAL_TRADING_MINUTES) * drawWidth; + }; + const yScale = (price) => PADDING.top + ((maxPrice - price) / priceRange) * drawHeight; + + // 分时线点位 + const pricePoints = data.map(d => { + const price = d.price || d.close || effectivePreClose; + const time = d.time || ''; + return { + x: xScale(time), + y: yScale(price), + price, + time, + }; + }); + + // 计算昨收线 Y 坐标 + const preCloseY = yScale(effectivePreClose); + + // 计算事件时间竖线 X 坐标 + let eventLineX = null; + if (eventTime) { + const eventTimeOnly = extractTime(eventTime) || eventTime; + const eventMinutes = timeToMinutes(eventTimeOnly); + if (eventMinutes > 0) { + eventLineX = PADDING.left + (eventMinutes / TOTAL_TRADING_MINUTES) * drawWidth; + } + } + + return { + pricePoints, + preCloseY, + eventLineX, + isUp, + drawWidth, + drawHeight, + }; + }, [data, preClose, eventTime]); + + // 加载状态 + if (loading) { + return ( + + + + ); + } + + // 无数据状态 + if (!chartData || chartData.pricePoints.length === 0) { + return ( + + 暂无数据 + + ); + } + + const lineColor = chartData.isUp ? '#EF4444' : '#22C55E'; + const areaGradientId = chartData.isUp ? 'miniAreaUp' : 'miniAreaDown'; + + return ( + + + {/* 渐变定义 */} + + + + + + + + + + + + {/* 昨收参考虚线 */} + + + {/* 分时线填充区域 */} + + + {/* 分时线 */} + + + {/* 事件时间金色竖线 */} + {chartData.eventLineX !== null && ( + <> + {/* 竖线 */} + + {/* 顶部小三角标记 */} + + + )} + + + ); +}); + +MiniChart.displayName = 'MiniChart'; + +export default MiniChart; diff --git a/MeAgent/src/screens/Events/RelatedStocks.js b/MeAgent/src/screens/Events/RelatedStocks.js index 0b4120b7..4d63b7ea 100644 --- a/MeAgent/src/screens/Events/RelatedStocks.js +++ b/MeAgent/src/screens/Events/RelatedStocks.js @@ -1,9 +1,10 @@ /** * 相关股票组件 - HeroUI 风格 * 展示事件关联的股票列表 + * 带有分时迷你图和事件时间标记 */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useState, useEffect } from 'react'; import { StyleSheet } from 'react-native'; import * as ExpoClipboard from 'expo-clipboard'; import { @@ -21,6 +22,8 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons } from '@expo/vector-icons'; import { gradients } from '../../theme'; import { StockWatchlistButton } from '../../components/AddWatchlistButton'; +import { stockDetailService } from '../../services/stockService'; +import MiniChart from './MiniChart'; // 格式化涨跌幅 const formatChange = (value) => { @@ -55,100 +58,76 @@ const getRelationDesc = (relationDesc) => { }; // 单个股票项 -const StockItem = memo(({ stock, quote, index, total, onPress, onCopyCode }) => { +const StockItem = memo(({ stock, quote, index, total, onPress, onCopyCode, minuteData, eventTime, loadingMinute }) => { const isLast = index === total - 1; // 使用报价数据或股票数据 const stockName = quote?.name || stock.stock_name || stock.name || `股票`; const stockCode = stock.stock_code || stock.code || ''; - const price = quote?.price ?? stock.price; const change = quote?.change ?? stock.change_percent; const relationDesc = getRelationDesc(stock.relation_desc); return ( - onPress?.(stock)} + _pressed={{ opacity: 0.7 }} > - + {/* 左侧:股票信息 */} - onPress?.(stock)} - _pressed={{ opacity: 0.7 }} - > - - {/* 股票名称和代码在一行 */} - - - {stockName} - - - {stockCode} - - {/* 复制按钮 */} - { - e.stopPropagation?.(); - onCopyCode?.(stockCode); - }} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - _pressed={{ opacity: 0.5 }} - > - - - - - {/* 关联原因 - 简洁显示 */} + + {/* 第一行:代码 + 名称 */} + + + {stockCode} + + + {stockName} + + + {/* 第二行:涨跌幅 + 关联原因 */} + + + {formatChange(change)} + + {relationDesc && ( {relationDesc} )} - - + + - {/* 右侧:涨跌幅、价格和加自选 */} - - - - - {formatChange(change)} - - - {price != null && ( - - ¥{price.toFixed(2)} - - )} - - {/* 加自选按钮 */} - - + {/* 右侧:分时迷你图 */} + - + ); }); @@ -164,8 +143,53 @@ const RelatedStocks = ({ maxDisplay = 10, showAll = false, onShowAll, + eventTime = null, // 事件发生时间,用于在分时图上标记 }) => { const toast = useToast(); + const [minuteDataMap, setMinuteDataMap] = useState({}); // { stockCode: { data, prevClose } } + const [loadingMinute, setLoadingMinute] = useState(false); + + // 批量加载分时数据 + useEffect(() => { + const loadMinuteData = async () => { + if (!stocks || stocks.length === 0) return; + + setLoadingMinute(true); + const newMinuteDataMap = {}; + + // 只加载前 maxDisplay 只股票的分时数据,避免请求过多 + const stocksToLoad = showAll ? stocks : stocks.slice(0, maxDisplay); + + // 并行请求分时数据(限制并发数为5) + const batchSize = 5; + for (let i = 0; i < stocksToLoad.length; i += batchSize) { + const batch = stocksToLoad.slice(i, i + batchSize); + const promises = batch.map(async (stock) => { + const code = stock.stock_code || stock.code; + if (!code) return; + + try { + const result = await stockDetailService.getMinuteData(code); + if (result.success && result.data) { + newMinuteDataMap[code] = { + data: result.data, + prevClose: result.prevClose, + }; + } + } catch (error) { + console.error('[RelatedStocks] 加载分时数据失败:', code, error); + } + }); + + await Promise.all(promises); + } + + setMinuteDataMap(prev => ({ ...prev, ...newMinuteDataMap })); + setLoadingMinute(false); + }; + + loadMinuteData(); + }, [stocks, showAll, maxDisplay]); // 复制股票代码 const handleCopyCode = useCallback(async (code) => { @@ -259,17 +283,23 @@ const RelatedStocks = ({ {/* 股票列表 */} - {displayStocks.map((stock, index) => ( - - ))} + {displayStocks.map((stock, index) => { + const stockCode = stock.stock_code || stock.code; + return ( + + ); + })} {/* 报价加载中提示 */} diff --git a/MeAgent/src/screens/Market/EventCalendar.js b/MeAgent/src/screens/Market/EventCalendar.js index 14e201a9..f194db71 100644 --- a/MeAgent/src/screens/Market/EventCalendar.js +++ b/MeAgent/src/screens/Market/EventCalendar.js @@ -31,7 +31,7 @@ import ztService from '../../services/ztService'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); const CELL_WIDTH = (SCREEN_WIDTH - 32) / 7; -const CELL_HEIGHT = 95; // 增加高度以容纳跨天概念条 +const CELL_HEIGHT = 75; // 精简布局后的高度 // 概念颜色调色板 const CONCEPT_COLORS = [ @@ -97,7 +97,7 @@ const isNextTradingDay = (date1, date2) => { return false; }; -// 合并连续相同概念(跨周显示由分段逻辑处理) +// 合并连续相同概念(单天概念也保留显示) const mergeConsecutiveConcepts = (calendarData, year, month) => { const sorted = [...calendarData] .filter(d => d.topSector) @@ -119,8 +119,8 @@ const mergeConsecutiveConcepts = (calendarData, year, month) => { currentEvent.endDate = item.date; currentEvent.dates.push(item.date); } else { - // 保存之前的事件(如果有多天) - if (currentEvent && currentEvent.dates.length > 1) { + // 保存之前的事件(包括单天的) + if (currentEvent) { events.push(currentEvent); } // 开始新事件 @@ -133,8 +133,8 @@ const mergeConsecutiveConcepts = (calendarData, year, month) => { } }); - // 保存最后一个事件 - if (currentEvent && currentEvent.dates.length > 1) { + // 保存最后一个事件(包括单天的) + if (currentEvent) { events.push(currentEvent); } @@ -354,63 +354,52 @@ const EventCalendar = ({ navigation }) => { bg={isToday ? 'rgba(212, 175, 55, 0.15)' : 'rgba(15, 15, 22, 0.4)'} p={1} > - {/* 第一行:日期 + 涨跌幅 */} - - - {day} - - {data?.indexChange !== null && data?.indexChange !== undefined && ( - = 0 ? '#EF4444' : '#22C55E'} - > - {data.indexChange >= 0 ? '+' : ''}{data.indexChange?.toFixed(2)}% - - )} - + {/* 日期数字 */} + + {day} + - {/* 涨停数据 */} - {data?.ztCount > 0 && ( - - = 60 ? '#EF4444' : '#F59E0B'} - size={14} - /> - = 60 ? '#EF4444' : '#F59E0B'} - ml={1} - > - {data.ztCount} - - - )} - - {/* 事件数量 */} - {data?.eventCount > 0 && ( - - - - {data.eventCount} - - - - 事件 - + {/* 涨停数 + 事件数(合并一行) */} + {(data?.ztCount > 0 || data?.eventCount > 0) && ( + + {/* 涨停数 */} + {data?.ztCount > 0 && ( + + = 60 ? '#EF4444' : '#F59E0B'} + size={12} + /> + = 60 ? '#EF4444' : '#F59E0B'} + > + {data.ztCount} + + + )} + {/* 事件数 */} + {data?.eventCount > 0 && ( + + + + {data.eventCount} + + + + )} )} @@ -453,10 +442,10 @@ const EventCalendar = ({ navigation }) => { // 计算位置和尺寸 const left = startCol * CELL_WIDTH + 2; const width = (endCol - startCol + 1) * CELL_WIDTH - 4; - // 根据堆叠索引调整垂直位置(每个概念条高度 20,间隔 2) - const barHeight = 20; + // 根据堆叠索引调整垂直位置(每个概念条高度 18,间隔 2) + const barHeight = 18; const verticalOffset = stackIndex * (barHeight + 2); - const top = row * CELL_HEIGHT + CELL_HEIGHT - 26 - verticalOffset; + const top = row * CELL_HEIGHT + CELL_HEIGHT - 22 - verticalOffset; return ( { styles.conceptBar, { // 根据是否是起始/结束段调整圆角 - borderTopLeftRadius: isStart ? 6 : 0, - borderBottomLeftRadius: isStart ? 6 : 0, - borderTopRightRadius: segment.isEnd ? 6 : 0, - borderBottomRightRadius: segment.isEnd ? 6 : 0, + borderTopLeftRadius: isStart ? 5 : 0, + borderBottomLeftRadius: isStart ? 5 : 0, + borderTopRightRadius: segment.isEnd ? 5 : 0, + borderBottomRightRadius: segment.isEnd ? 5 : 0, }, ]} > {concept} {totalDays > 1 && isStart && ( - + ({totalDays}天) )} @@ -661,7 +650,7 @@ const EventCalendar = ({ navigation }) => { rounded="xl" p={3} > - + { end={{ x: 1, y: 0 }} style={styles.legendBar} /> - 连续热门概念 - - - - 涨停≥60 + 热门概念 - 涨停<60 + 涨停数 { > N - 未来事件 - - - +0.5% - / - -0.5% - 上证涨跌 + 事件数 @@ -721,10 +700,10 @@ const styles = StyleSheet.create({ borderRadius: 16, }, conceptBar: { - height: 20, + height: 18, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: 6, + paddingHorizontal: 4, }, legendBar: { width: 20, diff --git a/MeAgent/src/screens/StockDetail/StockDetailScreen.js b/MeAgent/src/screens/StockDetail/StockDetailScreen.js index 22a15dd3..33620007 100644 --- a/MeAgent/src/screens/StockDetail/StockDetailScreen.js +++ b/MeAgent/src/screens/StockDetail/StockDetailScreen.js @@ -28,6 +28,7 @@ import { stockDetailService } from '../../services/stockService'; import { useWatchlist } from '../../hooks/useWatchlist'; import { useSingleQuote } from '../../hooks/useRealtimeQuote'; +import AddWatchlistModal from '../Watchlist/AddWatchlistModal'; import { fetchStockDetail, fetchMinuteData, @@ -64,6 +65,7 @@ const StockDetailScreen = () => { const [selectedAnalysis, setSelectedAnalysis] = useState(null); const [analysisModalOpen, setAnalysisModalOpen] = useState(false); const [fallbackOrderBook, setFallbackOrderBook] = useState(null); // API 降级盘口数据 + const [searchModalOpen, setSearchModalOpen] = useState(false); // 搜索弹窗 // Redux 状态 const currentStock = useSelector(selectCurrentStock); @@ -251,6 +253,16 @@ const StockDetailScreen = () => { setSelectedAnalysis(null); }, []); + // 打开搜索弹窗 + const handleOpenSearch = useCallback(() => { + setSearchModalOpen(true); + }, []); + + // 关闭搜索弹窗 + const handleCloseSearch = useCallback(() => { + setSearchModalOpen(false); + }, []); + // 获取当前图表数据 const currentChartData = useMemo(() => { if (chartType === 'minute') { @@ -293,6 +305,7 @@ const StockDetailScreen = () => { isInWatchlist={inWatchlist} onToggleWatchlist={handleToggleWatchlist} onBack={handleBack} + onSearch={handleOpenSearch} isRealtime={wsConnected && !!realtimeQuote?.current_price} /> @@ -357,6 +370,12 @@ const StockDetailScreen = () => { onClose={handleCloseAnalysisModal} analysis={selectedAnalysis} /> + + {/* 搜索股票弹窗 */} + ); diff --git a/MeAgent/src/screens/StockDetail/components/PriceHeader.js b/MeAgent/src/screens/StockDetail/components/PriceHeader.js index 77f8054c..8d2a8c8a 100644 --- a/MeAgent/src/screens/StockDetail/components/PriceHeader.js +++ b/MeAgent/src/screens/StockDetail/components/PriceHeader.js @@ -82,6 +82,7 @@ const PriceHeader = memo(({ isInWatchlist, onToggleWatchlist, onBack, + onSearch, // 搜索按钮点击回调 isRealtime = false, // 是否正在接收实时数据 }) => { const { @@ -154,7 +155,7 @@ const PriceHeader = memo(({ color={isInWatchlist ? '#F59E0B' : 'gray.400'} /> - + diff --git a/MeAgent/src/screens/Watchlist/AddWatchlistModal.js b/MeAgent/src/screens/Watchlist/AddWatchlistModal.js index b4990f2c..deb65e1f 100644 --- a/MeAgent/src/screens/Watchlist/AddWatchlistModal.js +++ b/MeAgent/src/screens/Watchlist/AddWatchlistModal.js @@ -3,7 +3,7 @@ * 支持搜索股票并添加到自选 */ -import React, { useState, useCallback, useRef, useMemo, memo } from 'react'; +import React, { useState, useCallback, useRef, useMemo, memo, useEffect } from 'react'; import { Keyboard, ActivityIndicator, Dimensions } from 'react-native'; import { Modal, @@ -26,10 +26,35 @@ const { height: SCREEN_HEIGHT } = Dimensions.get('window'); // 列表项高度常量(用于 getItemLayout 优化) const ITEM_HEIGHT = 56; +// 格式化价格 +const formatPrice = (price) => { + if (price === undefined || price === null || price === 0) return '--'; + return Number(price).toFixed(2); +}; + +// 格式化涨跌幅 +const formatChange = (change) => { + if (change === undefined || change === null) return '--'; + const sign = change > 0 ? '+' : ''; + return `${sign}${Number(change).toFixed(2)}%`; +}; + +// 获取涨跌颜色 +const getChangeColor = (change) => { + if (change > 0) return '#EF4444'; // 红色 + if (change < 0) return '#22C55E'; // 绿色 + return '#94A3B8'; // 灰色 +}; + // 搜索结果项组件 - 使用 memo 避免不必要的重渲染 -const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => { +const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded, quote }) => { const { stock_code, stock_name, industry } = item; + // 行情数据(优先使用传入的 quote,否则用 item 自带的) + const price = quote?.price || quote?.current_price || item.price || item.current_price; + const changePercent = quote?.change_percent ?? quote?.change ?? item.change_percent ?? item.change; + const changeColor = getChangeColor(changePercent); + const handlePress = useCallback(() => { if (!alreadyAdded && !isAdding) { onAdd(item); @@ -50,6 +75,7 @@ const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => { bg={pressed && !alreadyAdded ? 'rgba(255,255,255,0.05)' : 'transparent'} opacity={alreadyAdded ? 0.5 : 1} > + {/* 左侧:股票名称和代码 */} {stock_name} @@ -66,6 +92,17 @@ const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => { + {/* 中间:价格和涨跌幅 */} + + + {formatPrice(price)} + + + {formatChange(changePercent)} + + + + {/* 右侧:操作按钮 */} {alreadyAdded ? ( { const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [addingCode, setAddingCode] = useState(null); + const [quotes, setQuotes] = useState({}); // 行情数据缓存 const searchTimeoutRef = useRef(null); const abortControllerRef = useRef(null); const toast = useToast(); const { addStock, isInWatchlist, stocks } = useWatchlist({ autoLoad: false }); + // 获取股票行情 + const fetchQuotes = useCallback(async (codes) => { + if (!codes || codes.length === 0) return; + + try { + const result = await stockDetailService.getQuotes(codes); + if (result.success && result.data) { + setQuotes(prev => ({ ...prev, ...result.data })); + } + } catch (error) { + console.error('[AddWatchlistModal] 获取行情失败:', error); + } + }, []); + + // 弹窗打开时获取热门股票行情 + useEffect(() => { + if (isOpen) { + const hotCodes = HOT_STOCKS.map(s => s.stock_code); + fetchQuotes(hotCodes); + } + }, [isOpen, fetchQuotes]); + // 创建已添加股票代码的 Set 用于快速查找 const watchlistCodesSet = useMemo(() => { const normalizeCode = (code) => String(code).match(/\d{6}/)?.[0] || code; @@ -176,6 +236,11 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { const response = await stockDetailService.searchStocks(text, 20); if (response.success && Array.isArray(response.data)) { setSearchResults(response.data); + // 获取搜索结果的行情数据 + const codes = response.data.map(s => s.stock_code).filter(Boolean); + if (codes.length > 0) { + fetchQuotes(codes); + } } else { setSearchResults([]); } @@ -188,7 +253,7 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { setIsSearching(false); } }, 200); - }, []); + }, [fetchQuotes]); // 添加到自选 const handleAdd = useCallback(async (stock) => { @@ -234,6 +299,7 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { setSearchText(''); setSearchResults([]); setIsSearching(false); + setQuotes({}); // 清理行情缓存 onClose?.(); }, [onClose]); @@ -253,14 +319,24 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { const keyExtractor = useCallback((item) => item.stock_code, []); + // 获取股票行情(支持多种代码格式) + const getQuote = useCallback((stockCode) => { + if (!stockCode) return null; + // 尝试多种格式匹配 + const pureCode = String(stockCode).match(/\d{6}/)?.[0]; + return quotes[stockCode] || quotes[pureCode] || + quotes[`${pureCode}.SH`] || quotes[`${pureCode}.SZ`] || null; + }, [quotes]); + const renderItem = useCallback(({ item }) => ( - ), [handleAdd, addingCode, checkInWatchlist]); + ), [handleAdd, addingCode, checkInWatchlist, getQuote]); return ( codes.add(code)); + this.managers.szse.subscriptions.forEach(code => codes.add(code)); + return codes; + } + /** * 处理行情消息(参考 Web 端格式) * 消息格式: { type: 'stock' | 'index', data: { code: quote, ... } } */ _handleQuoteMessage(message, exchange) { - // 调试:打印收到的原始消息 - if (this._msgLogCount < 5) { - console.log(`[RealtimeQuote] 收到${exchange}消息:`, JSON.stringify(message).substring(0, 800)); - this._msgLogCount++; - } - // 心跳响应 if (message.type === 'pong') return; @@ -499,9 +504,35 @@ class RealtimeQuoteService { // 处理行情数据 // 格式: { type: 'stock' | 'index', data: { '603199': {...}, ... } } if ((message.type === 'stock' || message.type === 'index') && message.data) { - const quotes = this._parseQuoteData(message.data, exchange); + // 获取已订阅的股票代码 + const subscribedCodes = this._getSubscribedCodes(); + + // 如果没有订阅任何股票,跳过处理 + if (subscribedCodes.size === 0) { + return; + } + + // 只解析已订阅的股票数据 + const filteredData = {}; + Object.entries(message.data).forEach(([code, quote]) => { + const pureCode = normalizeCode(code); + if (subscribedCodes.has(pureCode)) { + filteredData[code] = quote; + } + }); + + // 如果过滤后没有数据,跳过 + if (Object.keys(filteredData).length === 0) { + return; + } + + const quotes = this._parseQuoteData(filteredData, exchange); if (Object.keys(quotes).length > 0) { - console.log('[RealtimeQuote] 处理行情:', Object.keys(quotes).length, '只股票'); + // 减少日志输出,只在调试时打印 + if (this._msgLogCount < 3) { + console.log('[RealtimeQuote] 处理行情:', Object.keys(quotes).length / 2, '只股票'); + this._msgLogCount++; + } this._notifyQuoteHandlers(quotes); } } diff --git a/MeAgent/src/services/ztService.js b/MeAgent/src/services/ztService.js index cfb4eb0f..a035f71d 100644 --- a/MeAgent/src/services/ztService.js +++ b/MeAgent/src/services/ztService.js @@ -266,15 +266,15 @@ export const ztService = { }, /** - * 快速获取日历数据(从 API 获取完整信息包括 top_sector) + * 快速获取日历数据(从 combined-data API 获取完整信息包括 top_sector) * @param {number} year - 年份 * @param {number} month - 月份 (1-12) * @returns {Promise} 日历数据 */ getCalendarDataFast: async (year, month) => { try { - // 使用后端日历 API 获取包含 top_sector 的完整数据 - const response = await apiRequest(`/api/zt/calendar?year=${year}&month=${month}`); + // 使用 combined-data API(与 Web 端一致) + const response = await apiRequest(`/api/v1/calendar/combined-data?year=${year}&month=${month}`); if (response.success && response.data) { const calendarData = response.data.map(d => ({ diff --git a/MeAgent/src/store/slices/eventsSlice.js b/MeAgent/src/store/slices/eventsSlice.js index 70fa7495..e416d948 100644 --- a/MeAgent/src/store/slices/eventsSlice.js +++ b/MeAgent/src/store/slices/eventsSlice.js @@ -235,8 +235,10 @@ const eventsSlice = createSlice({ if (refresh || pagination.page === 1) { state.events = events; } else { - // 加载更多时追加 - state.events = [...state.events, ...events]; + // 加载更多时追加,去除重复事件 + const existingIds = new Set(state.events.map(e => e.id)); + const newEvents = events.filter(e => !existingIds.has(e.id)); + state.events = [...state.events, ...newEvents]; } state.pagination = pagination; state.loading.events = false; diff --git a/app.py b/app.py index abdaef6c..2baa15d9 100755 --- a/app.py +++ b/app.py @@ -505,6 +505,15 @@ STOCK_NAME_EXPIRE = 86400 # 股票名称缓存24小时 PREV_CLOSE_PREFIX = "vf:stock:prev_close:" # 前收盘价缓存前缀 PREV_CLOSE_EXPIRE = 86400 # 前收盘价缓存24小时(当日有效) +# ==================== 行情数据缓存配置 ==================== +QUOTE_CACHE_PREFIX = "vf:quote:" # 实时行情缓存前缀 +QUOTE_CACHE_TTL_TRADING = 5 # 交易时间内缓存 5 秒 +QUOTE_CACHE_TTL_CLOSED = 60 # 盘后缓存 60 秒 + +MINUTE_CACHE_PREFIX = "vf:minute:" # 分时数据缓存前缀 +MINUTE_CACHE_TTL_TRADING = 30 # 交易时间内缓存 30 秒 +MINUTE_CACHE_TTL_CLOSED = 300 # 盘后缓存 5 分钟 + def get_cached_stock_names(base_codes): """ @@ -623,6 +632,119 @@ def get_cached_prev_close(base_codes, trade_date_str): return result +def is_trading_time(): + """ + 判断当前是否在交易时间内 + 交易时间:周一至周五 9:30-11:30, 13:00-15:00 + """ + now = beijing_now() + weekday = now.weekday() # 0=周一, 6=周日 + if weekday >= 5: # 周末 + return False + + current_time = now.time() + morning_start = dt_time(9, 30) + morning_end = dt_time(11, 30) + afternoon_start = dt_time(13, 0) + afternoon_end = dt_time(15, 0) + + return (morning_start <= current_time <= morning_end or + afternoon_start <= current_time <= afternoon_end) + + +def get_cached_quotes(codes): + """ + 批量获取实时行情缓存 + :param codes: 股票代码列表(带后缀,如 ['600000.SH', '000001.SZ']) + :return: (cached_data, missing_codes) - 缓存的数据和未命中的代码 + """ + if not codes: + return {}, [] + + cached_data = {} + missing_codes = [] + today_str = beijing_now().strftime('%Y%m%d') + + try: + pipe = redis_client.pipeline() + for code in codes: + pipe.get(f"{QUOTE_CACHE_PREFIX}{today_str}:{code}") + cached_values = pipe.execute() + + for code, cached_json in zip(codes, cached_values): + if cached_json: + try: + cached_data[code] = json.loads(cached_json) + except: + missing_codes.append(code) + else: + missing_codes.append(code) + except Exception as e: + print(f"⚠️ Redis 获取行情缓存失败: {e}") + return {}, codes + + return cached_data, missing_codes + + +def set_cached_quotes(quotes_data): + """ + 批量设置实时行情缓存 + :param quotes_data: dict {code: quote_dict} + """ + if not quotes_data: + return + + today_str = beijing_now().strftime('%Y%m%d') + ttl = QUOTE_CACHE_TTL_TRADING if is_trading_time() else QUOTE_CACHE_TTL_CLOSED + + try: + pipe = redis_client.pipeline() + for code, quote in quotes_data.items(): + cache_key = f"{QUOTE_CACHE_PREFIX}{today_str}:{code}" + pipe.setex(cache_key, ttl, json.dumps(quote, ensure_ascii=False)) + pipe.execute() + except Exception as e: + print(f"⚠️ Redis 缓存行情数据失败: {e}") + + +def get_cached_minute_data(stock_code): + """ + 获取分时数据缓存 + :param stock_code: 股票代码(带后缀) + :return: 缓存的分时数据或 None + """ + today_str = beijing_now().strftime('%Y%m%d') + cache_key = f"{MINUTE_CACHE_PREFIX}{today_str}:{stock_code}" + + try: + cached = redis_client.get(cache_key) + if cached: + return json.loads(cached) + except Exception as e: + print(f"⚠️ Redis 获取分时缓存失败: {e}") + + return None + + +def set_cached_minute_data(stock_code, minute_data): + """ + 设置分时数据缓存 + :param stock_code: 股票代码(带后缀) + :param minute_data: 分时数据字典 + """ + if not minute_data: + return + + today_str = beijing_now().strftime('%Y%m%d') + cache_key = f"{MINUTE_CACHE_PREFIX}{today_str}:{stock_code}" + ttl = MINUTE_CACHE_TTL_TRADING if is_trading_time() else MINUTE_CACHE_TTL_CLOSED + + try: + redis_client.setex(cache_key, ttl, json.dumps(minute_data, ensure_ascii=False)) + except Exception as e: + print(f"⚠️ Redis 缓存分时数据失败: {e}") + + def preload_stock_cache(): """ 预热股票缓存(定时任务,每天 9:25 执行) @@ -8786,16 +8908,29 @@ def get_flex_screen_quotes(): if not codes: return jsonify({'success': False, 'error': '请提供股票代码'}), 400 + # ==================== 先检查 Redis 缓存 ==================== + cached_results, missing_codes = get_cached_quotes(codes) + + if not missing_codes: + # 全部命中缓存,直接返回 + return jsonify({ + 'success': True, + 'data': cached_results, + 'source': 'cache' + }) + + # 部分命中,只查询未命中的代码 + results = cached_results.copy() + client = get_clickhouse_client() - results = {} source = 'realtime' - # 分离上交所和深交所代码 + # 分离上交所和深交所代码(只处理未命中缓存的) sse_codes = [] # 上交所 szse_stock_codes = [] # 深交所股票 szse_index_codes = [] # 深交所指数 - for code in codes: + for code in missing_codes: base_code = code.split('.')[0] if code.endswith('.SH'): sse_codes.append(base_code) @@ -8806,17 +8941,9 @@ def get_flex_screen_quotes(): else: szse_stock_codes.append(base_code) - # 获取股票名称 - 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() - stock_names = {row[0]: row[1] for row in result} + # 获取股票名称(只查询缺失代码,使用缓存) + base_codes = list(set([code.split('.')[0] for code in missing_codes])) + stock_names = get_cached_stock_names(base_codes) if base_codes else {} # 查询深交所股票实时行情 if szse_stock_codes: @@ -9078,10 +9205,15 @@ def get_flex_screen_quotes(): except Exception as e: print(f"查询分钟线数据失败: {e}") + # ==================== 将新查询的数据写入缓存 ==================== + new_data_to_cache = {code: quote for code, quote in results.items() if code in missing_codes} + if new_data_to_cache: + set_cached_quotes(new_data_to_cache) + return jsonify({ 'success': True, 'data': results, - 'source': source + 'source': source if not cached_results else 'mixed' }) except Exception as e: @@ -9665,9 +9797,8 @@ def get_latest_minute_data(stock_code): - 如果是当天交易日且在交易时间内,只返回到当前时间的数据 - 返回昨收价 prev_close,供前端计算涨跌幅 - 返回 is_trading 标志指示当前是否在交易中 + - 使用 Redis 缓存减少 ClickHouse 查询压力 """ - client = get_clickhouse_client() - # 确保股票代码包含后缀 if '.' not in stock_code: if stock_code.startswith('6'): @@ -9679,14 +9810,17 @@ def get_latest_minute_data(stock_code): base_code = stock_code.split('.')[0] - # 获取股票名称和昨收价 + # ==================== 检查 Redis 缓存 ==================== + cached_data = get_cached_minute_data(stock_code) + if cached_data: + return jsonify(cached_data) + + client = get_clickhouse_client() + + # 获取股票名称(使用缓存) + stock_names = get_cached_stock_names([base_code]) + stock_name = stock_names.get(base_code, 'Unknown') prev_close = None - stock_name = 'Unknown' - with engine.connect() as conn: - result = conn.execute(text( - "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" - ), {"code": base_code}).fetchone() - stock_name = result[0] if result else 'Unknown' # 查找最近30天内有数据的最新交易日 target_date = None @@ -9827,7 +9961,8 @@ def get_latest_minute_data(stock_code): 'change_pct': round(calculated_change_pct, 2) }) - return jsonify({ + # 构建响应数据 + response_data = { 'code': stock_code, 'name': stock_name, 'data': kline_data, @@ -9836,7 +9971,12 @@ def get_latest_minute_data(stock_code): 'is_latest': True, 'prev_close': prev_close, 'is_trading': is_trading - }) + } + + # ==================== 写入 Redis 缓存 ==================== + set_cached_minute_data(stock_code, response_data) + + return jsonify(response_data) @app.route('/api/stock//forecast-report', methods=['GET'])