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 (
+
+
+
+ );
+});
+
+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